317 lines
9.0 KiB
JavaScript
317 lines
9.0 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const xlsx = require('xlsx');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Ensure uploads folder exists
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
const metadataFile = path.join(uploadsDir, 'metadata.json');
|
|
|
|
function loadMetadata() {
|
|
try {
|
|
if (fs.existsSync(metadataFile)) {
|
|
const data = fs.readFileSync(metadataFile, 'utf8');
|
|
return JSON.parse(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed reading metadata:', err);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function saveMetadata(metadata) {
|
|
try {
|
|
fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2), 'utf8');
|
|
} catch (err) {
|
|
console.error('Failed saving metadata:', err);
|
|
}
|
|
}
|
|
|
|
// Multer storage configuration
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, uploadsDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const timestamp = Date.now();
|
|
const safeName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
cb(null, `${timestamp}-${safeName}`);
|
|
}
|
|
});
|
|
|
|
// File filter: accept only .xlsx and .xls
|
|
const fileFilter = (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
if (ext === '.xlsx' || ext === '.xls') {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Invalid file type. Only Excel files (.xlsx and .xls) are allowed.'), false);
|
|
}
|
|
};
|
|
|
|
const upload = multer({
|
|
storage,
|
|
fileFilter,
|
|
limits: {
|
|
fileSize: 100 * 1024 * 1024 // 100MB limit
|
|
}
|
|
});
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// 1. Get list of Excel files
|
|
app.get('/api/files', (req, res) => {
|
|
try {
|
|
const metadata = loadMetadata();
|
|
// Filter to ensure files actually exist on disk
|
|
const existing = metadata.filter(item => {
|
|
const filePath = path.join(uploadsDir, item.id);
|
|
return fs.existsSync(filePath);
|
|
});
|
|
if (existing.length !== metadata.length) {
|
|
saveMetadata(existing);
|
|
}
|
|
res.json({ files: existing });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to retrieve files: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// 2. Upload an Excel file
|
|
app.post('/api/upload', (req, res) => {
|
|
upload.single('file')(req, res, (err) => {
|
|
if (err) {
|
|
return res.status(400).json({ error: err.message || 'File upload failed.' });
|
|
}
|
|
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No Excel file was uploaded.' });
|
|
}
|
|
|
|
const filePath = req.file.path;
|
|
let workbook;
|
|
|
|
try {
|
|
// Validate that it is a valid, readable Excel file
|
|
workbook = xlsx.readFile(filePath, { cellDates: true });
|
|
if (!workbook || !workbook.SheetNames || workbook.SheetNames.length === 0) {
|
|
throw new Error('Excel file contains no worksheets.');
|
|
}
|
|
} catch (parseErr) {
|
|
// Remove invalid/corrupt uploaded file
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
} catch (e) {}
|
|
return res.status(400).json({
|
|
error: 'Invalid or corrupted Excel file. Please upload a valid .xlsx or .xls file.'
|
|
});
|
|
}
|
|
|
|
const fileRecord = {
|
|
id: req.file.filename,
|
|
originalName: req.file.originalname,
|
|
size: req.file.size,
|
|
uploadedAt: new Date().toISOString(),
|
|
sheetNames: workbook.SheetNames
|
|
};
|
|
|
|
const metadata = loadMetadata();
|
|
metadata.unshift(fileRecord);
|
|
saveMetadata(metadata);
|
|
|
|
res.json({ success: true, file: fileRecord });
|
|
});
|
|
});
|
|
|
|
// 3. Get Excel file details and worksheets
|
|
app.get('/api/files/:id', (req, res) => {
|
|
const fileId = req.params.id;
|
|
const filePath = path.join(uploadsDir, fileId);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'File not found on server.' });
|
|
}
|
|
|
|
const metadata = loadMetadata();
|
|
const fileRecord = metadata.find(f => f.id === fileId);
|
|
|
|
try {
|
|
const workbook = xlsx.readFile(filePath, { bookSheets: true });
|
|
res.json({
|
|
id: fileId,
|
|
originalName: fileRecord ? fileRecord.originalName : fileId,
|
|
size: fileRecord ? fileRecord.size : fs.statSync(filePath).size,
|
|
uploadedAt: fileRecord ? fileRecord.uploadedAt : null,
|
|
sheetNames: workbook.SheetNames
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to read Excel workbook: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// 4. Get specific sheet data from an Excel file
|
|
app.get('/api/files/:id/sheet', (req, res) => {
|
|
const fileId = req.params.id;
|
|
const sheetNameParam = req.query.name;
|
|
const filePath = path.join(uploadsDir, fileId);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'File not found on server.' });
|
|
}
|
|
|
|
try {
|
|
const workbook = xlsx.readFile(filePath, {
|
|
cellDates: true,
|
|
cellText: true,
|
|
raw: false
|
|
});
|
|
|
|
const targetSheetName = sheetNameParam || workbook.SheetNames[0];
|
|
|
|
if (!workbook.SheetNames.includes(targetSheetName)) {
|
|
return res.status(404).json({ error: `Sheet "${targetSheetName}" not found.` });
|
|
}
|
|
|
|
const worksheet = workbook.Sheets[targetSheetName];
|
|
|
|
// Convert sheet to array of rows (header: 1 gives 2D array: [ [cellA1, cellB1...], ... ])
|
|
const rows = xlsx.utils.sheet_to_json(worksheet, {
|
|
header: 1,
|
|
defval: '',
|
|
blankrows: true,
|
|
raw: false
|
|
});
|
|
|
|
// Determine max columns across all rows
|
|
let maxCols = 0;
|
|
for (let i = 0; i < rows.length; i++) {
|
|
if (rows[i].length > maxCols) {
|
|
maxCols = rows[i].length;
|
|
}
|
|
}
|
|
|
|
// Standardize all rows to have the same number of columns for grid consistency
|
|
const normalizedRows = rows.map(row => {
|
|
const newRow = new Array(maxCols);
|
|
for (let c = 0; c < maxCols; c++) {
|
|
newRow[c] = (row[c] !== undefined && row[c] !== null) ? String(row[c]) : '';
|
|
}
|
|
return newRow;
|
|
});
|
|
|
|
res.json({
|
|
sheetName: targetSheetName,
|
|
sheetNames: workbook.SheetNames,
|
|
totalRows: normalizedRows.length,
|
|
totalCols: maxCols,
|
|
rows: normalizedRows
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to parse sheet data: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// 5. Save edited sheet data back to the Excel file
|
|
app.post('/api/files/:id/sheet', (req, res) => {
|
|
const fileId = req.params.id;
|
|
const { sheetName, rows } = req.body;
|
|
const filePath = path.join(uploadsDir, fileId);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'File not found on server.' });
|
|
}
|
|
|
|
if (!sheetName || !Array.isArray(rows)) {
|
|
return res.status(400).json({ error: 'Invalid sheet data. sheetName and rows array are required.' });
|
|
}
|
|
|
|
try {
|
|
const workbook = xlsx.readFile(filePath);
|
|
|
|
if (!workbook.SheetNames.includes(sheetName)) {
|
|
return res.status(404).json({ error: `Sheet "${sheetName}" not found in workbook.` });
|
|
}
|
|
|
|
// Convert values to numbers if numeric, or strings
|
|
const formattedRows = rows.map(row => {
|
|
if (!Array.isArray(row)) return [];
|
|
return row.map(cell => {
|
|
if (cell === null || cell === undefined) return '';
|
|
const str = String(cell).trim();
|
|
if (str === '') return '';
|
|
// If string represents a clean number, parse it
|
|
if (/^-?\d+(\.\d+)?$/.test(str) && !isNaN(Number(str))) {
|
|
return Number(str);
|
|
}
|
|
return cell;
|
|
});
|
|
});
|
|
|
|
const newWorksheet = xlsx.utils.aoa_to_sheet(formattedRows);
|
|
workbook.Sheets[sheetName] = newWorksheet;
|
|
|
|
xlsx.writeFile(workbook, filePath);
|
|
|
|
// Update metadata file size and timestamp
|
|
const stats = fs.statSync(filePath);
|
|
const metadata = loadMetadata();
|
|
const fileRecord = metadata.find(f => f.id === fileId);
|
|
if (fileRecord) {
|
|
fileRecord.size = stats.size;
|
|
fileRecord.updatedAt = new Date().toISOString();
|
|
saveMetadata(metadata);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Worksheet "${sheetName}" saved successfully!`,
|
|
size: stats.size
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to save worksheet: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// 5. Download original Excel file
|
|
app.get('/api/files/:id/download', (req, res) => {
|
|
const fileId = req.params.id;
|
|
const filePath = path.join(uploadsDir, fileId);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'File not found on server.' });
|
|
}
|
|
|
|
const metadata = loadMetadata();
|
|
const fileRecord = metadata.find(f => f.id === fileId);
|
|
const downloadName = fileRecord ? fileRecord.originalName : fileId;
|
|
|
|
// Use res.download to preserve exact original file and content
|
|
res.download(filePath, downloadName, (err) => {
|
|
if (err && !res.headersSent) {
|
|
res.status(500).json({ error: 'Failed downloading file: ' + err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Fallback to index.html
|
|
app.use((req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`Excel Management Server running at http://localhost:${PORT}`);
|
|
});
|