Skip to content

Commit 1284e83

Browse files
authored
Merge pull request #70 from a-pedraza/feat/sheet-read-format
feat: add readCellFormat, copyFormatting, and batchWrite tools
2 parents de76e15 + cc4f7cc commit 1284e83

File tree

5 files changed

+321
-4
lines changed

5 files changed

+321
-4
lines changed

src/googleSheetsApiHelpers.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export async function addSheet(
259259
* Parses A1 notation range to extract sheet name and cell range
260260
* Returns {sheetName, a1Range} where a1Range is just the cell part (e.g., "A1:B2")
261261
*/
262-
function parseRange(range: string): { sheetName: string | null; a1Range: string } {
262+
export function parseRange(range: string): { sheetName: string | null; a1Range: string } {
263263
if (range.includes('!')) {
264264
const parts = range.split('!');
265265
return {
@@ -277,7 +277,7 @@ function parseRange(range: string): { sheetName: string | null; a1Range: string
277277
* Resolves a sheet name to a numeric sheet ID.
278278
* If sheetName is null/undefined, returns the first sheet's ID.
279279
*/
280-
async function resolveSheetId(
280+
export async function resolveSheetId(
281281
sheets: Sheets,
282282
spreadsheetId: string,
283283
sheetName?: string | null
@@ -303,7 +303,7 @@ async function resolveSheetId(
303303
* Converts column letters to a 0-based column index.
304304
* Example: "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26
305305
*/
306-
function colLettersToIndex(col: string): number {
306+
export function colLettersToIndex(col: string): number {
307307
let index = 0;
308308
const upper = col.toUpperCase();
309309
for (let i = 0; i < upper.length; i++) {
@@ -322,7 +322,7 @@ function colLettersToIndex(col: string): number {
322322
* start/end index is left out of the GridRange, which the Sheets API
323323
* interprets as "unbounded" (i.e., the entire row or column).
324324
*/
325-
function parseA1ToGridRange(a1Range: string, sheetId: number): sheets_v4.Schema$GridRange {
325+
export function parseA1ToGridRange(a1Range: string, sheetId: number): sheets_v4.Schema$GridRange {
326326
// Whole-row pattern: "1:3" or "1"
327327
const rowOnlyMatch = a1Range.match(/^(\d+)(?::(\d+))?$/);
328328
if (rowOnlyMatch) {

src/tools/sheets/batchWrite.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { FastMCP } from 'fastmcp';
2+
import { UserError } from 'fastmcp';
3+
import { z } from 'zod';
4+
import { getSheetsClient } from '../../clients.js';
5+
6+
export function register(server: FastMCP) {
7+
server.addTool({
8+
name: 'batchWrite',
9+
description:
10+
'Writes data to multiple ranges in a single API call. More efficient than multiple separate writeSpreadsheet calls when updating several ranges at once.',
11+
parameters: z.object({
12+
spreadsheetId: z
13+
.string()
14+
.describe(
15+
'The spreadsheet ID — the long string between /d/ and /edit in a Google Sheets URL.'
16+
),
17+
data: z
18+
.array(
19+
z.object({
20+
range: z.string().describe('A1 notation range (e.g., "Sheet1!A1:B2").'),
21+
values: z
22+
.array(z.array(z.any()))
23+
.describe('2D array of values to write. Each inner array represents a row.'),
24+
})
25+
)
26+
.min(1)
27+
.describe('Array of range+values pairs to write in a single batch.'),
28+
valueInputOption: z
29+
.enum(['RAW', 'USER_ENTERED'])
30+
.optional()
31+
.default('USER_ENTERED')
32+
.describe(
33+
'How input data should be interpreted. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed by a user.'
34+
),
35+
}),
36+
execute: async (args, { log }) => {
37+
const sheets = await getSheetsClient();
38+
const rangeNames = args.data.map((d) => d.range).join(', ');
39+
log.info(
40+
`Batch writing to ${args.data.length} range(s) in spreadsheet ${args.spreadsheetId}: ${rangeNames}`
41+
);
42+
43+
try {
44+
const response = await sheets.spreadsheets.values.batchUpdate({
45+
spreadsheetId: args.spreadsheetId,
46+
requestBody: {
47+
valueInputOption: args.valueInputOption,
48+
data: args.data.map((d) => ({ range: d.range, values: d.values })),
49+
},
50+
});
51+
52+
const totalCells = response.data.totalUpdatedCells || 0;
53+
const totalRows = response.data.totalUpdatedRows || 0;
54+
const totalColumns = response.data.totalUpdatedColumns || 0;
55+
const totalSheets = response.data.totalUpdatedSheets || 0;
56+
57+
return `Successfully batch-wrote ${totalCells} cells (${totalRows} rows, ${totalColumns} columns) across ${totalSheets} sheet(s) in ${args.data.length} range(s).`;
58+
} catch (error: any) {
59+
log.error(
60+
`Error batch writing to spreadsheet ${args.spreadsheetId}: ${error.message || error}`
61+
);
62+
if (error instanceof UserError) throw error;
63+
if (error.code === 404) {
64+
throw new UserError(`Spreadsheet not found (ID: ${args.spreadsheetId}). Check the ID.`);
65+
}
66+
if (error.code === 403) {
67+
throw new UserError(
68+
`Permission denied for spreadsheet (ID: ${args.spreadsheetId}). Ensure you have write access.`
69+
);
70+
}
71+
throw new UserError(
72+
`Failed to batch write to spreadsheet: ${error.message || 'Unknown error'}`
73+
);
74+
}
75+
},
76+
});
77+
}

src/tools/sheets/copyFormatting.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { FastMCP } from 'fastmcp';
2+
import { UserError } from 'fastmcp';
3+
import { z } from 'zod';
4+
import { getSheetsClient } from '../../clients.js';
5+
import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
6+
7+
export function register(server: FastMCP) {
8+
server.addTool({
9+
name: 'copyFormatting',
10+
description:
11+
'Copies formatting (not values) from a source range to a destination range within the same spreadsheet. Copies bold, colors, borders, number formats, etc.',
12+
parameters: z.object({
13+
spreadsheetId: z
14+
.string()
15+
.describe(
16+
'The spreadsheet ID — the long string between /d/ and /edit in a Google Sheets URL.'
17+
),
18+
sourceSheetName: z
19+
.string()
20+
.min(1)
21+
.describe('Source sheet/tab name (e.g., "Sheet1").'),
22+
sourceRange: z
23+
.string()
24+
.describe('A1 notation for the source range (e.g., "A1:D10"). Do not include the sheet name — use sourceSheetName instead.'),
25+
destinationSheetName: z
26+
.string()
27+
.min(1)
28+
.describe('Destination sheet/tab name (e.g., "Sheet2").'),
29+
destinationRange: z
30+
.string()
31+
.describe('A1 notation for the destination range (e.g., "A1:D10"). Do not include the sheet name — use destinationSheetName instead.'),
32+
}),
33+
execute: async (args, { log }) => {
34+
const sheets = await getSheetsClient();
35+
log.info(
36+
`Copying formatting from ${args.sourceSheetName}!${args.sourceRange} to ${args.destinationSheetName}!${args.destinationRange} in spreadsheet ${args.spreadsheetId}`
37+
);
38+
39+
try {
40+
// Resolve sheet names to numeric IDs
41+
const sourceSheetId = await SheetsHelpers.resolveSheetId(
42+
sheets,
43+
args.spreadsheetId,
44+
args.sourceSheetName
45+
);
46+
const destSheetId = await SheetsHelpers.resolveSheetId(
47+
sheets,
48+
args.spreadsheetId,
49+
args.destinationSheetName
50+
);
51+
52+
// Convert A1 ranges to GridRange objects
53+
const sourceGridRange = SheetsHelpers.parseA1ToGridRange(args.sourceRange, sourceSheetId);
54+
const destGridRange = SheetsHelpers.parseA1ToGridRange(args.destinationRange, destSheetId);
55+
56+
await sheets.spreadsheets.batchUpdate({
57+
spreadsheetId: args.spreadsheetId,
58+
requestBody: {
59+
requests: [
60+
{
61+
copyPaste: {
62+
source: sourceGridRange,
63+
destination: destGridRange,
64+
pasteType: 'PASTE_FORMAT',
65+
},
66+
},
67+
],
68+
},
69+
});
70+
71+
return `Successfully copied formatting from ${args.sourceSheetName}!${args.sourceRange} to ${args.destinationSheetName}!${args.destinationRange}.`;
72+
} catch (error: any) {
73+
log.error(
74+
`Error copying formatting in spreadsheet ${args.spreadsheetId}: ${error.message || error}`
75+
);
76+
if (error instanceof UserError) throw error;
77+
throw new UserError(`Failed to copy formatting: ${error.message || 'Unknown error'}`);
78+
}
79+
},
80+
});
81+
}

src/tools/sheets/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FastMCP } from 'fastmcp';
22
import { register as readSpreadsheet } from './readSpreadsheet.js';
33
import { register as writeSpreadsheet } from './writeSpreadsheet.js';
4+
import { register as batchWrite } from './batchWrite.js';
45
import { register as appendSpreadsheetRows } from './appendSpreadsheetRows.js';
56
import { register as clearSpreadsheetRange } from './clearSpreadsheetRange.js';
67
import { register as getSpreadsheetInfo } from './getSpreadsheetInfo.js';
@@ -11,12 +12,15 @@ import { register as duplicateSheet } from './duplicateSheet.js';
1112

1213
// Formatting & validation
1314
import { register as formatCells } from './formatCells.js';
15+
import { register as readCellFormat } from './readCellFormat.js';
16+
import { register as copyFormatting } from './copyFormatting.js';
1417
import { register as freezeRowsAndColumns } from './freezeRowsAndColumns.js';
1518
import { register as setDropdownValidation } from './setDropdownValidation.js';
1619

1720
export function registerSheetsTools(server: FastMCP) {
1821
readSpreadsheet(server);
1922
writeSpreadsheet(server);
23+
batchWrite(server);
2024
appendSpreadsheetRows(server);
2125
clearSpreadsheetRange(server);
2226
getSpreadsheetInfo(server);
@@ -27,6 +31,8 @@ export function registerSheetsTools(server: FastMCP) {
2731

2832
// Formatting & validation
2933
formatCells(server);
34+
readCellFormat(server);
35+
copyFormatting(server);
3036
freezeRowsAndColumns(server);
3137
setDropdownValidation(server);
3238
}

src/tools/sheets/readCellFormat.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { FastMCP } from 'fastmcp';
2+
import { UserError } from 'fastmcp';
3+
import { z } from 'zod';
4+
import { getSheetsClient } from '../../clients.js';
5+
import { rowColToA1 } from '../../googleSheetsApiHelpers.js';
6+
7+
/**
8+
* Converts a Google Sheets RGBA color object (0-1 range) to a hex string.
9+
* Returns null if the color is undefined or has no meaningful channels.
10+
*/
11+
function rgbaToHex(
12+
color: { red?: number | null; green?: number | null; blue?: number | null } | null | undefined
13+
): string | null {
14+
if (!color) return null;
15+
const r = Math.round((color.red ?? 0) * 255);
16+
const g = Math.round((color.green ?? 0) * 255);
17+
const b = Math.round((color.blue ?? 0) * 255);
18+
return `#${r.toString(16).padStart(2, '0').toUpperCase()}${g.toString(16).padStart(2, '0').toUpperCase()}${b.toString(16).padStart(2, '0').toUpperCase()}`;
19+
}
20+
21+
/**
22+
* Extracts a simplified formatting summary from a Google Sheets CellFormat object.
23+
* Only includes properties that are explicitly set (non-default).
24+
*/
25+
function simplifyFormat(fmt: any): Record<string, any> | null {
26+
if (!fmt) return null;
27+
const result: Record<string, any> = {};
28+
29+
// Text formatting
30+
if (fmt.textFormat) {
31+
const tf: Record<string, any> = {};
32+
if (fmt.textFormat.bold) tf.bold = true;
33+
if (fmt.textFormat.italic) tf.italic = true;
34+
if (fmt.textFormat.strikethrough) tf.strikethrough = true;
35+
if (fmt.textFormat.underline) tf.underline = true;
36+
if (fmt.textFormat.fontSize != null) tf.fontSize = fmt.textFormat.fontSize;
37+
if (fmt.textFormat.fontFamily) tf.fontFamily = fmt.textFormat.fontFamily;
38+
if (fmt.textFormat.foregroundColorStyle?.rgbColor) {
39+
tf.foregroundColor = rgbaToHex(fmt.textFormat.foregroundColorStyle.rgbColor);
40+
} else if (fmt.textFormat.foregroundColor) {
41+
tf.foregroundColor = rgbaToHex(fmt.textFormat.foregroundColor);
42+
}
43+
if (Object.keys(tf).length > 0) result.textFormat = tf;
44+
}
45+
46+
// Background color
47+
if (fmt.backgroundColorStyle?.rgbColor) {
48+
result.backgroundColor = rgbaToHex(fmt.backgroundColorStyle.rgbColor);
49+
} else if (fmt.backgroundColor) {
50+
result.backgroundColor = rgbaToHex(fmt.backgroundColor);
51+
}
52+
53+
// Alignment
54+
if (fmt.horizontalAlignment) result.horizontalAlignment = fmt.horizontalAlignment;
55+
if (fmt.verticalAlignment) result.verticalAlignment = fmt.verticalAlignment;
56+
57+
// Number format
58+
if (fmt.numberFormat) {
59+
result.numberFormat = {
60+
type: fmt.numberFormat.type,
61+
pattern: fmt.numberFormat.pattern,
62+
};
63+
}
64+
65+
// Borders
66+
if (fmt.borders) {
67+
const borders: Record<string, any> = {};
68+
for (const side of ['top', 'bottom', 'left', 'right'] as const) {
69+
if (fmt.borders[side]) {
70+
borders[side] = {
71+
style: fmt.borders[side].style,
72+
...(fmt.borders[side].colorStyle?.rgbColor
73+
? { color: rgbaToHex(fmt.borders[side].colorStyle.rgbColor) }
74+
: fmt.borders[side].color
75+
? { color: rgbaToHex(fmt.borders[side].color) }
76+
: {}),
77+
};
78+
}
79+
}
80+
if (Object.keys(borders).length > 0) result.borders = borders;
81+
}
82+
83+
// Wrap strategy
84+
if (fmt.wrapStrategy) result.wrapStrategy = fmt.wrapStrategy;
85+
86+
return Object.keys(result).length > 0 ? result : null;
87+
}
88+
89+
export function register(server: FastMCP) {
90+
server.addTool({
91+
name: 'readCellFormat',
92+
description:
93+
'Reads the formatting/style of cells in a given range. Returns formatting details like bold, italic, fontSize, fontFamily, colors, alignment, borders, and number format per cell.',
94+
parameters: z.object({
95+
spreadsheetId: z
96+
.string()
97+
.describe(
98+
'The spreadsheet ID — the long string between /d/ and /edit in a Google Sheets URL.'
99+
),
100+
range: z
101+
.string()
102+
.describe('A1 notation range to read formatting from (e.g., "Sheet1!A1:D5" or "A1:B2").'),
103+
}),
104+
execute: async (args, { log }) => {
105+
const sheets = await getSheetsClient();
106+
log.info(
107+
`Reading cell format for range "${args.range}" in spreadsheet ${args.spreadsheetId}`
108+
);
109+
110+
try {
111+
const response = await sheets.spreadsheets.get({
112+
spreadsheetId: args.spreadsheetId,
113+
ranges: [args.range],
114+
includeGridData: true,
115+
fields:
116+
'sheets.data.rowData.values.userEnteredFormat,sheets.data.startRow,sheets.data.startColumn',
117+
});
118+
119+
const sheetData = response.data.sheets?.[0]?.data?.[0];
120+
if (!sheetData?.rowData) {
121+
return JSON.stringify({ range: args.range, cells: [] }, null, 2);
122+
}
123+
124+
const startRow = sheetData.startRow ?? 0;
125+
const startCol = sheetData.startColumn ?? 0;
126+
127+
const cells: Array<{ cell: string; format: Record<string, any> }> = [];
128+
129+
for (let rowIdx = 0; rowIdx < sheetData.rowData.length; rowIdx++) {
130+
const row = sheetData.rowData[rowIdx];
131+
if (!row.values) continue;
132+
133+
for (let colIdx = 0; colIdx < row.values.length; colIdx++) {
134+
const cellData = row.values[colIdx];
135+
const fmt = simplifyFormat(cellData?.userEnteredFormat);
136+
if (fmt) {
137+
const cellRef = rowColToA1(startRow + rowIdx, startCol + colIdx);
138+
cells.push({ cell: cellRef, format: fmt });
139+
}
140+
}
141+
}
142+
143+
return JSON.stringify({ range: args.range, cells }, null, 2);
144+
} catch (error: any) {
145+
log.error(
146+
`Error reading cell format for spreadsheet ${args.spreadsheetId}: ${error.message || error}`
147+
);
148+
if (error instanceof UserError) throw error;
149+
throw new UserError(`Failed to read cell format: ${error.message || 'Unknown error'}`);
150+
}
151+
},
152+
});
153+
}

0 commit comments

Comments
 (0)