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
68 changes: 68 additions & 0 deletions workspace-server/src/__tests__/services/SheetsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('SheetsService', () => {
get: jest.fn(),
values: {
get: jest.fn(),
append: jest.fn(),
},
},
};
Expand Down Expand Up @@ -370,4 +371,71 @@ describe('SheetsService', () => {
expect(response.error).toBe('Metadata Error');
});
});

describe('appendRows', () => {
it('should append rows and return update info', async () => {
const mockResponse = {
data: {
updates: {
updatedRange: 'Sheet1!A3:B3',
updatedRows: 1,
updatedColumns: 2,
updatedCells: 2,
},
},
};

mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse);

const result = await sheetsService.appendRows({
spreadsheetId: 'test-id',
range: 'Sheet1!A1',
values: [['foo', 'bar']],
});
const response = JSON.parse(result.content[0].text);

expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
range: 'Sheet1!A1',
valueInputOption: 'USER_ENTERED',
requestBody: { values: [['foo', 'bar']] },
});

expect(response.updatedRange).toBe('Sheet1!A3:B3');
expect(response.updatedRows).toBe(1);
expect(response.updatedColumns).toBe(2);
expect(response.updatedCells).toBe(2);
});

it('should extract spreadsheet ID from URL', async () => {
mockSheetsAPI.spreadsheets.values.append.mockResolvedValue({
data: { updates: { updatedRange: 'Sheet1!A2:A2', updatedRows: 1, updatedColumns: 1, updatedCells: 1 } },
});

await sheetsService.appendRows({
spreadsheetId: 'https://docs.google.com/spreadsheets/d/abc123/edit',
range: 'Sheet1!A1',
values: [['value']],
});

expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith(
expect.objectContaining({ spreadsheetId: 'abc123' }),
);
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.values.append.mockRejectedValue(
new Error('Append Error'),
);

const result = await sheetsService.appendRows({
spreadsheetId: 'error-id',
range: 'Sheet1!A1',
values: [['data']],
});
const response = JSON.parse(result.content[0].text);

expect(response.error).toBe('Append Error');
});
});
});
2 changes: 1 addition & 1 deletion workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
service: 'sheets',
group: 'write',
scopes: scopes('spreadsheets'),
tools: [],
tools: ['sheets.appendRows'],
defaultEnabled: false,
},

Expand Down
22 changes: 22 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,28 @@ async function main() {
sheetsService.getMetadata,
);

registerTool(
'sheets.appendRows',
{
description:
'Appends rows to a Google Sheets spreadsheet after the last row with data in the given range.',
inputSchema: {
spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'),
range: z
.string()
.describe(
'The A1 notation range to append to (e.g., "Sheet1!A1"). Data is appended after the last row with data in this range.',
),
values: z
.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])))
.describe(
'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).',
),
},
},
sheetsService.appendRows,
);

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

public appendRows = async ({
spreadsheetId,
range,
values,
}: {
spreadsheetId: string;
range: string;
values: (string | number | boolean | null)[][];
}) => {
logToFile(
`[SheetsService] Starting appendRows for spreadsheet: ${spreadsheetId}, range: ${range}`,
);
try {
const id = extractDocId(spreadsheetId) || spreadsheetId;

const sheets = await this.getSheetsClient();
const response = await sheets.spreadsheets.values.append({
spreadsheetId: id,
range: range,
valueInputOption: 'USER_ENTERED',
requestBody: {
values: values,
},
});

logToFile(`[SheetsService] Finished appendRows for spreadsheet: ${id}`);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
updatedRange: response.data.updates?.updatedRange,
updatedRows: response.data.updates?.updatedRows,
updatedColumns: response.data.updates?.updatedColumns,
updatedCells: response.data.updates?.updatedCells,
}),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logToFile(
`[SheetsService] Error during sheets.appendRows: ${errorMessage}`,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({ error: errorMessage }),
},
],
};
}
};

public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => {
logToFile(
`[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`,
Expand Down