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
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The extension provides the following tools:
- `docs.getText`: Retrieves the text content of a Google Doc.
- `docs.replaceText`: Replaces all occurrences of a given text with new text in
a Google Doc.
- `docs.insertTable`: Inserts a table into a Google Doc, optionally pre-filled
with cell data.
- `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text
ranges in a Google Doc.

Expand All @@ -37,6 +39,8 @@ The extension provides the following tools:
### Google Sheets

- `sheets.getText`: Retrieves the content of a Google Sheets spreadsheet.
- `sheets.getRanges`: Gets values from multiple ranges of a Google Sheets
spreadsheet in a single call.
- `sheets.getRange`: Gets values from a specific range in a Google Sheets
spreadsheet.
- `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet.
Expand Down
19 changes: 19 additions & 0 deletions skills/google-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,25 @@ docs.writeText({
})
```

## Inserting Tables

Use `docs.insertTable` to add a table, optionally pre-filled with data in a
single call. The table is appended to the end of the body by default; pass
`index` to insert at a specific position.

```
docs.insertTable({
documentId: "document-id",
rows: 3,
columns: 2,
data: [["Metric", "Value"], ["Revenue", "$1.2M"], ["Churn", "2.1%"]]
})
```

`data` must fit within the table dimensions; omitted or empty cells are left
blank. Filling cells during insertion is much more reliable than writing into
table cells afterwards with index arithmetic.

## Find and Replace

Use `docs.replaceText` to find all occurrences of a string and replace them.
Expand Down
16 changes: 16 additions & 0 deletions skills/google-sheets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ sheets.getRange({
})
```

### Multiple Ranges in One Call

When you need several disjoint ranges (e.g., KPI cells spread across sheets),
use `sheets.getRanges` instead of calling `sheets.getRange` repeatedly — it
reads all ranges in a single API call and returns them in request order:

```
sheets.getRanges({
spreadsheetId: "spreadsheet-id",
ranges: ["Sheet1!A1:D10", "Summary!B2", "Q4!F1:F12"]
})
```

Optional: `valueRenderOption` (`FORMATTED_VALUE` default, `UNFORMATTED_VALUE`,
or `FORMULA`) and `majorDimension` (`ROWS` default or `COLUMNS`).

### Metadata

Use `sheets.getMetadata` to get spreadsheet structure without reading data —
Expand Down
148 changes: 148 additions & 0 deletions workspace-server/src/__tests__/services/DocsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,154 @@ describe('DocsService', () => {
});
});

describe('insertTable', () => {
it('should append an empty table at the end of the body', async () => {
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });

const result = await docsService.insertTable({
documentId: 'test-doc-id',
rows: 2,
columns: 3,
});

expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(1);
expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({
documentId: 'test-doc-id',
requestBody: {
requests: [
{
insertTable: {
rows: 2,
columns: 3,
endOfSegmentLocation: {},
},
},
],
},
});
expect(mockDocsAPI.documents.get).not.toHaveBeenCalled();

const response = JSON.parse(result.content[0].text);
expect(response.rows).toBe(2);
expect(response.columns).toBe(3);
expect(response.cellsFilled).toBe(0);
});

it('should insert at a specific index when provided', async () => {
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });

await docsService.insertTable({
documentId: 'test-doc-id',
rows: 1,
columns: 1,
index: 5,
});

expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({
documentId: 'test-doc-id',
requestBody: {
requests: [
{
insertTable: {
rows: 1,
columns: 1,
location: { index: 5 },
},
},
],
},
});
});

it('should fill cell data in reverse document order', async () => {
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });
mockDocsAPI.documents.get.mockResolvedValue({
data: {
body: {
content: [
{ startIndex: 0, endIndex: 1 },
{
startIndex: 1,
endIndex: 50,
table: {
tableRows: [
{
tableCells: [
{ content: [{ startIndex: 4 }] },
{ content: [{ startIndex: 6 }] },
],
},
{
tableCells: [
{ content: [{ startIndex: 9 }] },
{ content: [{ startIndex: 11 }] },
],
},
],
},
},
],
},
},
});

