Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions workspace-server/src/__tests__/features/feature-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,22 @@ describe('feature-config', () => {
expect(duplicates).toEqual([]);
});

it('should have slides.write, sheets.write, tasks.read, and tasks.write defaulted to OFF', () => {
it('should have slides.write, tasks.read, and tasks.write defaulted to OFF', () => {
const offByDefault = FEATURE_GROUPS.filter((fg) => !fg.defaultEnabled).map(
featureGroupKey,
);
expect(offByDefault).toContain('slides.write');
expect(offByDefault).toContain('sheets.write');
expect(offByDefault).toContain('tasks.read');
expect(offByDefault).toContain('tasks.write');
});

it('should have sheets.write defaulted to ON', () => {
const onByDefault = FEATURE_GROUPS.filter((fg) => fg.defaultEnabled).map(
featureGroupKey,
);
expect(onByDefault).toContain('sheets.write');
});

it('should have all default-ON services with at least one tool', () => {
const defaultOnWithNoTools = FEATURE_GROUPS.filter(
(fg) => fg.defaultEnabled && fg.tools.length === 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,14 @@ describe('resolveFeatures', () => {
expect(requiredScopes).toContain(fullUrl('userinfo.profile'));
});

it('should still include readonly scopes for default-OFF write groups (slides, sheets)', () => {
it('should still include readonly scopes for default-OFF write groups (slides)', () => {
const { requiredScopes } = resolveFeatures();
expect(requiredScopes).toContain(fullUrl('presentations.readonly'));
expect(requiredScopes).toContain(fullUrl('spreadsheets.readonly'));
});

it('should request the write scope for default-ON sheets.write', () => {
const { requiredScopes } = resolveFeatures();
expect(requiredScopes).toContain(fullUrl('spreadsheets'));
});
});
});
71 changes: 71 additions & 0 deletions workspace-server/src/__tests__/services/SheetsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('SheetsService', () => {
mockSheetsAPI = {
spreadsheets: {
get: jest.fn(),
batchUpdate: jest.fn(),
values: {
get: jest.fn(),
},
Expand Down Expand Up @@ -370,4 +371,74 @@ describe('SheetsService', () => {
expect(response.error).toBe('Metadata Error');
});
});

describe('batchUpdate', () => {
it('should apply formatting requests via spreadsheets.batchUpdate', async () => {
mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({
data: {
spreadsheetId: 'test-id',
replies: [{}],
},
});

const requests = JSON.stringify([
{
repeatCell: {
range: { sheetId: 0, startRowIndex: 0, endRowIndex: 1 },
cell: { userEnteredFormat: { textFormat: { bold: true } } },
fields: 'userEnteredFormat.textFormat.bold',
},
},
]);

const result = await sheetsService.batchUpdate({
spreadsheetId: 'test-id',
requests,
});
const response = JSON.parse(result.content[0].text);

expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
requestBody: { requests: JSON.parse(requests) },
});
expect(response.spreadsheetId).toBe('test-id');
expect(response.repliesCount).toBe(1);
});

it('should return an error when requests is not valid JSON', async () => {
const result = await sheetsService.batchUpdate({
spreadsheetId: 'test-id',
requests: 'not json',
});
const response = JSON.parse(result.content[0].text);

expect(response.error).toContain('must be a valid JSON array');
expect(mockSheetsAPI.spreadsheets.batchUpdate).not.toHaveBeenCalled();
});

it('should return an error when requests is not a JSON array', async () => {
const result = await sheetsService.batchUpdate({
spreadsheetId: 'test-id',
requests: '{"repeatCell":{}}',
});
const response = JSON.parse(result.content[0].text);

expect(response.error).toContain('must be a JSON array');
expect(mockSheetsAPI.spreadsheets.batchUpdate).not.toHaveBeenCalled();
});

it('should handle API errors gracefully', async () => {
mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue(
new Error('BatchUpdate Error'),
);

const result = await sheetsService.batchUpdate({
spreadsheetId: 'error-id',
requests: '[]',
});
const response = JSON.parse(result.content[0].text);

expect(response.error).toBe('BatchUpdate Error');
});
});
});
4 changes: 2 additions & 2 deletions workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,8 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
service: 'sheets',
group: 'write',
scopes: scopes('spreadsheets'),
tools: [],
defaultEnabled: false,
tools: ['sheets.batchUpdate'],
defaultEnabled: true,
},

// Time (no scopes needed)
Expand Down
17 changes: 17 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,23 @@ async function main() {
sheetsService.getMetadata,
);

registerTool(
'sheets.batchUpdate',
{
description:
'Applies one or more batchUpdate requests to a Google Sheets spreadsheet. This is the general-purpose write operation for everything beyond cell values: cell/text formatting (bold, colors, number formats, borders), column widths, wrapping, frozen rows, conditional formatting, merges, and sheet properties. Use sheets.getMetadata first to obtain the numeric sheetId that most requests target.',
inputSchema: {
spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'),
requests: z
.string()
.describe(
'A JSON string containing an array of Request objects conforming to the Google Sheets batchUpdate schema (https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/request#Request). Example (bold A1 on sheetId 0): \'[{"repeatCell":{"range":{"sheetId":0,"startRowIndex":0,"endRowIndex":1,"startColumnIndex":0,"endColumnIndex":1},"cell":{"userEnteredFormat":{"textFormat":{"bold":true}}},"fields":"userEnteredFormat.textFormat.bold"}}]\'.',
),
},
},
sheetsService.batchUpdate,
);

registerTool(
'drive.search',
{
Expand Down
62 changes: 62 additions & 0 deletions workspace-server/src/services/SheetsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,66 @@ export class SheetsService {
};
}
};

public batchUpdate = async ({
spreadsheetId,
requests,
}: {
spreadsheetId: string;
requests: string;
}) => {
logToFile(
`[SheetsService] Starting batchUpdate for spreadsheet: ${spreadsheetId}`,
);
try {
const id = extractDocId(spreadsheetId) || spreadsheetId;

let parsedRequests: sheets_v4.Schema$Request[];
try {
parsedRequests = JSON.parse(requests);
} catch (parseError) {
const message =
parseError instanceof Error ? parseError.message : String(parseError);
throw new Error(`"requests" must be a valid JSON array: ${message}`);
}
if (!Array.isArray(parsedRequests)) {
throw new Error('"requests" must be a JSON array of Request objects.');
}

const sheets = await this.getSheetsClient();
const response = await sheets.spreadsheets.batchUpdate({
spreadsheetId: id,
requestBody: { requests: parsedRequests },
});

logToFile(
`[SheetsService] Finished batchUpdate for spreadsheet: ${id} (${parsedRequests.length} request(s))`,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
spreadsheetId: response.data.spreadsheetId,
repliesCount: response.data.replies?.length ?? 0,
}),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logToFile(
`[SheetsService] Error during sheets.batchUpdate: ${errorMessage}`,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({ error: errorMessage }),
},
],
};
}
};
}
Loading