Spaces:
Sleeping
Sleeping
| // src/googleSheetsApiHelpers.ts | |
| import { google, sheets_v4 } from 'googleapis'; | |
| import { UserError } from 'fastmcp'; | |
| type Sheets = sheets_v4.Sheets; // Alias for convenience | |
| // --- Core Helper Functions --- | |
| /** | |
| * Converts A1 notation to row/column indices (0-based) | |
| * Example: "A1" -> {row: 0, col: 0}, "B2" -> {row: 1, col: 1} | |
| */ | |
| export function a1ToRowCol(a1: string): { row: number; col: number } { | |
| const match = a1.match(/^([A-Z]+)(\d+)$/i); | |
| if (!match) { | |
| throw new UserError(`Invalid A1 notation: ${a1}. Expected format like "A1" or "B2"`); | |
| } | |
| const colStr = match[1].toUpperCase(); | |
| const row = parseInt(match[2], 10) - 1; // Convert to 0-based | |
| let col = 0; | |
| for (let i = 0; i < colStr.length; i++) { | |
| col = col * 26 + (colStr.charCodeAt(i) - 64); | |
| } | |
| col -= 1; // Convert to 0-based | |
| return { row, col }; | |
| } | |
| /** | |
| * Converts row/column indices (0-based) to A1 notation | |
| * Example: {row: 0, col: 0} -> "A1", {row: 1, col: 1} -> "B2" | |
| */ | |
| export function rowColToA1(row: number, col: number): string { | |
| if (row < 0 || col < 0) { | |
| throw new UserError( | |
| `Row and column indices must be non-negative. Got row: ${row}, col: ${col}` | |
| ); | |
| } | |
| let colStr = ''; | |
| let colNum = col + 1; // Convert to 1-based for calculation | |
| while (colNum > 0) { | |
| colNum -= 1; | |
| colStr = String.fromCharCode(65 + (colNum % 26)) + colStr; | |
| colNum = Math.floor(colNum / 26); | |
| } | |
| return `${colStr}${row + 1}`; | |
| } | |
| /** | |
| * Validates and normalizes a range string | |
| * Examples: "A1" -> "Sheet1!A1", "A1:B2" -> "Sheet1!A1:B2" | |
| */ | |
| export function normalizeRange(range: string, sheetName?: string): string { | |
| // If range already contains '!', assume it's already normalized | |
| if (range.includes('!')) { | |
| return range; | |
| } | |
| // If sheetName is provided, prepend it | |
| if (sheetName) { | |
| return `${sheetName}!${range}`; | |
| } | |
| // Default to Sheet1 if no sheet name provided | |
| return `Sheet1!${range}`; | |
| } | |
| /** | |
| * Reads values from a spreadsheet range | |
| */ | |
| export async function readRange( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string, | |
| valueRenderOption: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA' = 'FORMATTED_VALUE' | |
| ): Promise<sheets_v4.Schema$ValueRange> { | |
| try { | |
| const response = await sheets.spreadsheets.values.get({ | |
| spreadsheetId, | |
| range, | |
| valueRenderOption, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have read access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to read range: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Writes values to a spreadsheet range | |
| */ | |
| export async function writeRange( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string, | |
| values: any[][], | |
| valueInputOption: 'RAW' | 'USER_ENTERED' = 'USER_ENTERED' | |
| ): Promise<sheets_v4.Schema$UpdateValuesResponse> { | |
| try { | |
| const response = await sheets.spreadsheets.values.update({ | |
| spreadsheetId, | |
| range, | |
| valueInputOption, | |
| requestBody: { | |
| values, | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to write range: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Appends values to the end of a sheet | |
| */ | |
| export async function appendValues( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string, | |
| values: any[][], | |
| valueInputOption: 'RAW' | 'USER_ENTERED' = 'USER_ENTERED' | |
| ): Promise<sheets_v4.Schema$AppendValuesResponse> { | |
| try { | |
| const response = await sheets.spreadsheets.values.append({ | |
| spreadsheetId, | |
| range, | |
| valueInputOption, | |
| insertDataOption: 'INSERT_ROWS', | |
| requestBody: { | |
| values, | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to append values: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Clears values from a range | |
| */ | |
| export async function clearRange( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string | |
| ): Promise<sheets_v4.Schema$ClearValuesResponse> { | |
| try { | |
| const response = await sheets.spreadsheets.values.clear({ | |
| spreadsheetId, | |
| range, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to clear range: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Gets spreadsheet metadata including sheet information | |
| */ | |
| export async function getSpreadsheetMetadata( | |
| sheets: Sheets, | |
| spreadsheetId: string | |
| ): Promise<sheets_v4.Schema$Spreadsheet> { | |
| try { | |
| const response = await sheets.spreadsheets.get({ | |
| spreadsheetId, | |
| includeGridData: false, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have read access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to get spreadsheet metadata: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Creates a new sheet/tab in a spreadsheet | |
| */ | |
| export async function addSheet( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| sheetTitle: string | |
| ): Promise<sheets_v4.Schema$BatchUpdateSpreadsheetResponse> { | |
| try { | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| addSheet: { | |
| properties: { | |
| title: sheetTitle, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| throw new UserError(`Failed to add sheet: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Parses A1 notation range to extract sheet name and cell range | |
| * Returns {sheetName, a1Range} where a1Range is just the cell part (e.g., "A1:B2") | |
| */ | |
| export function parseRange(range: string): { sheetName: string | null; a1Range: string } { | |
| const idx = range.indexOf('!'); | |
| if (idx !== -1) { | |
| return { | |
| sheetName: range.slice(0, idx).replace(/^'|'$/g, ''), | |
| a1Range: range.slice(idx + 1), | |
| }; | |
| } | |
| return { | |
| sheetName: null, | |
| a1Range: range, | |
| }; | |
| } | |
| /** | |
| * Resolves a sheet name to a numeric sheet ID. | |
| * If sheetName is null/undefined, returns the first sheet's ID. | |
| */ | |
| export async function resolveSheetId( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| sheetName?: string | null | |
| ): Promise<number> { | |
| const metadata = await getSpreadsheetMetadata(sheets, spreadsheetId); | |
| if (sheetName) { | |
| const sheet = metadata.sheets?.find((s) => s.properties?.title === sheetName); | |
| if (!sheet || sheet.properties?.sheetId === undefined || sheet.properties?.sheetId === null) { | |
| throw new UserError(`Sheet "${sheetName}" not found in spreadsheet.`); | |
| } | |
| return sheet.properties.sheetId; | |
| } | |
| const firstSheet = metadata.sheets?.[0]; | |
| if (firstSheet?.properties?.sheetId === undefined || firstSheet?.properties?.sheetId === null) { | |
| throw new UserError('Spreadsheet has no sheets.'); | |
| } | |
| return firstSheet.properties.sheetId; | |
| } | |
| /** | |
| * Converts column letters to a 0-based column index. | |
| * Example: "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 | |
| */ | |
| export function colLettersToIndex(col: string): number { | |
| let index = 0; | |
| const upper = col.toUpperCase(); | |
| for (let i = 0; i < upper.length; i++) { | |
| index = index * 26 + (upper.charCodeAt(i) - 64); | |
| } | |
| return index - 1; | |
| } | |
| /** | |
| * Parses an A1-notation cell range string into a Google Sheets GridRange object. | |
| * Supports: | |
| * - Standard: "A1", "A1:B2" | |
| * - Whole rows: "1:1", "1:3" | |
| * - Whole columns: "A:A", "A:C" | |
| * When a component is omitted (whole row/column), the corresponding | |
| * start/end index is left out of the GridRange, which the Sheets API | |
| * interprets as "unbounded" (i.e., the entire row or column). | |
| */ | |
| export function parseA1ToGridRange(a1Range: string, sheetId: number): sheets_v4.Schema$GridRange { | |
| // Whole-row pattern: "1:3" or "1" | |
| const rowOnlyMatch = a1Range.match(/^(\d+)(?::(\d+))?$/); | |
| if (rowOnlyMatch) { | |
| const startRow = parseInt(rowOnlyMatch[1], 10) - 1; | |
| const endRow = rowOnlyMatch[2] ? parseInt(rowOnlyMatch[2], 10) : startRow + 1; | |
| return { | |
| sheetId, | |
| startRowIndex: startRow, | |
| endRowIndex: endRow, | |
| // no column indices → entire row | |
| }; | |
| } | |
| // Whole-column pattern: "A:C" or "A" | |
| const colOnlyMatch = a1Range.match(/^([A-Z]+)(?::([A-Z]+))?$/i); | |
| if (colOnlyMatch && !/\d/.test(a1Range)) { | |
| const startCol = colLettersToIndex(colOnlyMatch[1]); | |
| const endCol = colOnlyMatch[2] ? colLettersToIndex(colOnlyMatch[2]) + 1 : startCol + 1; | |
| return { | |
| sheetId, | |
| startColumnIndex: startCol, | |
| endColumnIndex: endCol, | |
| // no row indices → entire column | |
| }; | |
| } | |
| // Standard A1 pattern: "A1" or "A1:B2" | |
| const standardMatch = a1Range.match(/^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/i); | |
| if (!standardMatch) { | |
| throw new UserError( | |
| `Invalid range format: "${a1Range}". Expected "A1:B2", "1:1" (whole row), or "A:A" (whole column).` | |
| ); | |
| } | |
| const startCol = colLettersToIndex(standardMatch[1]); | |
| const startRow = parseInt(standardMatch[2], 10) - 1; | |
| const endCol = standardMatch[3] ? colLettersToIndex(standardMatch[3]) + 1 : startCol + 1; | |
| const endRow = standardMatch[4] ? parseInt(standardMatch[4], 10) : startRow + 1; | |
| return { | |
| sheetId, | |
| startRowIndex: startRow, | |
| endRowIndex: endRow, | |
| startColumnIndex: startCol, | |
| endColumnIndex: endCol, | |
| }; | |
| } | |
| /** | |
| * Formats cells in a range. | |
| * Supports standard A1 ranges ("A1:D1"), whole-row ("1:1"), and whole-column ("A:A") notation. | |
| */ | |
| export async function formatCells( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string, | |
| format: { | |
| backgroundColor?: { red: number; green: number; blue: number }; | |
| textFormat?: { | |
| foregroundColor?: { red: number; green: number; blue: number }; | |
| fontSize?: number; | |
| bold?: boolean; | |
| italic?: boolean; | |
| }; | |
| horizontalAlignment?: 'LEFT' | 'CENTER' | 'RIGHT'; | |
| verticalAlignment?: 'TOP' | 'MIDDLE' | 'BOTTOM'; | |
| wrapStrategy?: 'OVERFLOW_CELL' | 'CLIP' | 'WRAP'; | |
| numberFormat?: { type: string; pattern?: string }; | |
| } | |
| ): Promise<sheets_v4.Schema$BatchUpdateSpreadsheetResponse> { | |
| try { | |
| // Parse the range to get sheet name and cell range | |
| const { sheetName, a1Range } = parseRange(range); | |
| const sheetId = await resolveSheetId(sheets, spreadsheetId, sheetName); | |
| // Parse A1 range to get row/column indices | |
| // Supports: "A1:B2" (standard), "1:3" (whole rows), "A:C" (whole columns) | |
| const gridRange = parseA1ToGridRange(a1Range, sheetId); | |
| const userEnteredFormat: sheets_v4.Schema$CellFormat = {}; | |
| if (format.backgroundColor) { | |
| userEnteredFormat.backgroundColor = { | |
| red: format.backgroundColor.red, | |
| green: format.backgroundColor.green, | |
| blue: format.backgroundColor.blue, | |
| alpha: 1, | |
| }; | |
| } | |
| if (format.textFormat) { | |
| userEnteredFormat.textFormat = {}; | |
| if (format.textFormat.foregroundColor) { | |
| userEnteredFormat.textFormat.foregroundColor = { | |
| red: format.textFormat.foregroundColor.red, | |
| green: format.textFormat.foregroundColor.green, | |
| blue: format.textFormat.foregroundColor.blue, | |
| alpha: 1, | |
| }; | |
| } | |
| if (format.textFormat.fontSize !== undefined) { | |
| userEnteredFormat.textFormat.fontSize = format.textFormat.fontSize; | |
| } | |
| if (format.textFormat.bold !== undefined) { | |
| userEnteredFormat.textFormat.bold = format.textFormat.bold; | |
| } | |
| if (format.textFormat.italic !== undefined) { | |
| userEnteredFormat.textFormat.italic = format.textFormat.italic; | |
| } | |
| } | |
| if (format.horizontalAlignment) { | |
| userEnteredFormat.horizontalAlignment = format.horizontalAlignment; | |
| } | |
| if (format.verticalAlignment) { | |
| userEnteredFormat.verticalAlignment = format.verticalAlignment; | |
| } | |
| if (format.wrapStrategy) { | |
| userEnteredFormat.wrapStrategy = format.wrapStrategy; | |
| } | |
| if (format.numberFormat) { | |
| userEnteredFormat.numberFormat = { | |
| type: format.numberFormat.type, | |
| pattern: format.numberFormat.pattern ?? '', | |
| }; | |
| } | |
| const fields = [ | |
| 'backgroundColor', | |
| 'textFormat', | |
| 'horizontalAlignment', | |
| 'verticalAlignment', | |
| 'wrapStrategy', | |
| ...(format.numberFormat ? ['numberFormat'] : []), | |
| ].join(','); | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| repeatCell: { | |
| range: gridRange, | |
| cell: { | |
| userEnteredFormat, | |
| }, | |
| fields: `userEnteredFormat(${fields})`, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| if (error instanceof UserError) throw error; | |
| throw new UserError(`Failed to format cells: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Freezes rows and/or columns in a sheet so they remain visible when scrolling. | |
| */ | |
| export async function freezeRowsAndColumns( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| sheetName?: string | null, | |
| frozenRows?: number, | |
| frozenColumns?: number | |
| ): Promise<sheets_v4.Schema$BatchUpdateSpreadsheetResponse> { | |
| try { | |
| const sheetId = await resolveSheetId(sheets, spreadsheetId, sheetName); | |
| const gridProperties: sheets_v4.Schema$GridProperties = {}; | |
| const fieldParts: string[] = []; | |
| if (frozenRows !== undefined) { | |
| gridProperties.frozenRowCount = frozenRows; | |
| fieldParts.push('gridProperties.frozenRowCount'); | |
| } | |
| if (frozenColumns !== undefined) { | |
| gridProperties.frozenColumnCount = frozenColumns; | |
| fieldParts.push('gridProperties.frozenColumnCount'); | |
| } | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| updateSheetProperties: { | |
| properties: { | |
| sheetId, | |
| gridProperties, | |
| }, | |
| fields: fieldParts.join(','), | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| if (error instanceof UserError) throw error; | |
| throw new UserError(`Failed to freeze rows/columns: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Sets or clears dropdown data validation on a range of cells. | |
| * When values are provided, creates a ONE_OF_LIST validation rule. | |
| * When values are omitted or empty, clears any existing validation from the range. | |
| */ | |
| export async function setDropdownValidation( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| range: string, | |
| values?: string[], | |
| strict: boolean = true, | |
| inputMessage?: string | |
| ): Promise<sheets_v4.Schema$BatchUpdateSpreadsheetResponse> { | |
| try { | |
| const { sheetName, a1Range } = parseRange(range); | |
| const sheetId = await resolveSheetId(sheets, spreadsheetId, sheetName); | |
| const gridRange = parseA1ToGridRange(a1Range, sheetId); | |
| const rule = | |
| values && values.length > 0 | |
| ? { | |
| condition: { | |
| type: 'ONE_OF_LIST' as const, | |
| values: values.map((v) => ({ userEnteredValue: v })), | |
| }, | |
| showCustomUi: true, | |
| strict, | |
| inputMessage: inputMessage || null, | |
| } | |
| : undefined; | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| setDataValidation: { | |
| range: gridRange, | |
| rule, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| if (error instanceof UserError) throw error; | |
| throw new UserError(`Failed to set dropdown validation: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Sets the width (in pixels) of one or more columns. | |
| * Each entry may target a single column ("A") or a contiguous range ("A:C"). | |
| */ | |
| export async function setColumnWidths( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| sheetName: string | null | undefined, | |
| columnWidths: Array<{ column: string; width: number }> | |
| ): Promise<sheets_v4.Schema$BatchUpdateSpreadsheetResponse> { | |
| try { | |
| const sheetId = await resolveSheetId(sheets, spreadsheetId, sheetName); | |
| const requests: sheets_v4.Schema$Request[] = columnWidths.map(({ column, width }) => { | |
| const colonIdx = column.indexOf(':'); | |
| let startIndex: number; | |
| let endIndex: number; | |
| if (colonIdx !== -1) { | |
| startIndex = colLettersToIndex(column.slice(0, colonIdx).trim()); | |
| endIndex = colLettersToIndex(column.slice(colonIdx + 1).trim()) + 1; | |
| } else { | |
| startIndex = colLettersToIndex(column.trim()); | |
| endIndex = startIndex + 1; | |
| } | |
| return { | |
| updateDimensionProperties: { | |
| range: { | |
| sheetId, | |
| dimension: 'COLUMNS', | |
| startIndex, | |
| endIndex, | |
| }, | |
| properties: { | |
| pixelSize: width, | |
| }, | |
| fields: 'pixelSize', | |
| }, | |
| }; | |
| }); | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { requests }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| if (error instanceof UserError) throw error; | |
| throw new UserError(`Failed to set column widths: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| /** | |
| * Helper to convert hex color to RGB (0-1 range) | |
| */ | |
| export function hexToRgb(hex: string): { red: number; green: number; blue: number } | null { | |
| if (!hex) return null; | |
| let hexClean = hex.startsWith('#') ? hex.slice(1) : hex; | |
| if (hexClean.length === 3) { | |
| hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2]; | |
| } | |
| if (hexClean.length !== 6) return null; | |
| const bigint = parseInt(hexClean, 16); | |
| if (isNaN(bigint)) return null; | |
| return { | |
| red: ((bigint >> 16) & 255) / 255, | |
| green: ((bigint >> 8) & 255) / 255, | |
| blue: (bigint & 255) / 255, | |
| }; | |
| } | |
| /** | |
| * Appends a BooleanRule conditional format rule to a spreadsheet. | |
| */ | |
| export async function addConditionalFormatRule( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| ranges: sheets_v4.Schema$GridRange[], | |
| conditionType: string, | |
| conditionValues: Array<{ userEnteredValue: string }>, | |
| format: Record<string, unknown> | |
| ): Promise<void> { | |
| try { | |
| await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| addConditionalFormatRule: { | |
| rule: { | |
| ranges, | |
| booleanRule: { | |
| condition: { | |
| type: conditionType, | |
| values: conditionValues, | |
| }, | |
| format, | |
| }, | |
| }, | |
| index: 0, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Spreadsheet not found (ID: ${spreadsheetId}). Check the ID.`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError( | |
| `Permission denied for spreadsheet (ID: ${spreadsheetId}). Ensure you have write access.` | |
| ); | |
| } | |
| throw new UserError( | |
| `Failed to add conditional format rule: ${error.message || 'Unknown error'}` | |
| ); | |
| } | |
| } | |
| // --- Table Helper Functions --- | |
| /** | |
| * Resolves a table name or ID to a table object with sheet context. | |
| * Searches through all sheets in the spreadsheet. | |
| */ | |
| export async function resolveTableIdentifier( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| tableIdentifier: string | |
| ): Promise<{ | |
| table: sheets_v4.Schema$Table; | |
| sheetId: number; | |
| sheetName: string; | |
| }> { | |
| const metadata = await getSpreadsheetMetadata(sheets, spreadsheetId); | |
| // Search through all sheets for the table | |
| for (const sheet of metadata.sheets || []) { | |
| // Check if sheetId exists (can be 0, which is valid for first sheet!) | |
| if (sheet.properties?.sheetId === null || sheet.properties?.sheetId === undefined) { | |
| continue; | |
| } | |
| const sheetName = sheet.properties.title || 'Unknown'; | |
| const tables = sheet.tables || []; | |
| for (const table of tables) { | |
| if (!table) continue; | |
| // Match by tableId (string) or name (case-insensitive) | |
| const idMatch = table.tableId === tableIdentifier; | |
| const nameMatch = table.name | |
| ? table.name.toLowerCase() === tableIdentifier.toLowerCase() | |
| : false; | |
| if (idMatch || nameMatch) { | |
| if (sheet.properties.sheetId === null || sheet.properties.sheetId === undefined) { | |
| throw new UserError(`Sheet "${sheetName}" has invalid ID.`); | |
| } | |
| return { | |
| table, | |
| sheetId: sheet.properties.sheetId, | |
| sheetName, | |
| }; | |
| } | |
| } | |
| } | |
| throw new UserError( | |
| `Table "${tableIdentifier}" not found in spreadsheet. Use listTables to see available tables.` | |
| ); | |
| } | |
| /** | |
| * Lists all tables across all sheets in a spreadsheet. | |
| * Optionally filters by sheet name. | |
| */ | |
| export async function listAllTables( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| sheetNameFilter?: string | |
| ): Promise< | |
| Array<{ | |
| table: sheets_v4.Schema$Table; | |
| sheetName: string; | |
| sheetId: number; | |
| }> | |
| > { | |
| const metadata = await getSpreadsheetMetadata(sheets, spreadsheetId); | |
| const result: Array<{ | |
| table: sheets_v4.Schema$Table; | |
| sheetName: string; | |
| sheetId: number; | |
| }> = []; | |
| for (const sheet of metadata.sheets || []) { | |
| // Check if sheetId exists (can be 0, which is valid for first sheet!) | |
| if (sheet.properties?.sheetId === null || sheet.properties?.sheetId === undefined) { | |
| continue; | |
| } | |
| // Filter by sheet name if provided | |
| if (sheetNameFilter && sheet.properties.title !== sheetNameFilter) continue; | |
| const sheetName = sheet.properties.title || 'Unknown'; | |
| const tables = sheet.tables || []; | |
| for (const table of tables) { | |
| if (table) { | |
| result.push({ | |
| table, | |
| sheetName, | |
| sheetId: sheet.properties.sheetId, | |
| }); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * Creates a new table with specified properties. | |
| */ | |
| export async function createTableHelper( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| tableDefinition: { | |
| name: string; | |
| range: sheets_v4.Schema$GridRange; | |
| columnProperties?: sheets_v4.Schema$TableColumnProperties[]; | |
| } | |
| ): Promise<sheets_v4.Schema$Table> { | |
| try { | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| addTable: { | |
| table: { | |
| name: tableDefinition.name, | |
| range: tableDefinition.range, | |
| columnProperties: tableDefinition.columnProperties, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| const reply = response.data.replies?.[0]?.addTable; | |
| if (!reply?.table) { | |
| throw new UserError('Failed to create table - no table returned in response.'); | |
| } | |
| return reply.table; | |
| } catch (error: any) { | |
| if (error.code === 400) { | |
| throw new UserError(`Invalid table definition: ${error.message}`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError(`Permission denied. Ensure you have write access to this spreadsheet.`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Deletes a table by ID. | |
| */ | |
| export async function deleteTableHelper( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| tableId: string | |
| ): Promise<void> { | |
| try { | |
| await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| deleteTable: { | |
| tableId, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Table not found (ID: ${tableId}).`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError(`Permission denied. Ensure you have write access to this spreadsheet.`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Updates a table's range. | |
| */ | |
| export async function updateTableRangeHelper( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| tableId: string, | |
| newRange: sheets_v4.Schema$GridRange | |
| ): Promise<sheets_v4.Schema$Table> { | |
| try { | |
| const response = await sheets.spreadsheets.batchUpdate({ | |
| spreadsheetId, | |
| requestBody: { | |
| requests: [ | |
| { | |
| updateTable: { | |
| table: { | |
| tableId, | |
| range: newRange, | |
| }, | |
| fields: 'range', | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| // The Google Sheets API may not return the table object in the response | |
| // even though the update was successful. Fetch the updated table to return. | |
| const { table } = await resolveTableIdentifier(sheets, spreadsheetId, tableId); | |
| return table; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Table not found (ID: ${tableId}).`); | |
| } | |
| if (error.code === 400) { | |
| throw new UserError(`Invalid range: ${error.message}`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError(`Permission denied. Ensure you have write access to this spreadsheet.`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Appends rows to a table using table-aware insertion. | |
| * Gets the table's range and appends values after the last data row. | |
| */ | |
| export async function appendToTableHelper( | |
| sheets: Sheets, | |
| spreadsheetId: string, | |
| tableId: string, | |
| values: any[][] | |
| ): Promise<{ rowsAppended: number; updatedRange: string }> { | |
| try { | |
| // First, get the table to find its range | |
| const { table, sheetName } = await resolveTableIdentifier(sheets, spreadsheetId, tableId); | |
| if (!table.range) { | |
| throw new UserError('Table does not have a range defined.'); | |
| } | |
| // Calculate the range to append to (start after the table's end row) | |
| const startRowIndex = table.range.endRowIndex || 0; | |
| const startColumnIndex = table.range.startColumnIndex || 0; | |
| const endColumnIndex = table.range.endColumnIndex || 0; | |
| const range = `${sheetName}!${rowColToA1(startRowIndex, startColumnIndex)}:${rowColToA1( | |
| startRowIndex + values.length - 1, | |
| endColumnIndex - 1 | |
| )}`; | |
| // Append the values using the standard values.append API | |
| const response = await sheets.spreadsheets.values.append({ | |
| spreadsheetId, | |
| range, | |
| valueInputOption: 'USER_ENTERED', | |
| insertDataOption: 'INSERT_ROWS', | |
| requestBody: { | |
| values, | |
| }, | |
| }); | |
| return { | |
| rowsAppended: values.length, | |
| updatedRange: response.data.updates?.updatedRange || range, | |
| }; | |
| } catch (error: any) { | |
| if (error.code === 404) { | |
| throw new UserError(`Table or spreadsheet not found (ID: ${tableId}).`); | |
| } | |
| if (error.code === 400) { | |
| throw new UserError(`Invalid data: ${error.message}`); | |
| } | |
| if (error.code === 403) { | |
| throw new UserError(`Permission denied. Ensure you have write access to this spreadsheet.`); | |
| } | |
| throw error; | |
| } | |
| } | |