UNPKG

@budibase/server

Version:
1,227 lines (1,065 loc) • 37.1 kB
// In this file is a mock implementation of the Google Sheets API. It is used // to test the Google Sheets integration, and it keeps track of a single // spreadsheet with many sheets. It aims to be a faithful recreation of the // Google Sheets API, but it is not a perfect recreation. Some fields are // missing if they aren't relevant to our use of the API. It's possible that // this will cause problems for future feature development, but the original // development of these tests involved hitting Google's APIs directly and // examining the responses. If we couldn't find a good example of something in // use, it wasn't included. import { Datasource } from "@budibase/types" import nock from "nock" import { GoogleSheetsConfig } from "../../googlesheets" import type { SpreadsheetProperties, ExtendedValue, WorksheetDimension, WorksheetDimensionProperties, WorksheetProperties, WorksheetGridProperties, CellData, CellBorder, CellFormat, CellPadding, Color, GridRange, DataSourceSheetProperties, } from "google-spreadsheet/src/lib/types/sheets-types" const BLACK: Color = { red: 0, green: 0, blue: 0 } const WHITE: Color = { red: 1, green: 1, blue: 1 } const NO_PADDING: CellPadding = { top: 0, right: 0, bottom: 0, left: 0 } const DEFAULT_BORDER: CellBorder = { style: "SOLID", width: 1, color: BLACK, colorStyle: { rgbColor: BLACK }, } const DEFAULT_CELL_FORMAT: CellFormat = { hyperlinkDisplayType: "PLAIN_TEXT", horizontalAlignment: "LEFT", verticalAlignment: "BOTTOM", wrapStrategy: "OVERFLOW_CELL", textDirection: "LEFT_TO_RIGHT", textRotation: { angle: 0, vertical: false }, padding: NO_PADDING, backgroundColorStyle: { rgbColor: BLACK }, borders: { top: DEFAULT_BORDER, bottom: DEFAULT_BORDER, left: DEFAULT_BORDER, right: DEFAULT_BORDER, }, numberFormat: { type: "NUMBER", pattern: "General", }, backgroundColor: WHITE, textFormat: { foregroundColor: BLACK, fontFamily: "Arial", fontSize: 10, bold: false, italic: false, strikethrough: false, underline: false, }, } // https://protobuf.dev/reference/protobuf/google.protobuf/#value type Value = string | number | boolean | null interface Range { row: number column: number } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values#ValueRange interface ValueRange { range: string majorDimension: WorksheetDimension values: Value[][] } // https://developers.google.com/sheets/api/reference/rest/v4/UpdateValuesResponse interface UpdateValuesResponse { spreadsheetId: string updatedRange: string updatedRows: number updatedColumns: number updatedCells: number updatedData: ValueRange } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest interface AddSheetRequest { properties: Partial<WorksheetProperties> } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response#AddSheetResponse interface AddSheetResponse { properties: WorksheetProperties } // https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/request#UpdateSheetPropertiesRequest interface UpdateSheetPropertiesRequest { properties: Partial<WorksheetProperties> fields: string } interface DeleteRangeRequest { range: GridRange shiftDimension: WorksheetDimension } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest interface DeleteSheetRequest { sheetId: number } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request interface BatchUpdateRequest { requests: { addSheet?: AddSheetRequest deleteRange?: DeleteRangeRequest deleteSheet?: DeleteSheetRequest updateSheetProperties?: UpdateSheetPropertiesRequest }[] includeSpreadsheetInResponse: boolean responseRanges: string[] responseIncludeGridData: boolean } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response interface BatchUpdateResponse { spreadsheetId: string replies: { addSheet?: AddSheetResponse }[] updatedSpreadsheet: Spreadsheet } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData interface RowData { values: CellData[] } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData interface GridData { startRow: number startColumn: number rowData: RowData[] rowMetadata: WorksheetDimensionProperties[] columnMetadata: WorksheetDimensionProperties[] } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet interface Sheet { properties: WorksheetProperties data: GridData[] } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#Spreadsheet interface Spreadsheet { properties: SpreadsheetProperties spreadsheetId: string sheets: Sheet[] } // https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption type ValueInputOption = | "USER_ENTERED" | "RAW" | "INPUT_VALUE_OPTION_UNSPECIFIED" // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption type InsertDataOption = "OVERWRITE" | "INSERT_ROWS" // https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption type ValueRenderOption = "FORMATTED_VALUE" | "UNFORMATTED_VALUE" | "FORMULA" // https://developers.google.com/sheets/api/reference/rest/v4/DateTimeRenderOption type DateTimeRenderOption = "SERIAL_NUMBER" | "FORMATTED_STRING" // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#query-parameters interface AppendParams { valueInputOption?: ValueInputOption insertDataOption?: InsertDataOption includeValuesInResponse?: boolean responseValueRenderOption?: ValueRenderOption responseDateTimeRenderOption?: DateTimeRenderOption } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#query-parameters interface BatchGetParams { ranges: string[] majorDimension?: WorksheetDimension valueRenderOption?: ValueRenderOption dateTimeRenderOption?: DateTimeRenderOption } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#response-body interface BatchGetResponse { spreadsheetId: string valueRanges: ValueRange[] } interface AppendRequest { range: string params: AppendParams body: ValueRange } // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#response-body interface AppendResponse { spreadsheetId: string tableRange: string updates: UpdateValuesResponse } export class GoogleSheetsMock { private config: GoogleSheetsConfig private spreadsheet: Spreadsheet static forDatasource(datasource: Datasource): GoogleSheetsMock { return new GoogleSheetsMock(datasource.config as GoogleSheetsConfig) } private constructor(config: GoogleSheetsConfig) { this.config = config this.spreadsheet = { properties: { title: "Test Spreadsheet", locale: "en_US", autoRecalc: "ON_CHANGE", timeZone: "America/New_York", defaultFormat: {}, iterativeCalculationSettings: {}, spreadsheetTheme: {}, }, spreadsheetId: config.spreadsheetId, sheets: [], } this.mockAuth() this.mockAPI() } public cell(cell: string): Value | undefined { const cellData = this.cellData(cell) if (!cellData) { return undefined } return this.cellValue(cellData) } public set(cell: string, value: Value): void { const cellData = this.cellData(cell) if (!cellData) { throw new Error(`Cell ${cell} not found`) } cellData.userEnteredValue = this.createValue(value) } public swapColumns(columnA: string, columnB: string): void { const rangeA = this.parseA1Notation(columnA) const rangeB = this.parseA1Notation(columnB) if (rangeA.sheetId !== rangeB.sheetId) { throw new Error("Cannot swap columns from different sheets") } const sheet = this.getSheetById(rangeA.sheetId) if (!sheet) { throw new Error(`Sheet ${rangeA.sheetId} not found`) } sheet.data[0].rowData.forEach(row => { const temp = row.values[rangeA.startColumnIndex] row.values[rangeA.startColumnIndex] = row.values[rangeB.startColumnIndex] row.values[rangeB.startColumnIndex] = temp }) } public sheet(name: string | number): Sheet | undefined { if (typeof name === "number") { return this.getSheetById(name) } return this.getSheetByName(name) } public createSheet(opts: Partial<WorksheetProperties>): Sheet { const properties = this.defaultWorksheetProperties(opts) if (this.getSheetByName(properties.title)) { throw new Error(`Sheet ${properties.title} already exists`) } const resp = this.handleAddSheet({ properties }) return this.getSheetById(resp.properties.sheetId)! } private route( method: "get" | "put" | "post", path: string | RegExp, handler: (uri: string, request: nock.Body) => nock.Body ): nock.Scope { const headers = { reqheaders: { authorization: "Bearer test" } } const scope = nock("https://sheets.googleapis.com/", headers) return scope[method](path).reply(200, handler).persist() } private get( path: string | RegExp, handler: (uri: string, request: nock.Body) => nock.Body ): nock.Scope { return this.route("get", path, handler) } private put( path: string | RegExp, handler: (uri: string, request: nock.Body) => nock.Body ): nock.Scope { return this.route("put", path, handler) } private post( path: string | RegExp, handler: (uri: string, request: nock.Body) => nock.Body ): nock.Scope { return this.route("post", path, handler) } private mockAuth() { nock("https://www.googleapis.com/") .post("/oauth2/v4/token") .reply(200, { grant_type: "client_credentials", client_id: "your-client-id", client_secret: "your-client-secret", }) .persist() nock("https://oauth2.googleapis.com/") .post("/token", { client_id: "test", client_secret: "test", grant_type: "refresh_token", refresh_token: "refreshToken", }) .reply(200, { access_token: "test", expires_in: 3600, token_type: "Bearer", scopes: "https://www.googleapis.com/auth/spreadsheets", }) .persist() } private mockAPI() { const spreadsheetId = this.config.spreadsheetId this.get(`/v4/spreadsheets/${spreadsheetId}/`, () => this.handleGetSpreadsheet() ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate this.post( `/v4/spreadsheets/${spreadsheetId}/:batchUpdate`, (_uri, request) => this.handleBatchUpdate(request as BatchUpdateRequest) ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update this.put( new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), (_uri, request) => this.handleValueUpdate(request as ValueRange) ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet this.get( new RegExp(`/v4/spreadsheets/${spreadsheetId}/values:batchGet.*`), uri => { const url = new URL(uri, "https://sheets.googleapis.com/") const params: BatchGetParams = { ranges: url.searchParams.getAll("ranges"), majorDimension: (url.searchParams.get("majorDimension") as WorksheetDimension) || "ROWS", valueRenderOption: (url.searchParams.get("valueRenderOption") as ValueRenderOption) || undefined, dateTimeRenderOption: (url.searchParams.get( "dateTimeRenderOption" ) as DateTimeRenderOption) || undefined, } return this.handleBatchGet(params as unknown as BatchGetParams) } ) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get this.get(new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), uri => { const range = uri.split("/").pop() if (!range) { throw new Error("No range provided") } return this.getValueRange(decodeURIComponent(range)) }) // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append this.post( new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*:append`), (_uri, request) => { const url = new URL(_uri, "https://sheets.googleapis.com/") const params: Record<string, any> = Object.fromEntries( url.searchParams.entries() ) if (params.includeValuesInResponse === "true") { params.includeValuesInResponse = true } else { params.includeValuesInResponse = false } let range = url.pathname.split("/").pop() if (!range) { throw new Error("No range provided") } if (range.endsWith(":append")) { range = range.slice(0, -7) } range = decodeURIComponent(range) return this.handleValueAppend({ range, params, body: request as ValueRange, }) } ) } private handleValueAppend(request: AppendRequest): AppendResponse { const { range, params, body } = request const { sheetId, endRowIndex } = this.parseA1Notation(range) const sheet = this.getSheetById(sheetId) if (!sheet) { throw new Error(`Sheet ${sheetId} not found`) } // Ensure new rows have the same column count as the sheet const currentColumnCount = sheet.properties.gridProperties.columnCount const newRows = body.values.map(v => { const rowData = this.valuesToRowData(v) // Pad the row with empty cells if needed to match the sheet's column count while (rowData.values.length < currentColumnCount) { rowData.values.push(this.createCellData(null)) } return rowData }) const newMetadata = newRows.map(() => ({ hiddenByUser: false, hiddenByFilter: false, pixelSize: 100, developerMetadata: [], })) const toDelete = params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0 sheet.data[0].rowData.splice(endRowIndex + 1, toDelete, ...newRows) sheet.data[0].rowMetadata.splice(endRowIndex + 1, toDelete, ...newMetadata) // It's important to give back a correct updated range because the API // library we use makes use of it to assign the correct row IDs to rows. const updatedRange = this.createA1({ sheetId, startRowIndex: endRowIndex + 1, startColumnIndex: 0, endRowIndex: endRowIndex + newRows.length, endColumnIndex: 0, }) sheet.properties.gridProperties.rowCount = sheet.data[0].rowData.length return { spreadsheetId: this.spreadsheet.spreadsheetId, tableRange: range, updates: { spreadsheetId: this.spreadsheet.spreadsheetId, updatedRange, updatedRows: body.values.length, updatedColumns: body.values[0].length, updatedCells: body.values.length * body.values[0].length, updatedData: body, }, } } private handleBatchGet(params: BatchGetParams): BatchGetResponse { const { ranges, majorDimension } = params if (majorDimension && majorDimension !== "ROWS") { throw new Error("Only row-major updates are supported") } return { spreadsheetId: this.spreadsheet.spreadsheetId, valueRanges: ranges.map(range => this.getValueRange(range)), } } private handleBatchUpdate( batchUpdateRequest: BatchUpdateRequest ): BatchUpdateResponse { const response: BatchUpdateResponse = { spreadsheetId: this.spreadsheet.spreadsheetId, replies: [], updatedSpreadsheet: this.spreadsheet, } for (const request of batchUpdateRequest.requests) { if (request.addSheet) { response.replies.push({ addSheet: this.handleAddSheet(request.addSheet), }) } if (request.deleteRange) { this.handleDeleteRange(request.deleteRange) response.replies.push({}) } if (request.deleteSheet) { this.handleDeleteSheet(request.deleteSheet) response.replies.push({}) } if (request.updateSheetProperties) { this.handleUpdateSheetProperties(request.updateSheetProperties) response.replies.push({}) } } return response } private defaultWorksheetProperties( opts: Partial<WorksheetProperties> ): WorksheetProperties { return { index: this.spreadsheet.sheets.length, hidden: false, rightToLeft: false, tabColor: BLACK, tabColorStyle: { rgbColor: BLACK }, sheetType: "GRID", title: "Sheet", sheetId: this.spreadsheet.sheets.length, gridProperties: { rowCount: 5, columnCount: 5, }, dataSourceSheetProperties: {} as DataSourceSheetProperties, ...opts, } } private handleAddSheet(request: AddSheetRequest): AddSheetResponse { const properties = this.defaultWorksheetProperties(request.properties) this.spreadsheet.sheets.push({ properties, data: [ this.createEmptyGrid( properties.gridProperties.rowCount, properties.gridProperties.columnCount ), ], }) return { properties } } private handleDeleteRange(request: DeleteRangeRequest) { const { range, shiftDimension } = request if (shiftDimension !== "ROWS") { throw new Error("Only row-based deletes are supported") } const sheet = this.getSheetById(range.sheetId) if (!sheet) { throw new Error(`Sheet ${range.sheetId} not found`) } if (range.startRowIndex === undefined || range.endRowIndex === undefined) { throw new Error("Range must have start and end row indexes") } const totalRows = sheet.data[0].rowData.length if (totalRows < range.endRowIndex) { throw new Error( `Cannot delete range ${JSON.stringify(range)} from sheet ${ sheet.properties.title }. Only ${totalRows} rows exist.` ) } const rowsToDelete = range.endRowIndex - range.startRowIndex sheet.data[0].rowData.splice(range.startRowIndex, rowsToDelete) sheet.data[0].rowMetadata.splice(range.startRowIndex, rowsToDelete) sheet.properties.gridProperties.rowCount -= rowsToDelete } private handleDeleteSheet(request: DeleteSheetRequest) { const { sheetId } = request this.spreadsheet.sheets.splice(sheetId, 1) } private handleUpdateSheetProperties(request: UpdateSheetPropertiesRequest) { if (request.fields !== "gridProperties") { throw new Error( `Only 'gridProperties' field updates are supported, got: ${request.fields}` ) } if (!request.properties || !request.properties.gridProperties) { throw new Error("No grid properties provided for update") } if (request.properties.sheetId === undefined) { throw new Error("No sheet ID provided for update") } this.resizeGrid( request.properties.sheetId, request.properties.gridProperties ) } private resizeGrid( sheetId: number, newGridProperties: WorksheetGridProperties ) { const sheet = this.getSheetById(sheetId) if (!sheet) { throw new Error(`Sheet with ID ${sheetId} not found`) } const currentGridProperties = sheet.properties.gridProperties if (!newGridProperties) { throw new Error("No grid properties provided for update") } if (newGridProperties.rowCount !== undefined) { const diff = newGridProperties.rowCount - currentGridProperties.rowCount if (diff < 0) { this.removeRows(sheet, currentGridProperties.rowCount + diff, -diff) } else if (diff > 0) { this.addEmptyRows(sheet, diff) } } if (newGridProperties.columnCount !== undefined) { const diff = newGridProperties.columnCount - currentGridProperties.columnCount if (diff < 0) { this.removeColumns( sheet, currentGridProperties.columnCount + diff, -diff ) } else if (diff > 0) { this.addEmptyColumns(sheet, diff) } } } private addEmptyRows(sheet: Sheet, count: number): void { const rowData = sheet.data[0].rowData const rowMetadata = sheet.data[0].rowMetadata const newRows = this.createEmptyRows(count, rowData[0].values.length) rowData.push(...newRows) const newMetadata = this.createDimensionMetadata(count) rowMetadata.push(...newMetadata) sheet.properties.gridProperties.rowCount += count } private removeRows(sheet: Sheet, startIndex: number, count: number): void { sheet.data[0].rowData.splice(startIndex, count) sheet.data[0].rowMetadata.splice(startIndex, count) sheet.properties.gridProperties.rowCount -= count } private addEmptyColumns(sheet: Sheet, count: number): void { for (const row of sheet.data[0].rowData) { row.values.push(...this.createEmptyCells(count)) } const newMetadata = this.createDimensionMetadata(count) sheet.data[0].columnMetadata.push(...newMetadata) sheet.properties.gridProperties.columnCount += count } private removeColumns(sheet: Sheet, startIndex: number, count: number): void { for (const row of sheet.data[0].rowData) { row.values.splice(startIndex, count) } sheet.data[0].columnMetadata.splice(startIndex, count) sheet.properties.gridProperties.columnCount -= count } private handleGetSpreadsheet(): Spreadsheet { return this.spreadsheet } private handleValueUpdate(valueRange: ValueRange): UpdateValuesResponse { this.iterateValueRange(valueRange, (cell, value) => { cell.userEnteredValue = this.createValue(value) }) const response: UpdateValuesResponse = { spreadsheetId: this.spreadsheet.spreadsheetId, updatedRange: valueRange.range, updatedRows: valueRange.values.length, updatedColumns: valueRange.values[0].length, updatedCells: valueRange.values.length * valueRange.values[0].length, updatedData: valueRange, } return response } private iterateValueRange( valueRange: ValueRange, cb: (cell: CellData, value: Value) => void ) { if (valueRange.majorDimension !== "ROWS") { throw new Error("Only row-major updates are supported") } const { sheetId, startColumnIndex, startRowIndex, endColumnIndex, endRowIndex, } = this.parseA1Notation(valueRange.range) for (let row = startRowIndex; row <= endRowIndex; row++) { for (let col = startColumnIndex; col <= endColumnIndex; col++) { const cell = this.getCellNumericIndexes(sheetId, row, col) if (!cell) { const sheet = this.getSheetById(sheetId) if (!sheet) { throw new Error(`Sheet ${sheetId} not found`) } const sheetRows = sheet.data[0].rowData.length const sheetCols = sheet.data[0].rowData[0].values.length throw new Error( `Failed to find cell at ${row}, ${col}. Range: ${valueRange.range}. Sheet dimensions: ${sheetRows}x${sheetCols}.` ) } const value = valueRange.values[row - startRowIndex][col - startColumnIndex] cb(cell, value) } } } private getValueRange(range: string): ValueRange { const { sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex, } = this.parseA1Notation(range) const sheet = this.getSheetById(sheetId) if (!sheet) { throw new Error(`Sheet ${sheetId} not found`) } const valueRange: ValueRange = { range, majorDimension: "ROWS", values: [], } const data = sheet.data[0] for (let row = startRowIndex; row <= endRowIndex; row++) { const values: Value[] = [] const rowData = data.rowData[row] for (let col = startColumnIndex; col <= endColumnIndex; col++) { let cellValue: Value = null if (rowData && rowData.values && rowData.values[col]) { cellValue = this.cellValue(rowData.values[col]) } values.push(cellValue) } valueRange.values.push(values) } const trimmed = this.trimValueRange(valueRange) return trimmed } private printValueRange(valueRange: ValueRange): string { if (!valueRange.values || valueRange.values.length === 0) { return `**Range:** ${valueRange.range}\n\n*No data*` } const values = valueRange.values // Find the maximum number of columns across all rows const maxCols = Math.max(...values.map(row => row.length)) // Pad all rows to have the same number of columns const paddedValues = values.map(row => { const paddedRow = [...row] while (paddedRow.length < maxCols) { paddedRow.push("") } return paddedRow.map(cell => cell?.toString() || "") }) // Create markdown table let table = `**Range:** ${valueRange.range}\n\n` if (paddedValues.length > 0) { // Header row (first row of data) const headerRow = paddedValues[0] table += "| " + headerRow.join(" | ") + " |\n" // Separator row table += "| " + headerRow.map(() => "---").join(" | ") + " |\n" // Data rows (remaining rows) for (let i = 1; i < paddedValues.length; i++) { table += "| " + paddedValues[i].join(" | ") + " |\n" } } return table } // When Google Sheets returns a value range, it will trim the data down to the // smallest possible size. It does all of the following: // // 1. Converts cells in non-empty rows up to the first value to empty strings. // 2. Removes all cells after the last non-empty cell in a row. // 3. Removes all rows after the last non-empty row. // 4. Rows that are before the first non-empty row that are empty are replaced with []. // // We replicate this behaviour here. private trimValueRange(valueRange: ValueRange): ValueRange { for (const row of valueRange.values) { if (row.every(v => v == null)) { row.splice(0, row.length) continue } let lastNonEmptyIndex = -1 for (let i = row.length - 1; i >= 0; i--) { const cell = row[i] if (cell != null && cell !== "") { lastNonEmptyIndex = i break } } row.splice(lastNonEmptyIndex + 1) for (let i = 0; i < row.length; i++) { const cell = row[i] if (cell == null) { row[i] = "" } else { break } } } for (let i = valueRange.values.length - 1; i >= 0; i--) { const row = valueRange.values[i] if (row.length === 0) { valueRange.values.pop() } else { break } } return valueRange } private valuesToRowData(values: Value[]): RowData { return { values: values.map(v => { return this.createCellData(v) }), } } private unwrapValue(from: ExtendedValue): Value { if ("stringValue" in from) { return from.stringValue } else if ("numberValue" in from) { return from.numberValue } else if ("boolValue" in from) { return from.boolValue } else if ("formulaValue" in from) { return from.formulaValue } else { return null } } private cellValue(from: CellData): Value { return this.unwrapValue(from.userEnteredValue) } private createValue(from: Value): ExtendedValue { if (from == null) { return {} as ExtendedValue } else if (typeof from === "string") { return { stringValue: from, } } else if (typeof from === "number") { return { numberValue: from, } } else if (typeof from === "boolean") { return { boolValue: from, } } else { throw new Error("Unsupported value type") } } /** * Because the structure of a CellData is very nested and contains a lot of * extraneous formatting information, this function abstracts it away and just * lets you create a cell containing a given value. * * When you want to read the value back out, use {@link cellValue}. * * @param value value to store in the returned cell * @returns a CellData containing the given value. Read it back out with * {@link cellValue} */ private createCellData(value: Value): CellData { return { userEnteredValue: this.createValue(value), effectiveValue: this.createValue(value), formattedValue: value?.toString() || "", userEnteredFormat: DEFAULT_CELL_FORMAT, effectiveFormat: DEFAULT_CELL_FORMAT, } } private createEmptyCells(cols: number): CellData[] { const cells: CellData[] = [] for (let i = 0; i < cols; i++) { cells.push(this.createCellData(null)) } return cells } private createEmptyRowData(cols: number): RowData { return { values: this.createEmptyCells(cols) } } private createEmptyRows(count: number, cols: number): RowData[] { const rows: RowData[] = [] for (let i = 0; i < count; i++) { rows.push(this.createEmptyRowData(cols)) } return rows } private createDimensionMetadata( count: number ): WorksheetDimensionProperties[] { const metadata: WorksheetDimensionProperties[] = [] for (let i = 0; i < count; i++) { metadata.push({ hiddenByFilter: false, hiddenByUser: false, pixelSize: 100, developerMetadata: [], }) } return metadata } private createEmptyGrid(numRows: number, numCols: number): GridData { const rowData = this.createEmptyRows(numRows, numCols) const rowMetadata = this.createDimensionMetadata(numRows) const columnMetadata = this.createDimensionMetadata(numCols) return { startRow: 0, startColumn: 0, rowData, rowMetadata, columnMetadata, } } private cellData(cell: string): CellData | undefined { const { sheetId, startColumnIndex, startRowIndex } = this.parseA1Notation(cell) return this.getCellNumericIndexes(sheetId, startRowIndex, startColumnIndex) } private getCellNumericIndexes( sheet: Sheet | number, row: number, column: number ): CellData | undefined { if (typeof sheet === "number") { const foundSheet = this.getSheetById(sheet) if (!foundSheet) { return undefined } sheet = foundSheet } const data = sheet.data[0] const rowData = data.rowData[row] if (!rowData) { return undefined } const cell = rowData.values[column] if (!cell) { return undefined } return cell } // https://developers.google.com/sheets/api/guides/concepts#cell // // Examples from // https://code.luasoftware.com/tutorials/google-sheets-api/google-sheets-api-range-parameter-a1-notation // // "Sheet1!A1" -> First cell on Row 1 Col 1 // "Sheet1!A1:C1" -> Col 1-3 (A, B, C) on Row 1 = A1, B1, C1 // "A1" -> First visible sheet (if sheet name is ommitted) // "'My Sheet'!A1" -> If sheet name which contain space or start with a bracket. // "Sheet1" -> All cells in Sheet1. // "Sheet1!A:A" -> All cells on Col 1. // "Sheet1!A:B" -> All cells on Col 1 and 2. // "Sheet1!1:1" -> All cells on Row 1. // "Sheet1!1:2" -> All cells on Row 1 and 2. // // How that translates to our code below, omitting the `sheet` property: // // "Sheet1!A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } } // "Sheet1!A1:C1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 2 } } // "A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } } // "Sheet1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 100, column: 25 } } // -> This is because we default to having a 100x26 grid. // "Sheet1!A:A" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 0 } } // "Sheet1!A:B" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 1 } } // "Sheet1!1:1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 25 } } // "Sheet1!1:2" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 1, column: 25 } } private parseA1Notation(range: string): Required<GridRange> { let sheet: Sheet let rest: string if (!range.includes("!")) { sheet = this.spreadsheet.sheets[0] rest = range } else { let sheetName = range.split("!")[0] if (sheetName.startsWith("'") && sheetName.endsWith("'")) { sheetName = sheetName.slice(1, -1) } const foundSheet = this.getSheetByName(sheetName) if (!foundSheet) { throw new Error(`Sheet ${sheetName} not found`) } sheet = foundSheet rest = range.split("!")[1] } const [topLeft, bottomRight] = rest.split(":") const parsedTopLeft = topLeft ? this.parseCell(topLeft) : undefined let parsedBottomRight = bottomRight ? this.parseCell(bottomRight) : undefined if (!parsedTopLeft && !parsedBottomRight) { throw new Error("No range provided") } if (!parsedTopLeft) { throw new Error("No top left cell provided") } if (!parsedBottomRight) { parsedBottomRight = parsedTopLeft } return this.ensureGridRange({ sheetId: sheet.properties.sheetId, startRowIndex: parsedTopLeft.row, endRowIndex: parsedBottomRight.row, startColumnIndex: parsedTopLeft.column, endColumnIndex: parsedBottomRight.column, }) } private ensureGridRange(range: GridRange): Required<GridRange> { const sheet = this.getSheetById(range.sheetId) if (!sheet) { throw new Error(`Sheet ${range.sheetId} not found`) } return { sheetId: range.sheetId, startRowIndex: range.startRowIndex ?? 0, endRowIndex: range.endRowIndex ?? sheet.properties.gridProperties.rowCount - 1, startColumnIndex: range.startColumnIndex ?? 0, endColumnIndex: range.endColumnIndex ?? sheet.properties.gridProperties.columnCount - 1, } } private createA1(range: Required<GridRange>) { const { sheetId, startColumnIndex, startRowIndex, endColumnIndex, endRowIndex, } = range const sheet = this.getSheetById(sheetId) if (!sheet) { throw new Error(`Sheet ${range.sheetId} not found`) } let title = sheet.properties.title if (title.includes(" ")) { title = `'${title}'` } const topLeftLetters = this.numberToLetters(startColumnIndex) const bottomRightLetters = this.numberToLetters(endColumnIndex) const topLeftRow = startRowIndex + 1 const bottomRightRow = endRowIndex + 1 return `${title}!${topLeftLetters}${topLeftRow}:${bottomRightLetters}${bottomRightRow}` } private parseCell(cell: string): Partial<Range> { // Check if the cell starts with a number (row-only reference like "1") const firstChar = cell.slice(0, 1) if (this.isInteger(firstChar)) { return { row: parseInt(cell) - 1 } } // Find where the letters end and numbers begin let letterEnd = 0 for (let i = 0; i < cell.length; i++) { if (this.isInteger(cell[i])) { break } letterEnd = i + 1 } // Extract the column letters const columnLetters = cell.slice(0, letterEnd) const column = this.lettersToNumber(columnLetters) // If there's no number part, it's a column-only reference if (letterEnd === cell.length) { return { column } } // Extract and parse the row number const number = cell.slice(letterEnd) return { row: parseInt(number) - 1, column } } private lettersToNumber(letters: string): number { let result = 0 for (let i = 0; i < letters.length; i++) { result = result * 26 + (letters.charCodeAt(i) - 64) } return result - 1 // Convert to 0-based indexing } private numberToLetters(number: number): string { let result = "" number = number + 1 // Convert from 0-based to 1-based while (number > 0) { number-- result = String.fromCharCode((number % 26) + 65) + result number = Math.floor(number / 26) } return result } private isInteger(value: string): boolean { return !isNaN(parseInt(value)) } private getSheetByName(name: string): Sheet | undefined { return this.spreadsheet.sheets.find( sheet => sheet.properties.title === name ) } private getSheetById(id: number): Sheet | undefined { return this.spreadsheet.sheets.find( sheet => sheet.properties.sheetId === id ) } }