From ddcd66e1bf147b74020187815a62da3eb31f67d7 Mon Sep 17 00:00:00 2001 From: AOJDevStudio Date: Sat, 28 Mar 2026 15:22:18 -0500 Subject: [PATCH] feat(sheets): add updateRecords operation for key-column-based row updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire updateRecords into sheets-schemas.ts (Zod schema + operation enum) and sheets-handler.ts (case dispatch + handler function). The core implementation in src/modules/sheets/update.ts, SDK spec/runtime/types, and unit tests already existed — this completes the legacy handler layer. - SheetsUpdateRecordsSchema: validates spreadsheetId, range, keyColumn, updates (array of {key, values}), optional sheetName - handleUpdateRecords: reads sheet, matches key column, updates individual cells per matched row, returns {updated, notFound, errors, message} - All 568 tests pass, type-check clean Closes #55 Related to GDRIVE-22 --- src/sheets/sheets-handler.ts | 105 +++++++++++++++++++++++++++++++++++ src/sheets/sheets-schemas.ts | 17 ++++++ 2 files changed, 122 insertions(+) diff --git a/src/sheets/sheets-handler.ts b/src/sheets/sheets-handler.ts index c64fdf8..6c5a1a9 100644 --- a/src/sheets/sheets-handler.ts +++ b/src/sheets/sheets-handler.ts @@ -277,6 +277,8 @@ export async function handleSheetsTool( return handleFreezeRowsColumns(validated, context); case 'setColumnWidth': return handleSetColumnWidth(validated, context); + case 'updateRecords': + return handleUpdateRecords(validated, context); default: throw new Error(`Unsupported sheets operation: ${(validated as SheetsToolInput).operation}`); } @@ -294,6 +296,7 @@ type ConditionalFormatArgs = Extract; type FreezeArgs = Extract; type SetColumnWidthArgs = Extract; +type UpdateRecordsArgs = Extract; async function handleListSheets(args: ListArgs, context: SheetsHandlerContext) { const response = await context.sheets.spreadsheets.get({ @@ -946,3 +949,105 @@ async function handleSetColumnWidth(args: SetColumnWidthArgs, context: SheetsHan }], }; } + +/** + * Convert a 0-based column index to A1 notation letter(s). + * 0 → A, 1 → B, 25 → Z, 26 → AA, etc. + */ +function columnIndexToLetter(col: number): string { + let letter = ''; + let n = col; + while (n >= 0) { + letter = String.fromCharCode((n % 26) + 65) + letter; + n = Math.floor(n / 26) - 1; + } + return letter; +} + +async function handleUpdateRecords(args: UpdateRecordsArgs, context: SheetsHandlerContext) { + const { spreadsheetId, range, keyColumn, updates, sheetName } = args; + + // Build resolved range + let resolvedRange = range; + if (sheetName && !range.includes('!')) { + resolvedRange = `${sheetName}!${range}`; + } + + // Read existing data + const response = await context.sheets.spreadsheets.values.get({ + spreadsheetId, + range: resolvedRange, + }); + + const values = (response.data.values ?? []) as unknown[][]; + if (values.length === 0) { + throw new Error('No data found in the specified range'); + } + + const headers = (values[0] as string[]).map((h) => String(h)); + const keyColIndex = headers.indexOf(keyColumn); + if (keyColIndex === -1) { + throw new Error(`Key column '${keyColumn}' not found in headers: ${headers.join(', ')}`); + } + + // Extract sheet name prefix for cell references + const sheetPrefix = resolvedRange.includes('!') ? resolvedRange.split('!')[0] + '!' : ''; + + let updated = 0; + const notFound: string[] = []; + + for (const update of updates) { + const rowIndex = values.findIndex( + (row, i) => i > 0 && String((row as unknown[])[keyColIndex]) === update.key + ); + + if (rowIndex === -1) { + notFound.push(update.key); + continue; + } + + for (const [colName, value] of Object.entries(update.values)) { + const colIndex = headers.indexOf(colName); + if (colIndex === -1) { + continue; // skip unknown columns + } + + const cellRef = `${sheetPrefix}${columnIndexToLetter(colIndex)}${rowIndex + 1}`; + + await context.sheets.spreadsheets.values.update({ + spreadsheetId, + range: cellRef, + valueInputOption: 'USER_ENTERED', + requestBody: { values: [[value]] }, + }); + } + + updated++; + } + + await context.cacheManager.invalidate(`sheet:${spreadsheetId}:*`); + context.performanceMonitor.track('updateRecords', Date.now() - context.startTime); + context.logger.info('Records updated by key column', { + spreadsheetId, + keyColumn, + updated, + notFound: notFound.length, + }); + + const notFoundMsg = + notFound.length > 0 ? ` Keys not found: ${notFound.join(', ')}.` : ''; + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + updated, + notFound, + errors: [], + message: `Updated ${updated} record${updated === 1 ? '' : 's'}.${notFoundMsg}`, + }), + }, + ], + }; +} diff --git a/src/sheets/sheets-schemas.ts b/src/sheets/sheets-schemas.ts index b06e20d..d10ba43 100644 --- a/src/sheets/sheets-schemas.ts +++ b/src/sheets/sheets-schemas.ts @@ -13,6 +13,7 @@ const SheetsOperationEnum = z.enum([ 'append', 'freeze', 'setColumnWidth', + 'updateRecords', ]); const SheetsBaseSchema = z.object({ @@ -181,6 +182,21 @@ export const SheetsSetColumnWidthSchema = SheetsBaseSchema.extend({ columns: z.array(ColumnWidthSchema).min(1), }); +const UpdateEntrySchema = z + .object({ + key: z.string().min(1), + values: z.record(z.string(), z.unknown()), + }) + .strict(); + +export const SheetsUpdateRecordsSchema = SheetsBaseSchema.extend({ + operation: z.literal('updateRecords'), + range: z.string().min(1, 'Range is required'), + keyColumn: z.string().min(1, 'keyColumn is required'), + updates: z.array(UpdateEntrySchema).min(1, 'At least one update entry is required'), + sheetName: z.string().min(1).optional(), +}); + export const SheetsToolSchema = z.discriminatedUnion('operation', [ SheetsListSchema, SheetsReadSchema, @@ -194,6 +210,7 @@ export const SheetsToolSchema = z.discriminatedUnion('operation', [ SheetsAppendSchema, SheetsFreezeSchema, SheetsSetColumnWidthSchema, + SheetsUpdateRecordsSchema, ]) .refine( (data) => data.operation !== 'rename' || data.sheetName !== undefined || data.sheetId !== undefined,