gdocs-mcp 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -3
- package/dist/presets/converter.js +1 -11
- package/dist/tools/apply-style-preset.js +48 -22
- package/dist/tools/insert-text.d.ts +8 -0
- package/dist/tools/insert-text.js +36 -3
- package/dist/tools/update-table-style.d.ts +60 -0
- package/dist/tools/update-table-style.js +204 -0
- package/dist/tools/write-table.d.ts +2 -6
- package/dist/tools/write-table.js +45 -40
- package/dist/utils/color.d.ts +14 -0
- package/dist/utils/color.js +14 -0
- package/dist/utils/defaults.d.ts +11 -0
- package/dist/utils/defaults.js +29 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,7 +36,8 @@ import { InsertTableRowSchema, insertTableRow, DeleteTableRowSchema, deleteTable
|
|
|
36
36
|
import { DeleteHeaderFooterSchema, deleteHeaderFooter } from './tools/delete-header-footer.js';
|
|
37
37
|
import { CreateFootnoteSchema, createFootnote } from './tools/create-footnote.js';
|
|
38
38
|
import { WriteTableSchema, writeTable } from './tools/write-table.js';
|
|
39
|
-
|
|
39
|
+
import { UpdateTableStyleSchema, updateTableStyle } from './tools/update-table-style.js';
|
|
40
|
+
const VERSION = '0.5.2';
|
|
40
41
|
const QUIET = process.env.GDOCS_MCP_QUIET === '1';
|
|
41
42
|
const server = new McpServer({
|
|
42
43
|
name: 'gdocs-mcp',
|
|
@@ -45,7 +46,7 @@ const server = new McpServer({
|
|
|
45
46
|
function formatError(err) {
|
|
46
47
|
if (err instanceof Error) {
|
|
47
48
|
const message = err.message;
|
|
48
|
-
if (message.includes('not found')
|
|
49
|
+
if (message.includes('404') || (message.includes('not found') && !message.includes('Preset') && !message.includes('preset'))) {
|
|
49
50
|
return 'Document not found. Check the document ID and ensure it is shared with your account.';
|
|
50
51
|
}
|
|
51
52
|
if (message.includes('UNAUTHENTICATED') || message.includes('invalid_grant')) {
|
|
@@ -135,7 +136,7 @@ registerTool('add_comment', 'Add a comment anchored to specific text in the docu
|
|
|
135
136
|
// v0.4.2 structure tool
|
|
136
137
|
registerTool('read_document_structure', 'Return the structural outline of a Google Doc: paragraphs with indices, heading levels, text previews, tables with row/col counts, list metadata. Lightweight map for targeted edits.', ReadDocumentStructureSchema, readDocumentStructure);
|
|
137
138
|
// v0.5 gap closure tools
|
|
138
|
-
registerTool('insert_text', 'Insert text at a specific index or append to end of document.', InsertTextSchema, insertText);
|
|
139
|
+
registerTool('insert_text', 'Insert text at a specific index or append to end of document. Resets formatting to NORMAL_TEXT defaults by default. Pass resetFormatting: false to inherit formatting from the insertion point.', InsertTextSchema, insertText);
|
|
139
140
|
registerTool('delete_content_range', 'Delete a range of content. Supports preview mode (dry-run) to see what would be deleted before executing.', DeleteContentRangeSchema, deleteContentRange);
|
|
140
141
|
registerTool('insert_table_row', 'Insert a row into an existing table. Zero-indexed.', InsertTableRowSchema, insertTableRow);
|
|
141
142
|
registerTool('delete_table_row', 'Delete a row from an existing table. Zero-indexed.', DeleteTableRowSchema, deleteTableRow);
|
|
@@ -145,6 +146,7 @@ registerTool('delete_header_footer', 'Delete a header or footer. Supports previe
|
|
|
145
146
|
registerTool('create_footnote', 'Create a footnote at a specific position.', CreateFootnoteSchema, createFootnote);
|
|
146
147
|
// v0.5 differentiator
|
|
147
148
|
registerTool('write_table', 'Create a fully populated, styled table in one call. Inserts table, fills cells, applies header styling, sets content-aware column widths. Not safe for concurrent editing.', WriteTableSchema, writeTable);
|
|
149
|
+
registerTool('update_table_style', 'Identify and style a table by context (nearby heading, position, or dimensions) instead of raw index. Supports preset application and individual style overrides. Returns table metadata when no styling params are provided.', UpdateTableStyleSchema, updateTableStyle);
|
|
148
150
|
async function main() {
|
|
149
151
|
const transport = new StdioServerTransport();
|
|
150
152
|
await server.connect(transport);
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const h = hex.replace('#', '');
|
|
3
|
-
return {
|
|
4
|
-
red: parseInt(h.substring(0, 2), 16) / 255,
|
|
5
|
-
green: parseInt(h.substring(2, 4), 16) / 255,
|
|
6
|
-
blue: parseInt(h.substring(4, 6), 16) / 255,
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
function toColor(hex) {
|
|
10
|
-
return { color: { rgbColor: hexToRgb(hex) } };
|
|
11
|
-
}
|
|
1
|
+
import { toColor } from '../utils/color.js';
|
|
12
2
|
function toDimension(magnitude, unit = 'PT') {
|
|
13
3
|
return { magnitude, unit };
|
|
14
4
|
}
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
4
|
import { getPreset } from '../presets/config.js';
|
|
5
5
|
import { buildDocumentStyleRequest, buildTableStyleRequests } from '../presets/converter.js';
|
|
6
|
+
import { toColor } from '../utils/color.js';
|
|
6
7
|
export const ApplyStylePresetSchema = z.object({
|
|
7
8
|
documentId: z.string().describe('The Google Doc document ID'),
|
|
8
9
|
presetName: z.string().optional().describe('Name of the preset to apply. If omitted, uses the active preset from styles.json.'),
|
|
@@ -23,9 +24,20 @@ export async function applyStylePreset(args) {
|
|
|
23
24
|
if (paragraphs.length === 0)
|
|
24
25
|
continue;
|
|
25
26
|
for (const para of paragraphs) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
if (para.inTable && para.textRuns && para.textRuns.length > 0) {
|
|
28
|
+
for (const run of para.textRuns) {
|
|
29
|
+
if (run.startIndex >= run.endIndex)
|
|
30
|
+
continue;
|
|
31
|
+
const textStyleReq = buildTextStyleRequest(config, run.startIndex, run.endIndex);
|
|
32
|
+
if (textStyleReq)
|
|
33
|
+
requests.push(textStyleReq);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const textStyleReq = buildTextStyleRequest(config, para.startIndex, para.endIndex);
|
|
38
|
+
if (textStyleReq)
|
|
39
|
+
requests.push(textStyleReq);
|
|
40
|
+
}
|
|
29
41
|
const paraStyleReq = buildParagraphStyleRequest(config, para.startIndex, para.endIndex);
|
|
30
42
|
if (paraStyleReq)
|
|
31
43
|
requests.push(paraStyleReq);
|
|
@@ -58,17 +70,6 @@ export async function applyStylePreset(args) {
|
|
|
58
70
|
requestCount: requests.length,
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
|
-
function hexToRgb(hex) {
|
|
62
|
-
const h = hex.replace('#', '');
|
|
63
|
-
return {
|
|
64
|
-
red: parseInt(h.substring(0, 2), 16) / 255,
|
|
65
|
-
green: parseInt(h.substring(2, 4), 16) / 255,
|
|
66
|
-
blue: parseInt(h.substring(4, 6), 16) / 255,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
function toColor(hex) {
|
|
70
|
-
return { color: { rgbColor: hexToRgb(hex) } };
|
|
71
|
-
}
|
|
72
73
|
function toDimension(magnitude) {
|
|
73
74
|
return { magnitude, unit: 'PT' };
|
|
74
75
|
}
|
|
@@ -180,16 +181,41 @@ function buildParagraphStyleRequest(config, startIndex, endIndex) {
|
|
|
180
181
|
}
|
|
181
182
|
function groupParagraphsByStyle(content) {
|
|
182
183
|
const groups = {};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
continue;
|
|
186
|
-
const styleType = element.paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
|
|
184
|
+
const addParagraph = (paragraph, startIndex, endIndex, inTable) => {
|
|
185
|
+
const styleType = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
|
|
187
186
|
if (!groups[styleType])
|
|
188
187
|
groups[styleType] = [];
|
|
189
|
-
|
|
190
|
-
startIndex:
|
|
191
|
-
endIndex:
|
|
192
|
-
|
|
188
|
+
const range = {
|
|
189
|
+
startIndex: startIndex || 0,
|
|
190
|
+
endIndex: endIndex || 0,
|
|
191
|
+
inTable,
|
|
192
|
+
};
|
|
193
|
+
if (inTable) {
|
|
194
|
+
const textRuns = [];
|
|
195
|
+
for (const el of paragraph.elements || []) {
|
|
196
|
+
if (el.textRun && el.startIndex < el.endIndex) {
|
|
197
|
+
textRuns.push({ startIndex: el.startIndex, endIndex: el.endIndex });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
range.textRuns = textRuns;
|
|
201
|
+
}
|
|
202
|
+
groups[styleType].push(range);
|
|
203
|
+
};
|
|
204
|
+
for (const element of content) {
|
|
205
|
+
if (element.paragraph) {
|
|
206
|
+
addParagraph(element.paragraph, element.startIndex, element.endIndex, false);
|
|
207
|
+
}
|
|
208
|
+
if (element.table) {
|
|
209
|
+
for (const row of element.table.tableRows || []) {
|
|
210
|
+
for (const cell of row.tableCells || []) {
|
|
211
|
+
for (const cellElement of cell.content || []) {
|
|
212
|
+
if (cellElement.paragraph) {
|
|
213
|
+
addParagraph(cellElement.paragraph, cellElement.startIndex, cellElement.endIndex, true);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
193
219
|
}
|
|
194
220
|
return groups;
|
|
195
221
|
}
|
|
@@ -4,6 +4,14 @@ export declare const InsertTextSchema: z.ZodObject<{
|
|
|
4
4
|
text: z.ZodString;
|
|
5
5
|
index: z.ZodOptional<z.ZodNumber>;
|
|
6
6
|
appendToEnd: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
7
|
+
resetFormatting: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
|
+
style: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
fontFamily: z.ZodOptional<z.ZodString>;
|
|
10
|
+
fontSize: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
bold: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
+
italic: z.ZodOptional<z.ZodBoolean>;
|
|
13
|
+
color: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$strip>>;
|
|
7
15
|
}, z.core.$strip>;
|
|
8
16
|
export declare function insertText(args: z.infer<typeof InsertTextSchema>): Promise<{
|
|
9
17
|
documentId: string;
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { google } from 'googleapis';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
import { hexToRgb } from '../utils/color.js';
|
|
5
|
+
import { getDefaultTextStyle } from '../utils/defaults.js';
|
|
4
6
|
export const InsertTextSchema = z.object({
|
|
5
7
|
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
8
|
text: z.string().describe('Text to insert'),
|
|
7
9
|
index: z.number().optional().describe('Position to insert at. Required unless appendToEnd is true.'),
|
|
8
10
|
appendToEnd: z.boolean().optional().default(false).describe('Append text to the end of the document body. No need to know the last index.'),
|
|
11
|
+
resetFormatting: z.boolean().optional().default(true).describe('Reset text formatting to NORMAL_TEXT defaults after insertion. Set to false to inherit formatting from the insertion point.'),
|
|
12
|
+
style: z.object({
|
|
13
|
+
fontFamily: z.string().optional(),
|
|
14
|
+
fontSize: z.number().optional(),
|
|
15
|
+
bold: z.boolean().optional(),
|
|
16
|
+
italic: z.boolean().optional(),
|
|
17
|
+
color: z.string().optional(),
|
|
18
|
+
}).optional().describe('Explicit style overrides. Each property overrides the preset default. Only applies when resetFormatting is true.'),
|
|
9
19
|
});
|
|
10
20
|
export async function insertText(args) {
|
|
11
21
|
const auth = getAuthClient();
|
|
@@ -21,11 +31,34 @@ export async function insertText(args) {
|
|
|
21
31
|
if (insertIndex === undefined) {
|
|
22
32
|
throw new Error('Either index or appendToEnd: true is required.');
|
|
23
33
|
}
|
|
34
|
+
const requests = [
|
|
35
|
+
{ insertText: { location: { index: insertIndex }, text: args.text } },
|
|
36
|
+
];
|
|
37
|
+
if (args.resetFormatting !== false) {
|
|
38
|
+
const defaults = getDefaultTextStyle();
|
|
39
|
+
const explicitStyle = args.style ?? {};
|
|
40
|
+
const filtered = Object.fromEntries(Object.entries(explicitStyle).filter(([, v]) => v !== undefined));
|
|
41
|
+
const merged = { ...defaults, ...filtered };
|
|
42
|
+
requests.push({
|
|
43
|
+
updateTextStyle: {
|
|
44
|
+
range: {
|
|
45
|
+
startIndex: insertIndex,
|
|
46
|
+
endIndex: insertIndex + args.text.length,
|
|
47
|
+
},
|
|
48
|
+
textStyle: {
|
|
49
|
+
weightedFontFamily: { fontFamily: merged.fontFamily },
|
|
50
|
+
fontSize: { magnitude: merged.fontSize, unit: 'PT' },
|
|
51
|
+
bold: merged.bold,
|
|
52
|
+
italic: merged.italic,
|
|
53
|
+
foregroundColor: { color: { rgbColor: hexToRgb(merged.color) } },
|
|
54
|
+
},
|
|
55
|
+
fields: 'weightedFontFamily,fontSize,bold,italic,foregroundColor',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
24
59
|
await docs.documents.batchUpdate({
|
|
25
60
|
documentId: args.documentId,
|
|
26
|
-
requestBody: {
|
|
27
|
-
requests: [{ insertText: { location: { index: insertIndex }, text: args.text } }],
|
|
28
|
-
},
|
|
61
|
+
requestBody: { requests },
|
|
29
62
|
});
|
|
30
63
|
const apiMs = Math.round(performance.now() - apiStart);
|
|
31
64
|
return {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const UpdateTableStyleSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
nearHeading: z.ZodOptional<z.ZodString>;
|
|
5
|
+
tableIndex: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
dimensions: z.ZodOptional<z.ZodObject<{
|
|
7
|
+
rows: z.ZodNumber;
|
|
8
|
+
columns: z.ZodNumber;
|
|
9
|
+
}, z.core.$strip>>;
|
|
10
|
+
preset: z.ZodOptional<z.ZodString>;
|
|
11
|
+
headerBackground: z.ZodOptional<z.ZodString>;
|
|
12
|
+
headerTextColor: z.ZodOptional<z.ZodString>;
|
|
13
|
+
borderColor: z.ZodOptional<z.ZodString>;
|
|
14
|
+
borderWidth: z.ZodOptional<z.ZodNumber>;
|
|
15
|
+
alternatingRowColor: z.ZodOptional<z.ZodString>;
|
|
16
|
+
cellPadding: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
export declare function updateTableStyle(args: z.infer<typeof UpdateTableStyleSchema>): Promise<{
|
|
19
|
+
documentId: string;
|
|
20
|
+
table: {
|
|
21
|
+
startIndex: number;
|
|
22
|
+
rows: number;
|
|
23
|
+
cols: number;
|
|
24
|
+
nearestHeading: string | null;
|
|
25
|
+
tablePosition: number;
|
|
26
|
+
};
|
|
27
|
+
mode: string;
|
|
28
|
+
_apiMs: number;
|
|
29
|
+
applied?: undefined;
|
|
30
|
+
reason?: undefined;
|
|
31
|
+
requestCount?: undefined;
|
|
32
|
+
} | {
|
|
33
|
+
documentId: string;
|
|
34
|
+
table: {
|
|
35
|
+
startIndex: number;
|
|
36
|
+
rows: number;
|
|
37
|
+
cols: number;
|
|
38
|
+
nearestHeading: string | null;
|
|
39
|
+
tablePosition: number;
|
|
40
|
+
};
|
|
41
|
+
applied: boolean;
|
|
42
|
+
reason: string;
|
|
43
|
+
_apiMs: number;
|
|
44
|
+
mode?: undefined;
|
|
45
|
+
requestCount?: undefined;
|
|
46
|
+
} | {
|
|
47
|
+
documentId: string;
|
|
48
|
+
table: {
|
|
49
|
+
startIndex: number;
|
|
50
|
+
rows: number;
|
|
51
|
+
cols: number;
|
|
52
|
+
nearestHeading: string | null;
|
|
53
|
+
tablePosition: number;
|
|
54
|
+
};
|
|
55
|
+
applied: boolean;
|
|
56
|
+
requestCount: number;
|
|
57
|
+
_apiMs: number;
|
|
58
|
+
mode?: undefined;
|
|
59
|
+
reason?: undefined;
|
|
60
|
+
}>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
import { getPreset } from '../presets/config.js';
|
|
5
|
+
import { buildTableStyleRequests } from '../presets/converter.js';
|
|
6
|
+
export const UpdateTableStyleSchema = z.object({
|
|
7
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
8
|
+
// Identification (at least one required — validated in handler)
|
|
9
|
+
nearHeading: z.string().optional().describe('Find the first table after a heading containing this text (case-insensitive substring match). Only top-level body headings count.'),
|
|
10
|
+
tableIndex: z.number().optional().describe('Select the nth table in the document (0-indexed).'),
|
|
11
|
+
dimensions: z.object({
|
|
12
|
+
rows: z.number(),
|
|
13
|
+
columns: z.number(),
|
|
14
|
+
}).optional().describe('Match a table by exact row and column count.'),
|
|
15
|
+
// Styling (all optional)
|
|
16
|
+
preset: z.string().optional().describe('Apply a named style preset\'s table styles.'),
|
|
17
|
+
headerBackground: z.string().optional().describe('Hex color for header row background.'),
|
|
18
|
+
headerTextColor: z.string().optional().describe('Hex color for header text.'),
|
|
19
|
+
borderColor: z.string().optional().describe('Hex color for all cell borders.'),
|
|
20
|
+
borderWidth: z.number().optional().describe('Border width in PT.'),
|
|
21
|
+
alternatingRowColor: z.string().optional().describe('Hex color for alternating (even) row backgrounds.'),
|
|
22
|
+
cellPadding: z.number().optional().describe('Cell padding in PT.'),
|
|
23
|
+
});
|
|
24
|
+
function extractHeadingText(paragraph) {
|
|
25
|
+
const styleType = paragraph.paragraphStyle?.namedStyleType || '';
|
|
26
|
+
const isHeading = styleType.startsWith('HEADING_') || styleType === 'TITLE' || styleType === 'SUBTITLE';
|
|
27
|
+
if (!isHeading)
|
|
28
|
+
return null;
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (const el of paragraph.elements || []) {
|
|
31
|
+
if (el.textRun?.content) {
|
|
32
|
+
parts.push(el.textRun.content);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return parts.join('').trim() || null;
|
|
36
|
+
}
|
|
37
|
+
function buildTableCandidates(content) {
|
|
38
|
+
const candidates = [];
|
|
39
|
+
let lastHeadingText = null;
|
|
40
|
+
let tablePosition = 0;
|
|
41
|
+
for (const element of content) {
|
|
42
|
+
if (element.paragraph) {
|
|
43
|
+
const headingText = extractHeadingText(element.paragraph);
|
|
44
|
+
if (headingText !== null) {
|
|
45
|
+
lastHeadingText = headingText;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (element.table) {
|
|
49
|
+
candidates.push({
|
|
50
|
+
startIndex: element.startIndex,
|
|
51
|
+
rows: element.table.rows || 0,
|
|
52
|
+
cols: element.table.columns || 0,
|
|
53
|
+
nearestHeading: lastHeadingText,
|
|
54
|
+
tablePosition,
|
|
55
|
+
});
|
|
56
|
+
tablePosition++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return candidates;
|
|
60
|
+
}
|
|
61
|
+
function formatCandidateList(candidates) {
|
|
62
|
+
return candidates
|
|
63
|
+
.map(c => ` [${c.tablePosition}] ${c.rows}x${c.cols} at index ${c.startIndex}${c.nearestHeading ? ` (after heading: "${c.nearestHeading}")` : ''}`)
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
export async function updateTableStyle(args) {
|
|
67
|
+
// Validate that at least one identification param is provided
|
|
68
|
+
if (args.nearHeading === undefined && args.tableIndex === undefined && args.dimensions === undefined) {
|
|
69
|
+
throw new Error('At least one identification parameter required: nearHeading, tableIndex, or dimensions');
|
|
70
|
+
}
|
|
71
|
+
const auth = getAuthClient();
|
|
72
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
73
|
+
const apiStart = performance.now();
|
|
74
|
+
const doc = await docs.documents.get({ documentId: args.documentId });
|
|
75
|
+
const content = doc.data.body?.content || [];
|
|
76
|
+
const allCandidates = buildTableCandidates(content);
|
|
77
|
+
if (allCandidates.length === 0) {
|
|
78
|
+
throw new Error('Document contains no tables.');
|
|
79
|
+
}
|
|
80
|
+
// Filter by identification params (intersect all provided)
|
|
81
|
+
let filtered = [...allCandidates];
|
|
82
|
+
if (args.nearHeading !== undefined) {
|
|
83
|
+
const needle = args.nearHeading.toLowerCase();
|
|
84
|
+
// Find the last heading that contains the search text
|
|
85
|
+
let matchedHeading = null;
|
|
86
|
+
for (const candidate of allCandidates) {
|
|
87
|
+
if (candidate.nearestHeading && candidate.nearestHeading.toLowerCase().includes(needle)) {
|
|
88
|
+
matchedHeading = candidate.nearestHeading;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (matchedHeading === null) {
|
|
92
|
+
throw new Error(`No table found near a heading containing "${args.nearHeading}".\n` +
|
|
93
|
+
`Available tables:\n${formatCandidateList(allCandidates)}`);
|
|
94
|
+
}
|
|
95
|
+
// Return only the first table whose nearestHeading matches
|
|
96
|
+
const firstMatch = filtered.find(c => c.nearestHeading !== null && c.nearestHeading.toLowerCase().includes(needle));
|
|
97
|
+
filtered = firstMatch ? [firstMatch] : [];
|
|
98
|
+
}
|
|
99
|
+
if (args.tableIndex !== undefined) {
|
|
100
|
+
filtered = filtered.filter(c => c.tablePosition === args.tableIndex);
|
|
101
|
+
}
|
|
102
|
+
if (args.dimensions !== undefined) {
|
|
103
|
+
filtered = filtered.filter(c => c.rows === args.dimensions.rows && c.cols === args.dimensions.columns);
|
|
104
|
+
}
|
|
105
|
+
// Handle match results
|
|
106
|
+
if (filtered.length === 0) {
|
|
107
|
+
throw new Error(`No table matches the provided criteria.\n` +
|
|
108
|
+
`Available tables:\n${formatCandidateList(allCandidates)}`);
|
|
109
|
+
}
|
|
110
|
+
if (filtered.length > 1) {
|
|
111
|
+
throw new Error(`Multiple tables match the provided criteria. Narrow your search.\n` +
|
|
112
|
+
`Matching tables:\n${formatCandidateList(filtered)}`);
|
|
113
|
+
}
|
|
114
|
+
const matched = filtered[0];
|
|
115
|
+
// Check if any styling was requested
|
|
116
|
+
const hasStyling = args.preset !== undefined ||
|
|
117
|
+
args.headerBackground !== undefined ||
|
|
118
|
+
args.headerTextColor !== undefined ||
|
|
119
|
+
args.borderColor !== undefined ||
|
|
120
|
+
args.borderWidth !== undefined ||
|
|
121
|
+
args.alternatingRowColor !== undefined ||
|
|
122
|
+
args.cellPadding !== undefined;
|
|
123
|
+
if (!hasStyling) {
|
|
124
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
125
|
+
return {
|
|
126
|
+
documentId: args.documentId,
|
|
127
|
+
table: {
|
|
128
|
+
startIndex: matched.startIndex,
|
|
129
|
+
rows: matched.rows,
|
|
130
|
+
cols: matched.cols,
|
|
131
|
+
nearestHeading: matched.nearestHeading,
|
|
132
|
+
tablePosition: matched.tablePosition,
|
|
133
|
+
},
|
|
134
|
+
mode: 'read-only',
|
|
135
|
+
_apiMs: apiMs,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Build TableStyleConfig: start from preset if provided, then override with explicit params
|
|
139
|
+
let tableConfig = {};
|
|
140
|
+
if (args.preset !== undefined) {
|
|
141
|
+
const { preset } = getPreset(args.preset);
|
|
142
|
+
if (preset.table) {
|
|
143
|
+
tableConfig = { ...preset.table };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (args.headerBackground !== undefined) {
|
|
147
|
+
tableConfig = { ...tableConfig, headerBackground: args.headerBackground };
|
|
148
|
+
}
|
|
149
|
+
if (args.headerTextColor !== undefined) {
|
|
150
|
+
tableConfig = { ...tableConfig, headerTextColor: args.headerTextColor };
|
|
151
|
+
}
|
|
152
|
+
if (args.borderColor !== undefined) {
|
|
153
|
+
tableConfig = { ...tableConfig, borderColor: args.borderColor };
|
|
154
|
+
}
|
|
155
|
+
if (args.borderWidth !== undefined) {
|
|
156
|
+
tableConfig = { ...tableConfig, borderWidth: args.borderWidth };
|
|
157
|
+
}
|
|
158
|
+
if (args.alternatingRowColor !== undefined) {
|
|
159
|
+
tableConfig = { ...tableConfig, alternateRowBackground: args.alternatingRowColor };
|
|
160
|
+
}
|
|
161
|
+
if (args.cellPadding !== undefined) {
|
|
162
|
+
tableConfig = { ...tableConfig, cellPadding: args.cellPadding };
|
|
163
|
+
}
|
|
164
|
+
const tableForRequests = [{
|
|
165
|
+
startIndex: matched.startIndex,
|
|
166
|
+
rows: matched.rows,
|
|
167
|
+
cols: matched.cols,
|
|
168
|
+
}];
|
|
169
|
+
const requests = buildTableStyleRequests(tableConfig, tableForRequests);
|
|
170
|
+
if (requests.length === 0) {
|
|
171
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
172
|
+
return {
|
|
173
|
+
documentId: args.documentId,
|
|
174
|
+
table: {
|
|
175
|
+
startIndex: matched.startIndex,
|
|
176
|
+
rows: matched.rows,
|
|
177
|
+
cols: matched.cols,
|
|
178
|
+
nearestHeading: matched.nearestHeading,
|
|
179
|
+
tablePosition: matched.tablePosition,
|
|
180
|
+
},
|
|
181
|
+
applied: false,
|
|
182
|
+
reason: 'Style config produced no requests',
|
|
183
|
+
_apiMs: apiMs,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
await docs.documents.batchUpdate({
|
|
187
|
+
documentId: args.documentId,
|
|
188
|
+
requestBody: { requests },
|
|
189
|
+
});
|
|
190
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
191
|
+
return {
|
|
192
|
+
documentId: args.documentId,
|
|
193
|
+
table: {
|
|
194
|
+
startIndex: matched.startIndex,
|
|
195
|
+
rows: matched.rows,
|
|
196
|
+
cols: matched.cols,
|
|
197
|
+
nearestHeading: matched.nearestHeading,
|
|
198
|
+
tablePosition: matched.tablePosition,
|
|
199
|
+
},
|
|
200
|
+
applied: true,
|
|
201
|
+
requestCount: requests.length,
|
|
202
|
+
_apiMs: apiMs,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -15,12 +15,9 @@ export declare function writeTable(args: z.infer<typeof WriteTableSchema>): Prom
|
|
|
15
15
|
tableCreated: boolean;
|
|
16
16
|
styled: boolean;
|
|
17
17
|
reason: string;
|
|
18
|
-
rows?: undefined;
|
|
19
|
-
cols?: undefined;
|
|
20
|
-
tableStartIndex?: undefined;
|
|
21
|
-
requestCount?: undefined;
|
|
22
|
-
_apiMs?: undefined;
|
|
23
18
|
} | {
|
|
19
|
+
_apiMs: number;
|
|
20
|
+
warning?: string | undefined;
|
|
24
21
|
documentId: string;
|
|
25
22
|
tableCreated: boolean;
|
|
26
23
|
styled: boolean;
|
|
@@ -28,6 +25,5 @@ export declare function writeTable(args: z.infer<typeof WriteTableSchema>): Prom
|
|
|
28
25
|
cols: number;
|
|
29
26
|
tableStartIndex: any;
|
|
30
27
|
requestCount: number;
|
|
31
|
-
_apiMs: number;
|
|
32
28
|
reason?: undefined;
|
|
33
29
|
}>;
|
|
@@ -2,16 +2,16 @@ import { google } from 'googleapis';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
4
|
import { getPreset } from '../presets/config.js';
|
|
5
|
+
import { hexToRgb } from '../utils/color.js';
|
|
6
|
+
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../utils/defaults.js';
|
|
5
7
|
const DEFAULT_HEADER_BG = { red: 0.043, green: 0.325, blue: 0.58 };
|
|
6
8
|
const DEFAULT_HEADER_TEXT = { red: 0.95, green: 0.95, blue: 0.95 };
|
|
7
9
|
const DEFAULT_PADDING = 5;
|
|
8
10
|
const DEFAULT_PAGE_WIDTH = 612;
|
|
9
11
|
const DEFAULT_MARGIN = 72;
|
|
10
|
-
const DEFAULT_FONT_FAMILY = 'Arial';
|
|
11
|
-
const DEFAULT_FONT_SIZE = 11;
|
|
12
12
|
const DEFAULT_LINE_SPACING = 115;
|
|
13
|
-
const
|
|
14
|
-
const
|
|
13
|
+
const MIN_COL_WIDTH_PT = 60;
|
|
14
|
+
const PT_PER_CHAR_HEADER = 7;
|
|
15
15
|
export const WriteTableSchema = z.object({
|
|
16
16
|
documentId: z.string().describe('The Google Doc document ID'),
|
|
17
17
|
index: z.number().describe('Position in the document to insert the table. Must be a body-level index, not inside an existing table.'),
|
|
@@ -23,14 +23,6 @@ export const WriteTableSchema = z.object({
|
|
|
23
23
|
cellPadding: z.number().optional(),
|
|
24
24
|
}).optional().describe('Optional style overrides. Falls back to active preset, then defaults.'),
|
|
25
25
|
});
|
|
26
|
-
function hexToRgb(hex) {
|
|
27
|
-
const h = hex.replace('#', '');
|
|
28
|
-
return {
|
|
29
|
-
red: parseInt(h.substring(0, 2), 16) / 255,
|
|
30
|
-
green: parseInt(h.substring(2, 4), 16) / 255,
|
|
31
|
-
blue: parseInt(h.substring(4, 6), 16) / 255,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
26
|
export async function writeTable(args) {
|
|
35
27
|
const auth = getAuthClient();
|
|
36
28
|
const docs = google.docs({ version: 'v1', auth });
|
|
@@ -208,39 +200,51 @@ export async function writeTable(args) {
|
|
|
208
200
|
fields: 'paddingTop,paddingBottom,paddingLeft,paddingRight',
|
|
209
201
|
},
|
|
210
202
|
});
|
|
211
|
-
//
|
|
212
|
-
const
|
|
213
|
-
|
|
203
|
+
// Column widths: header-aware floors + proportional allocation
|
|
204
|
+
const headerLengths = args.headers.map(h => h.length);
|
|
205
|
+
const maxBodyLengths = new Array(colCount).fill(0);
|
|
206
|
+
for (const row of args.rows) {
|
|
214
207
|
for (let c = 0; c < colCount; c++) {
|
|
215
|
-
|
|
208
|
+
maxBodyLengths[c] = Math.max(maxBodyLengths[c], (row[c] || '').length);
|
|
216
209
|
}
|
|
217
210
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
211
|
+
// Floor per column: max(MIN_COL_WIDTH_PT, headerChars * PT_PER_CHAR_HEADER)
|
|
212
|
+
const floors = headerLengths.map(len => Math.max(MIN_COL_WIDTH_PT, len * PT_PER_CHAR_HEADER));
|
|
213
|
+
const totalFloors = floors.reduce((a, b) => a + b, 0);
|
|
214
|
+
let columnWidths;
|
|
215
|
+
let widthWarning;
|
|
216
|
+
if (totalFloors > usableWidth) {
|
|
217
|
+
// Floors exceed page width -- equal distribution fallback
|
|
218
|
+
const equalWidth = Math.round(usableWidth / colCount);
|
|
219
|
+
columnWidths = new Array(colCount).fill(equalWidth);
|
|
220
|
+
widthWarning = `Table has ${colCount} columns; minimum widths cannot be satisfied, content may wrap`;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Proportional allocation of remaining space
|
|
224
|
+
const remaining = usableWidth - totalFloors;
|
|
225
|
+
const totalBodyLen = maxBodyLengths.reduce((a, b) => a + b, 0);
|
|
226
|
+
columnWidths = floors.map((floor, c) => {
|
|
227
|
+
const proportional = totalBodyLen > 0
|
|
228
|
+
? Math.round(remaining * (maxBodyLengths[c] / totalBodyLen))
|
|
229
|
+
: Math.round(remaining / colCount);
|
|
230
|
+
return floor + proportional;
|
|
224
231
|
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
widthType: 'FIXED_WIDTH',
|
|
238
|
-
width: { magnitude: scaledWidths[c], unit: 'PT' },
|
|
239
|
-
},
|
|
240
|
-
fields: 'widthType,width',
|
|
232
|
+
}
|
|
233
|
+
// Adjust rounding error on last column
|
|
234
|
+
const widthTotal = columnWidths.reduce((a, b) => a + b, 0);
|
|
235
|
+
columnWidths[columnWidths.length - 1] += Math.round(usableWidth) - widthTotal;
|
|
236
|
+
for (let c = 0; c < colCount; c++) {
|
|
237
|
+
styleRequests.push({
|
|
238
|
+
updateTableColumnProperties: {
|
|
239
|
+
tableStartLocation: { index: table2.startIndex },
|
|
240
|
+
columnIndices: [c],
|
|
241
|
+
tableColumnProperties: {
|
|
242
|
+
widthType: 'FIXED_WIDTH',
|
|
243
|
+
width: { magnitude: columnWidths[c], unit: 'PT' },
|
|
241
244
|
},
|
|
242
|
-
|
|
243
|
-
|
|
245
|
+
fields: 'widthType,width',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
244
248
|
}
|
|
245
249
|
if (styleRequests.length > 0) {
|
|
246
250
|
await docs.documents.batchUpdate({
|
|
@@ -257,6 +261,7 @@ export async function writeTable(args) {
|
|
|
257
261
|
cols: colCount,
|
|
258
262
|
tableStartIndex: table2.startIndex,
|
|
259
263
|
requestCount: requests.length + styleRequests.length,
|
|
264
|
+
...(widthWarning ? { warning: widthWarning } : {}),
|
|
260
265
|
_apiMs: apiMs,
|
|
261
266
|
};
|
|
262
267
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function hexToRgb(hex) {
|
|
2
|
+
const h = hex.replace('#', '');
|
|
3
|
+
if (h.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(h)) {
|
|
4
|
+
throw new Error(`Invalid hex color '${hex}'. Use 6-character hex format (e.g., '#FF0000').`);
|
|
5
|
+
}
|
|
6
|
+
return {
|
|
7
|
+
red: parseInt(h.substring(0, 2), 16) / 255,
|
|
8
|
+
green: parseInt(h.substring(2, 4), 16) / 255,
|
|
9
|
+
blue: parseInt(h.substring(4, 6), 16) / 255,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function toColor(hex) {
|
|
13
|
+
return { color: { rgbColor: hexToRgb(hex) } };
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const DEFAULT_FONT_FAMILY = "Arial";
|
|
2
|
+
export declare const DEFAULT_FONT_SIZE = 11;
|
|
3
|
+
export declare const DEFAULT_FONT_COLOR = "#000000";
|
|
4
|
+
export interface TextStyleDefaults {
|
|
5
|
+
fontFamily: string;
|
|
6
|
+
fontSize: number;
|
|
7
|
+
bold: boolean;
|
|
8
|
+
italic: boolean;
|
|
9
|
+
color: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function getDefaultTextStyle(): TextStyleDefaults;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getPreset } from '../presets/config.js';
|
|
2
|
+
export const DEFAULT_FONT_FAMILY = 'Arial';
|
|
3
|
+
export const DEFAULT_FONT_SIZE = 11;
|
|
4
|
+
export const DEFAULT_FONT_COLOR = '#000000';
|
|
5
|
+
export function getDefaultTextStyle() {
|
|
6
|
+
try {
|
|
7
|
+
const { preset } = getPreset();
|
|
8
|
+
const normalText = preset.styles?.NORMAL_TEXT;
|
|
9
|
+
if (normalText) {
|
|
10
|
+
return {
|
|
11
|
+
fontFamily: normalText.fontFamily ?? DEFAULT_FONT_FAMILY,
|
|
12
|
+
fontSize: normalText.fontSize ?? DEFAULT_FONT_SIZE,
|
|
13
|
+
bold: normalText.bold ?? false,
|
|
14
|
+
italic: normalText.italic ?? false,
|
|
15
|
+
color: normalText.color ?? DEFAULT_FONT_COLOR,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// No active preset, use hardcoded defaults
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
24
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
25
|
+
bold: false,
|
|
26
|
+
italic: false,
|
|
27
|
+
color: DEFAULT_FONT_COLOR,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/package.json
CHANGED