const result = await docsService.insertTable({
documentId: 'test-doc-id',
rows: 2,
columns: 2,
data: [
['Name', 'Score'],
['Alice', '95'],
],
});

expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(2);
const secondCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0];
expect(secondCall.requestBody.requests).toEqual([
{ insertText: { location: { index: 11 }, text: '95' } },
{ insertText: { location: { index: 9 }, text: 'Alice' } },
{ insertText: { location: { index: 6 }, text: 'Score' } },
{ insertText: { location: { index: 4 }, text: 'Name' } },
]);
Comment thread
matheusrmorgado marked this conversation as resolved.

const response = JSON.parse(result.content[0].text);
expect(response.cellsFilled).toBe(4);
});

it('should reject data that exceeds the table dimensions', async () => {
const result = await docsService.insertTable({
documentId: 'test-doc-id',
rows: 1,
columns: 1,
data: [
['a', 'b'],
['c', 'd'],
],
});

expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled();
expect(result.isError).toBe(true);
const response = JSON.parse(result.content[0].text);
expect(response.error).toContain('data has 2 rows');
});

it('should handle errors gracefully', async () => {
mockDocsAPI.documents.batchUpdate.mockRejectedValue(
new Error('Table Error'),
);

const result = await docsService.insertTable({
documentId: 'error-id',
rows: 2,
columns: 2,
});

expect(result.isError).toBe(true);
const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Table Error');
});
});

describe('getText', () => {
it('should extract text from a document', async () => {
const mockDoc = {
Expand Down
97 changes: 97 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(),
batchGet: jest.fn(),
},
},
};
Expand Down Expand Up @@ -298,6 +299,102 @@ describe('SheetsService', () => {
});
});

describe('getRanges', () => {
it('should get values from multiple ranges in one call with defaults', async () => {
const mockBatchData = {
data: {
valueRanges: [
{
range: 'Sheet1!A1:B2',
majorDimension: 'ROWS',
values: [
['A1', 'B1'],
['A2', 'B2'],
],
},
{
range: 'Summary!D2',
majorDimension: 'ROWS',
values: [['42']],
},
],
},
};

mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue(
mockBatchData,
);

const result = await sheetsService.getRanges({
spreadsheetId: 'test-spreadsheet-id',
ranges: ['Sheet1!A1:B2', 'Summary!D2'],
});

expect(mockSheetsAPI.spreadsheets.values.batchGet).toHaveBeenCalledWith({
spreadsheetId: 'test-spreadsheet-id',
ranges: ['Sheet1!A1:B2', 'Summary!D2'],
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
});

const response = JSON.parse(result.content[0].text);
expect(response.spreadsheetId).toBe('test-spreadsheet-id');
expect(response.valueRanges).toHaveLength(2);
expect(response.valueRanges[0].range).toBe('Sheet1!A1:B2');
expect(response.valueRanges[1].values).toEqual([['42']]);
});

it('should pass through render and dimension options', async () => {
mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue({
data: { valueRanges: [] },
});

await sheetsService.getRanges({
spreadsheetId: 'test-spreadsheet-id',
ranges: ['Sheet1!A1'],
valueRenderOption: 'FORMULA',
majorDimension: 'COLUMNS',
});

expect(mockSheetsAPI.spreadsheets.values.batchGet).toHaveBeenCalledWith({
spreadsheetId: 'test-spreadsheet-id',
ranges: ['Sheet1!A1'],
valueRenderOption: 'FORMULA',
majorDimension: 'COLUMNS',
});
});

it('should default missing values arrays to empty', async () => {
mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue({
data: {
valueRanges: [{ range: 'Sheet1!Z99', majorDimension: 'ROWS' }],
},
});

const result = await sheetsService.getRanges({
spreadsheetId: 'test-spreadsheet-id',
ranges: ['Sheet1!Z99'],
});

const response = JSON.parse(result.content[0].text);
expect(response.valueRanges[0].values).toEqual([]);
});

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

const result = await sheetsService.getRanges({
spreadsheetId: 'error-id',
ranges: ['Sheet1!A1'],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Batch Error');
});
});

describe('getMetadata', () => {
it('should retrieve spreadsheet metadata', async () => {
const mockSpreadsheet = {
Expand Down
Loading
Loading