Skip to main content
Glama
excel.ts17.7 kB
/** * Excel file handler using ExcelJS * Handles reading, writing, and editing Excel files (.xlsx, .xls, .xlsm) */ import ExcelJS from 'exceljs'; import fs from 'fs/promises'; import { FileHandler, ReadOptions, FileResult, EditResult, FileInfo, ExcelSheet } from './base.js'; // File size limit: 10MB const FILE_SIZE_LIMIT = 10 * 1024 * 1024; /** * Excel file metadata (internal use only) */ interface ExcelMetadata { sheets: ExcelSheet[]; fileSize: number; isLargeFile: boolean; } /** * Excel file handler implementation using ExcelJS * Supports: .xlsx, .xls, .xlsm files */ export class ExcelFileHandler implements FileHandler { canHandle(path: string): boolean { const ext = path.toLowerCase(); return ext.endsWith('.xlsx') || ext.endsWith('.xls') || ext.endsWith('.xlsm'); } async read(path: string, options?: ReadOptions): Promise<FileResult> { await this.checkFileSize(path); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(path); const metadata = await this.extractMetadata(workbook, path); const { sheetName, data, totalRows, returnedRows } = this.worksheetToArray( workbook, options?.sheet, options?.range, options?.offset, options?.length ); // Format output with sheet info header, usage hint, and JSON data const paginationInfo = totalRows > returnedRows ? `\n[Showing rows ${(options?.offset || 0) + 1}-${(options?.offset || 0) + returnedRows} of ${totalRows} total. Use offset/length to paginate.]` : ''; const content = `[Sheet: '${sheetName}' from ${path}]${paginationInfo} [To MODIFY cells: use edit_block with range param, e.g., edit_block(path, {range: "Sheet1!E5", content: [[newValue]]})] ${JSON.stringify(data)}`; return { content, mimeType: 'application/json', metadata: { isExcelFile: true, sheets: metadata.sheets, fileSize: metadata.fileSize, isLargeFile: metadata.isLargeFile } }; } async write(path: string, content: any, mode?: 'rewrite' | 'append'): Promise<void> { // Check existing file size if it exists try { await this.checkFileSize(path); } catch (error) { // File doesn't exist - that's fine for write if ((error as any).code !== 'ENOENT' && !(error instanceof Error && error.message.includes('ENOENT'))) { throw error; } } // Parse content let parsedContent = content; if (typeof content === 'string') { try { parsedContent = JSON.parse(content); } catch { throw new Error('Invalid content format. Expected JSON string with 2D array or object with sheet names.'); } } // Handle append mode by finding last row and writing after it if (mode === 'append') { try { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(path); if (Array.isArray(parsedContent)) { // Append to Sheet1 let worksheet = workbook.getWorksheet('Sheet1'); if (!worksheet) { worksheet = workbook.addWorksheet('Sheet1'); } const startRow = (worksheet.actualRowCount || 0) + 1; this.writeRowsStartingAt(worksheet, startRow, parsedContent); } else if (typeof parsedContent === 'object' && parsedContent !== null) { // Append to each named sheet for (const [sheetName, data] of Object.entries(parsedContent)) { if (Array.isArray(data)) { let worksheet = workbook.getWorksheet(sheetName); if (!worksheet) { worksheet = workbook.addWorksheet(sheetName); } const startRow = (worksheet.actualRowCount || 0) + 1; this.writeRowsStartingAt(worksheet, startRow, data as any[][]); } } } await workbook.xlsx.writeFile(path); return; } catch (error) { // File doesn't exist - fall through to create new file if ((error as any).code !== 'ENOENT' && !(error instanceof Error && error.message.includes('ENOENT'))) { throw error; } } } // Rewrite mode (or append to non-existent file): create new workbook const workbook = new ExcelJS.Workbook(); if (Array.isArray(parsedContent)) { // Single sheet from 2D array this.writeDataToSheet(workbook, 'Sheet1', parsedContent); } else if (typeof parsedContent === 'object' && parsedContent !== null) { // Object with sheet names as keys for (const [sheetName, data] of Object.entries(parsedContent)) { if (Array.isArray(data)) { this.writeDataToSheet(workbook, sheetName, data as any[][]); } } } else { throw new Error('Invalid content format. Expected 2D array or object with sheet names.'); } await workbook.xlsx.writeFile(path); } async editRange(path: string, range: string, content: any, options?: Record<string, any>): Promise<EditResult> { // Verify file exists and check size try { await this.checkFileSize(path); } catch (error) { if ((error as any).code === 'ENOENT' || (error instanceof Error && error.message.includes('ENOENT'))) { throw new Error(`File not found: ${path}`); } throw error; } // Validate content if (!Array.isArray(content)) { throw new Error('Content must be a 2D array for range editing'); } // Parse range: "Sheet1!A1:C10" or "Sheet1" const [sheetName, cellRange] = this.parseRange(range); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(path); // Get or create sheet let worksheet = workbook.getWorksheet(sheetName); if (!worksheet) { worksheet = workbook.addWorksheet(sheetName); } if (cellRange) { // Write to specific range const { startRow, startCol } = this.parseCellRange(cellRange); for (let r = 0; r < content.length; r++) { const rowData = content[r]; if (!Array.isArray(rowData)) continue; for (let c = 0; c < rowData.length; c++) { const cell = worksheet.getCell(startRow + r, startCol + c); const value = rowData[c]; if (typeof value === 'string' && value.startsWith('=')) { cell.value = { formula: value.substring(1) }; } else { cell.value = value; } } } } else { // Replace entire sheet content // Clear existing data worksheet.eachRow((row, rowNumber) => { row.eachCell((cell) => { cell.value = null; }); }); // Write new data for (let r = 0; r < content.length; r++) { const rowData = content[r]; if (!Array.isArray(rowData)) continue; const row = worksheet.getRow(r + 1); for (let c = 0; c < rowData.length; c++) { const value = rowData[c]; if (typeof value === 'string' && value.startsWith('=')) { row.getCell(c + 1).value = { formula: value.substring(1) }; } else { row.getCell(c + 1).value = value; } } row.commit(); } } await workbook.xlsx.writeFile(path); return { success: true, editsApplied: 1 }; } async getInfo(path: string): Promise<FileInfo> { const stats = await fs.stat(path); try { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(path); const metadata = await this.extractMetadata(workbook, path); return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), fileType: 'excel', metadata: { isExcelFile: true, sheets: metadata.sheets, fileSize: metadata.fileSize, isLargeFile: metadata.isLargeFile } }; } catch (error) { return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), fileType: 'excel', metadata: { isExcelFile: true, fileSize: stats.size, error: true, errorMessage: error instanceof Error ? error.message : String(error) } }; } } // ========== Private Helpers ========== private async checkFileSize(path: string): Promise<void> { const stats = await fs.stat(path); if (stats.size > FILE_SIZE_LIMIT) { const sizeMB = (stats.size / 1024 / 1024).toFixed(1); throw new Error( `Excel file size (${sizeMB}MB) exceeds 10MB limit. ` + `Consider using specialized tools for large file processing.` ); } } private async extractMetadata(workbook: ExcelJS.Workbook, path: string): Promise<ExcelMetadata> { const stats = await fs.stat(path); const sheets: ExcelSheet[] = workbook.worksheets.map(ws => ({ name: ws.name, rowCount: ws.actualRowCount || 0, colCount: ws.actualColumnCount || 0 })); return { sheets, fileSize: stats.size, isLargeFile: stats.size > FILE_SIZE_LIMIT }; } private worksheetToArray( workbook: ExcelJS.Workbook, sheetRef?: string | number, range?: string, offset?: number, length?: number ): { sheetName: string; data: any[][]; totalRows: number; returnedRows: number } { if (workbook.worksheets.length === 0) { return { sheetName: '', data: [], totalRows: 0, returnedRows: 0 }; } // Find target worksheet let worksheet: ExcelJS.Worksheet | undefined; let sheetName: string; if (sheetRef === undefined) { worksheet = workbook.worksheets[0]; sheetName = worksheet.name; } else if (typeof sheetRef === 'number') { if (sheetRef < 0 || sheetRef >= workbook.worksheets.length) { throw new Error(`Sheet index ${sheetRef} out of range (0-${workbook.worksheets.length - 1})`); } worksheet = workbook.worksheets[sheetRef]; sheetName = worksheet.name; } else { worksheet = workbook.getWorksheet(sheetRef); if (!worksheet) { const available = workbook.worksheets.map(ws => ws.name).join(', '); throw new Error(`Sheet "${sheetRef}" not found. Available sheets: ${available}`); } sheetName = sheetRef; } // Determine range to read let startRow = 1; let endRow = worksheet.actualRowCount || 1; let startCol = 1; let endCol = worksheet.actualColumnCount || 1; if (range) { const parsed = this.parseCellRange(range); startRow = parsed.startRow; startCol = parsed.startCol; if (parsed.endRow) endRow = parsed.endRow; if (parsed.endCol) endCol = parsed.endCol; } // Calculate total rows before pagination const totalRows = endRow - startRow + 1; // Apply offset/length pagination (row-based, matching text file behavior) if (offset !== undefined) { if (offset < 0) { // Negative offset: last N rows (like text files) // offset: -10 means "last 10 rows" const lastNRows = Math.abs(offset); startRow = Math.max(startRow, endRow - lastNRows + 1); } else if (offset > 0) { // Positive offset: skip first N rows startRow = startRow + offset; } } // Apply length limit (only for positive offset or no offset) if (length !== undefined && length > 0 && (offset === undefined || offset >= 0)) { endRow = Math.min(endRow, startRow + length - 1); } // Ensure valid range if (startRow > endRow) { return { sheetName, data: [], totalRows, returnedRows: 0 }; } // Build 2D array (preserving types) const data: any[][] = []; for (let r = startRow; r <= endRow; r++) { const row = worksheet.getRow(r); const rowData: any[] = []; for (let c = startCol; c <= endCol; c++) { const cell = row.getCell(c); let value: any = null; if (cell.value !== null && cell.value !== undefined) { if (typeof cell.value === 'object') { // Handle formula results, rich text, etc. if ('result' in cell.value) { value = cell.value.result ?? null; } else if ('richText' in cell.value) { value = (cell.value as any).richText.map((rt: any) => rt.text).join(''); } else if ('text' in cell.value) { value = (cell.value as any).text; } else if (cell.value instanceof Date) { value = cell.value.toISOString(); } else { value = String(cell.value); } } else { // Preserve native types (string, number, boolean) value = cell.value; } } rowData.push(value); } data.push(rowData); } return { sheetName, data, totalRows, returnedRows: data.length }; } private writeDataToSheet(workbook: ExcelJS.Workbook, sheetName: string, data: any[][]): void { // Remove existing sheet if it exists const existing = workbook.getWorksheet(sheetName); if (existing) { workbook.removeWorksheet(existing.id); } const worksheet = workbook.addWorksheet(sheetName); this.writeRowsStartingAt(worksheet, 1, data); } private writeRowsStartingAt(worksheet: ExcelJS.Worksheet, startRow: number, data: any[][]): void { for (let r = 0; r < data.length; r++) { const rowData = data[r]; if (!Array.isArray(rowData)) continue; const row = worksheet.getRow(startRow + r); for (let c = 0; c < rowData.length; c++) { const value = rowData[c]; if (typeof value === 'string' && value.startsWith('=')) { row.getCell(c + 1).value = { formula: value.substring(1) }; } else { row.getCell(c + 1).value = value; } } row.commit(); } } private parseRange(range: string): [string, string | null] { if (range.includes('!')) { const [sheetName, cellRange] = range.split('!'); return [sheetName, cellRange]; } return [range, null]; } private parseCellRange(range: string): { startRow: number; startCol: number; endRow?: number; endCol?: number } { // Parse A1 or A1:C10 format const match = range.match(/^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/i); if (!match) { throw new Error(`Invalid cell range: ${range}`); } const startCol = this.columnToNumber(match[1]); const startRow = parseInt(match[2], 10); if (match[3] && match[4]) { const endCol = this.columnToNumber(match[3]); const endRow = parseInt(match[4], 10); return { startRow, startCol, endRow, endCol }; } return { startRow, startCol }; } private columnToNumber(col: string): number { let result = 0; for (let i = 0; i < col.length; i++) { result = result * 26 + col.charCodeAt(i) - 64; } return result; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wonderwhy-er/DesktopCommanderMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server