-
Notifications
You must be signed in to change notification settings - Fork 17
Add border-map and compare-ranges tools | increase tests coverage #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
aad07e5
Add unit tests; small chart/auth fixes
marcin-uliasz 068653e
Add border-map & compare-ranges tools; CF locale
marcin-uliasz 3b43ed5
Merge remote-tracking branch 'upstream/main'
marcin-uliasz fa6e3fe
Support quoted sheet names and case-insensitive ranges
marcin-uliasz 31d35c2
Style: normalize whitespace and line breaks
marcin-uliasz 29ced74
Set compactMode default to false
marcin-uliasz ad0a1ec
Bump package version to 1.7.2
marcin-uliasz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| import { Tool } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { z } from 'zod'; | ||
| import { sheets_v4 } from 'googleapis'; | ||
| import { getAuthenticatedClient } from '../utils/google-auth.js'; | ||
| import { handleError } from '../utils/error-handler.js'; | ||
| import { formatSuccessResponse } from '../utils/formatters.js'; | ||
| import { ToolResponse } from '../types/tools.js'; | ||
| import { columnToIndex, colIndexToLetter } from '../utils/range-helpers.js'; | ||
| import { extractSheetName } from '../utils/range-helpers.js'; | ||
| import { extractFormatFields } from '../utils/compact-format.js'; | ||
|
|
||
| const inputSchema = z.object({ | ||
| spreadsheetId: z.string(), | ||
| rangeA: z.string().describe('First range with sheet prefix, e.g. "Sheet1!A6:Z6"'), | ||
| rangeB: z.string().describe('Second range with sheet prefix, e.g. "Sheet1!A7:Z7"'), | ||
| fields: z.array(z.string()).optional(), | ||
| useEffectiveFormat: z.boolean().optional().default(false), | ||
| mode: z | ||
| .enum(['row-by-row', 'full']) | ||
| .optional() | ||
| .default('row-by-row') | ||
| .describe( | ||
| '"row-by-row": compare each row in rangeA against corresponding row in rangeB. ' + | ||
| '"full": compare the entire formatting of rangeA against rangeB as blocks.' | ||
| ), | ||
| }); | ||
|
|
||
| export const compareRangesTool: Tool = { | ||
| name: 'sheets_compare_ranges', | ||
| description: | ||
| 'Compare cell formatting between two ranges. ' + | ||
| 'Useful for verifying repeated patterns, e.g. "do all data rows 6–85 have identical formatting?" ' + | ||
| 'or "is row 10 formatted identically to the template row 5?". ' + | ||
| 'Returns a diff listing only the cells and properties that differ between the two ranges. ' + | ||
| 'When ranges have the same dimensions, cells are compared position-by-position. ' + | ||
| 'Use mode:"row-by-row" to compare row N of rangeA with row N of rangeB (default). ' + | ||
| 'Use fields to restrict comparison to specific format properties.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| spreadsheetId: { | ||
| type: 'string', | ||
| description: 'The ID of the spreadsheet (found in the URL after /d/)', | ||
| }, | ||
| rangeA: { | ||
| type: 'string', | ||
| description: 'First range with sheet prefix, e.g. "Sheet1!A6:Z6"', | ||
| }, | ||
| rangeB: { | ||
| type: 'string', | ||
| description: 'Second range with sheet prefix, e.g. "Sheet1!A7:Z7"', | ||
| }, | ||
| fields: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| description: | ||
| 'Optional list of format property names to compare, e.g. ["backgroundColor", "textFormat"]. ' + | ||
| 'All format properties compared if omitted.', | ||
| }, | ||
| useEffectiveFormat: { | ||
| type: 'boolean', | ||
| description: | ||
| 'Default: false. Compare effectiveFormat (true) or userEnteredFormat (false). ' + | ||
| 'effectiveFormat includes conditional formatting overlays.', | ||
| }, | ||
| mode: { | ||
| type: 'string', | ||
| enum: ['row-by-row', 'full'], | ||
| description: | ||
| '"row-by-row" (default): compare row 1 of rangeA with row 1 of rangeB, etc. ' + | ||
| '"full": flatten both ranges and compare cell by cell in reading order.', | ||
| }, | ||
| }, | ||
| required: ['spreadsheetId', 'rangeA', 'rangeB'], | ||
| }, | ||
| }; | ||
|
|
||
| function parseRangeParts(range: string): { | ||
| sheetName: string; | ||
| startCol: number; | ||
| startRow: number; | ||
| endCol: number; | ||
| endRow: number; | ||
| } { | ||
| if (!range.includes('!')) { | ||
| throw new Error(`Range must include sheet name prefix, e.g. "Sheet1!A1:F10". Got: "${range}"`); | ||
| } | ||
| const { sheetName, range: rangeOnly } = extractSheetName(range); | ||
| if (!sheetName) { | ||
| throw new Error(`Range must include sheet name prefix, e.g. "Sheet1!A1:F10". Got: "${range}"`); | ||
| } | ||
|
|
||
| const match = rangeOnly.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); | ||
| if (!match) { | ||
| throw new Error(`Invalid range format: "${rangeOnly}". Expected e.g. "A1:F10".`); | ||
| } | ||
| return { | ||
| sheetName, | ||
| startCol: columnToIndex(match[1]!.toUpperCase()), | ||
| startRow: parseInt(match[2]!) - 1, | ||
| endCol: columnToIndex(match[3]!.toUpperCase()) + 1, // exclusive | ||
| endRow: parseInt(match[4]!), // exclusive | ||
| }; | ||
| } | ||
|
|
||
| function getFmtKey( | ||
| rowData: sheets_v4.Schema$RowData[], | ||
| rowOffset: number, | ||
| colOffset: number, | ||
| useEffectiveFormat: boolean, | ||
| fields?: string[] | ||
| ): { key: string; fmt: Record<string, unknown> } { | ||
| const row = rowData[rowOffset]; | ||
| const cell = row?.values?.[colOffset]; | ||
| const rawFmt = useEffectiveFormat ? cell?.effectiveFormat : cell?.userEnteredFormat; | ||
| if (!rawFmt) { | ||
| return { key: '{}', fmt: {} }; | ||
| } | ||
| const fmt = extractFormatFields(rawFmt, fields); | ||
| return { key: JSON.stringify(fmt), fmt }; | ||
| } | ||
|
|
||
| interface CellDiff { | ||
| cellA: string; | ||
| cellB: string; | ||
| diffs: Record<string, { a: unknown; b: unknown }>; | ||
| } | ||
|
|
||
| function deepDiffProperties( | ||
| fmtA: Record<string, unknown>, | ||
| fmtB: Record<string, unknown> | ||
| ): Record<string, { a: unknown; b: unknown }> { | ||
| const allKeys = new Set([...Object.keys(fmtA), ...Object.keys(fmtB)]); | ||
| const diffs: Record<string, { a: unknown; b: unknown }> = {}; | ||
| for (const key of allKeys) { | ||
| const aVal = fmtA[key]; | ||
| const bVal = fmtB[key]; | ||
| if (JSON.stringify(aVal) !== JSON.stringify(bVal)) { | ||
| diffs[key] = { a: aVal ?? null, b: bVal ?? null }; | ||
| } | ||
| } | ||
| return diffs; | ||
| } | ||
|
|
||
| export async function handleCompareRanges(input: any): Promise<ToolResponse> { | ||
| try { | ||
| const { | ||
| spreadsheetId, | ||
| rangeA, | ||
| rangeB, | ||
| fields: formatFields, | ||
| useEffectiveFormat, | ||
| } = inputSchema.parse(input); | ||
|
|
||
| const parsedA = parseRangeParts(rangeA); | ||
| const parsedB = parseRangeParts(rangeB); | ||
|
|
||
| const numRowsA = parsedA.endRow - parsedA.startRow; | ||
| const numColsA = parsedA.endCol - parsedA.startCol; | ||
| const numRowsB = parsedB.endRow - parsedB.startRow; | ||
| const numColsB = parsedB.endCol - parsedB.startCol; | ||
|
|
||
| if (numColsA !== numColsB) { | ||
| throw new Error( | ||
| `Ranges must have the same number of columns. rangeA has ${numColsA} columns, rangeB has ${numColsB}.` | ||
| ); | ||
| } | ||
| if (numRowsA !== numRowsB) { | ||
| throw new Error( | ||
| `Ranges must have the same number of rows. rangeA has ${numRowsA} rows, rangeB has ${numRowsB}.` | ||
| ); | ||
| } | ||
|
|
||
| const formatField = useEffectiveFormat ? 'effectiveFormat' : 'userEnteredFormat'; | ||
|
|
||
| const sheets = await getAuthenticatedClient(); | ||
|
|
||
| const response = await sheets.spreadsheets.get({ | ||
| spreadsheetId, | ||
| ranges: [rangeA, rangeB], | ||
| includeGridData: true, | ||
| fields: | ||
| 'sheets.properties.title,' + | ||
| 'sheets.data.startRow,sheets.data.startColumn,' + | ||
| `sheets.data.rowData.values.${formatField}`, | ||
| }); | ||
|
|
||
| // Find grid data for each range — responses come back per-sheet, per-range | ||
| // When both ranges are in the same sheet, they appear as two data entries in the same sheet | ||
| // When in different sheets, they appear in different sheet entries | ||
| const allSheets = response.data.sheets ?? []; | ||
|
|
||
| function findGridData( | ||
| parsed: ReturnType<typeof parseRangeParts>, | ||
| rangeStr: string | ||
| ): sheets_v4.Schema$GridData { | ||
| for (const s of allSheets) { | ||
| if (s.properties?.title !== parsed.sheetName) { | ||
| continue; | ||
| } | ||
| for (const gd of s.data ?? []) { | ||
| const gdStartRow = gd.startRow ?? 0; | ||
| const gdStartCol = gd.startColumn ?? 0; | ||
| if (gdStartRow === parsed.startRow && gdStartCol === parsed.startCol) { | ||
| return gd; | ||
| } | ||
| } | ||
| } | ||
| throw new Error(`Could not locate grid data for range "${rangeStr}". Check the sheet name.`); | ||
| } | ||
|
|
||
| const gdA = findGridData(parsedA, rangeA); | ||
| const gdB = findGridData(parsedB, rangeB); | ||
|
|
||
| const rowDataA: sheets_v4.Schema$RowData[] = gdA.rowData ?? []; | ||
| const rowDataB: sheets_v4.Schema$RowData[] = gdB.rowData ?? []; | ||
|
|
||
| const diffs: CellDiff[] = []; | ||
| let equalCells = 0; | ||
|
|
||
| for (let r = 0; r < numRowsA; r++) { | ||
| for (let c = 0; c < numColsA; c++) { | ||
| const { fmt: fmtA } = getFmtKey(rowDataA, r, c, useEffectiveFormat, formatFields); | ||
| const { fmt: fmtB } = getFmtKey(rowDataB, r, c, useEffectiveFormat, formatFields); | ||
|
|
||
| const propDiffs = deepDiffProperties(fmtA, fmtB); | ||
| if (Object.keys(propDiffs).length === 0) { | ||
| equalCells++; | ||
| continue; | ||
| } | ||
| diffs.push({ | ||
| cellA: `${colIndexToLetter(parsedA.startCol + c)}${parsedA.startRow + r + 1}`, | ||
| cellB: `${colIndexToLetter(parsedB.startCol + c)}${parsedB.startRow + r + 1}`, | ||
| diffs: propDiffs, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| const totalCells = numRowsA * numColsA; | ||
| const identical = diffs.length === 0; | ||
|
|
||
| return formatSuccessResponse( | ||
| { | ||
| rangeA, | ||
| rangeB, | ||
| dimensions: { rows: numRowsA, cols: numColsA, totalCells }, | ||
| formatType: formatField, | ||
| fieldsCompared: formatFields ?? 'all', | ||
| identical, | ||
| summary: identical | ||
| ? `All ${totalCells} cells have identical formatting.` | ||
| : `${diffs.length} of ${totalCells} cells differ. ${equalCells} cells are identical.`, | ||
| equalCells, | ||
| diffCount: diffs.length, | ||
| diffs, | ||
| }, | ||
| identical | ||
| ? `Ranges ${rangeA} and ${rangeB} have identical formatting` | ||
| : `Found ${diffs.length} formatting difference(s) between ${rangeA} and ${rangeB}` | ||
| ); | ||
| } catch (error) { | ||
| return handleError(error); | ||
| } | ||
| } | ||
|
marcin-uliasz marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.