gdocs-mcp 0.4.0 → 0.4.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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gdocs-mcp
|
|
2
2
|
|
|
3
|
-
Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit,
|
|
3
|
+
Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit, style, export, and collaborate on your Google Docs — with OAuth tokens that never leave your machine.
|
|
4
4
|
|
|
5
5
|
**Open-source. Self-hosted. Your tokens never leave your machine.**
|
|
6
6
|
|
|
@@ -46,9 +46,9 @@ Restart Claude Desktop. Ask Claude: *"List my recent Google Docs"* to verify.
|
|
|
46
46
|
claude mcp add gdocs -- npx gdocs-mcp
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
## Tools (
|
|
49
|
+
## Tools (28)
|
|
50
50
|
|
|
51
|
-
### Google Docs
|
|
51
|
+
### Google Docs — Read & Write
|
|
52
52
|
|
|
53
53
|
| Tool | Description |
|
|
54
54
|
|------|-------------|
|
|
@@ -63,11 +63,29 @@ claude mcp add gdocs -- npx gdocs-mcp
|
|
|
63
63
|
| `update_document` | Raw batchUpdate with 35+ request types |
|
|
64
64
|
| `unmerge_table_cells` | Unmerge previously merged table cells |
|
|
65
65
|
|
|
66
|
-
###
|
|
66
|
+
### Content Insertion
|
|
67
67
|
|
|
68
68
|
| Tool | Description |
|
|
69
69
|
|------|-------------|
|
|
70
|
-
| `
|
|
70
|
+
| `insert_image` | Insert an image from a URL with optional width/height sizing |
|
|
71
|
+
| `insert_table` | Create a table with specified rows (1-20) and columns (1-20) |
|
|
72
|
+
| `insert_page_break` | Insert a page break at a specific position |
|
|
73
|
+
| `insert_link` | Add a hyperlink to an existing text range |
|
|
74
|
+
|
|
75
|
+
### Document Management
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
|------|-------------|
|
|
79
|
+
| `export_document` | Export as PDF, DOCX, TXT, or HTML. Save to file or return base64 |
|
|
80
|
+
| `copy_document` | Duplicate a doc with a new title. Great for templates |
|
|
81
|
+
| `delete_document` | Move to trash. Requires exact title confirmation to prevent accidents |
|
|
82
|
+
|
|
83
|
+
### Comments
|
|
84
|
+
|
|
85
|
+
| Tool | Description |
|
|
86
|
+
|------|-------------|
|
|
87
|
+
| `get_comments` | List comments with author, content, resolved status, and replies |
|
|
88
|
+
| `add_comment` | Add a comment anchored to specific text in the document |
|
|
71
89
|
|
|
72
90
|
### Style Presets
|
|
73
91
|
|
|
@@ -81,7 +99,28 @@ Define how your documents look once, apply everywhere.
|
|
|
81
99
|
| `set_active_preset` | Set the default preset for new documents |
|
|
82
100
|
| `delete_style_preset` | Delete a custom preset |
|
|
83
101
|
|
|
84
|
-
|
|
102
|
+
### Formatting
|
|
103
|
+
|
|
104
|
+
| Tool | Description |
|
|
105
|
+
|------|-------------|
|
|
106
|
+
| `update_header_footer` | Create or update header/footer content and styling |
|
|
107
|
+
| `format_list` | Apply bullet, numbered, or remove list formatting (6 glyph presets) |
|
|
108
|
+
|
|
109
|
+
### Google Sheets
|
|
110
|
+
|
|
111
|
+
| Tool | Description |
|
|
112
|
+
|------|-------------|
|
|
113
|
+
| `get_charts` | List all charts in a spreadsheet with IDs and specs |
|
|
114
|
+
|
|
115
|
+
### Automation
|
|
116
|
+
|
|
117
|
+
| Tool | Description |
|
|
118
|
+
|------|-------------|
|
|
119
|
+
| `execute_script` | Run a function in a deployed Google Apps Script project |
|
|
120
|
+
|
|
121
|
+
## Style Presets
|
|
122
|
+
|
|
123
|
+
### Built-in Presets
|
|
85
124
|
|
|
86
125
|
| Preset | Font | Headings | Body |
|
|
87
126
|
|--------|------|----------|------|
|
|
@@ -90,9 +129,9 @@ Define how your documents look once, apply everywhere.
|
|
|
90
129
|
| `classic` | Georgia + Garamond | Dark blue, serif | 11pt, 1.4x spacing |
|
|
91
130
|
| `minimal` | Roboto | Black, light weight | 10.5pt, 1.5x spacing |
|
|
92
131
|
|
|
93
|
-
|
|
132
|
+
### Extract + Apply Workflow
|
|
94
133
|
|
|
95
|
-
|
|
134
|
+
Point to a Google Doc that already looks the way you want:
|
|
96
135
|
|
|
97
136
|
```
|
|
98
137
|
You: "Extract styles from my brand doc and save as 'brand'"
|
|
@@ -104,19 +143,41 @@ You: "Apply brand styles to this report"
|
|
|
104
143
|
|
|
105
144
|
One-step capture, one-step apply. No JSON editing needed.
|
|
106
145
|
|
|
107
|
-
|
|
146
|
+
### Style Properties
|
|
108
147
|
|
|
109
148
|
Presets support every text and paragraph property the Google Docs API exposes:
|
|
110
149
|
|
|
111
150
|
**Text:** font family, font size, bold, italic, underline, strikethrough, small caps, text color, background color, baseline offset
|
|
112
151
|
|
|
113
|
-
**Paragraph:** alignment (left/center/right/justified), line spacing, space above/below, first line indent, start/end indent, keep lines together, keep with next, direction (LTR/RTL), paragraph borders
|
|
152
|
+
**Paragraph:** alignment (left/center/right/justified), line spacing, space above/below, first line indent, start/end indent, keep lines together, keep with next, direction (LTR/RTL), paragraph borders
|
|
114
153
|
|
|
115
|
-
**Document:** page margins
|
|
154
|
+
**Document:** page margins, page size
|
|
116
155
|
|
|
117
156
|
**Tables:** header row background and text color, bold headers, border color and width, cell padding, alternating row backgrounds
|
|
118
157
|
|
|
119
|
-
Only properties you specify are applied. Omitted properties are left unchanged. Config
|
|
158
|
+
Only properties you specify are applied. Omitted properties are left unchanged. Config stored at `~/.gdocs-mcp/styles.json`.
|
|
159
|
+
|
|
160
|
+
## Upgrading to v0.4
|
|
161
|
+
|
|
162
|
+
v0.4 expands the Drive scope from `drive.readonly` to `drive` (needed for export, copy, delete, and comments). You must re-authenticate:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npx gdocs-mcp auth
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If you don't need the new tools and want to keep the old scope:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
GDOCS_MCP_READONLY=1 npx gdocs-mcp
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Scope Justification
|
|
175
|
+
|
|
176
|
+
| Scope | Tools |
|
|
177
|
+
|-------|-------|
|
|
178
|
+
| `documents` | read, create, replace, update, insert image/table/link/page break, style presets, header/footer, format list |
|
|
179
|
+
| `spreadsheets.readonly` | get_charts |
|
|
180
|
+
| `drive` | search, export, copy, delete, get/add comments |
|
|
120
181
|
|
|
121
182
|
## Limitations
|
|
122
183
|
|
|
@@ -126,8 +187,22 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
|
|
|
126
187
|
- **Custom named styles** cannot be created — only the 9 built-in types (Title, Subtitle, Heading 1-6, Normal Text)
|
|
127
188
|
- **Conditional formatting** does not exist in Google Docs
|
|
128
189
|
- **Multi-column layout** is not exposed via the API
|
|
129
|
-
- **Bullet/numbered list glyph types** are per-paragraph, not configurable via style presets
|
|
130
190
|
- **Table header detection** assumes row 0 is the header — multi-row headers are not detected
|
|
191
|
+
- **Suggested edits** (propose mode) are not yet supported
|
|
192
|
+
- **Comment replies** are not yet supported — only top-level comments
|
|
193
|
+
- **Image URLs** are passed directly to Google's API — the MCP server does not fetch them
|
|
194
|
+
|
|
195
|
+
## Performance Logging
|
|
196
|
+
|
|
197
|
+
Every tool call logs latency to stderr:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
[gdocs-mcp] read_document: 342ms (api: 298ms)
|
|
201
|
+
[gdocs-mcp] apply_style_preset: 1247ms (api: 1102ms, 1593 requests)
|
|
202
|
+
[gdocs-mcp] list_style_presets: 2ms (local)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Disable with `GDOCS_MCP_QUIET=1`.
|
|
131
206
|
|
|
132
207
|
## Security
|
|
133
208
|
|
|
@@ -136,16 +211,25 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
|
|
|
136
211
|
- Style presets stored at `~/.gdocs-mcp/styles.json`
|
|
137
212
|
- No telemetry, no data collection, no third-party token storage
|
|
138
213
|
- Tokens refresh automatically; if refresh fails, run `npx gdocs-mcp auth` again
|
|
214
|
+
- `delete_document` requires exact title match — not a boolean flag
|
|
139
215
|
|
|
140
216
|
## Configuration
|
|
141
217
|
|
|
142
|
-
Credentials and tokens
|
|
218
|
+
Credentials and tokens stored in `~/.gdocs-mcp/` by default. Override with:
|
|
143
219
|
|
|
144
220
|
```bash
|
|
145
221
|
GDOCS_CREDENTIALS=/path/to/credentials.json npx gdocs-mcp
|
|
146
222
|
```
|
|
147
223
|
|
|
148
|
-
|
|
224
|
+
Environment variables:
|
|
225
|
+
|
|
226
|
+
| Variable | Effect |
|
|
227
|
+
|----------|--------|
|
|
228
|
+
| `GDOCS_CREDENTIALS` | Override credentials.json path |
|
|
229
|
+
| `GDOCS_MCP_READONLY` | Set to `1` to disable write tools (export, copy, delete, comments) |
|
|
230
|
+
| `GDOCS_MCP_QUIET` | Set to `1` to disable performance logging |
|
|
231
|
+
|
|
232
|
+
For faster startup, install globally:
|
|
149
233
|
|
|
150
234
|
```bash
|
|
151
235
|
npm install -g gdocs-mcp
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,8 @@ import { CopyDocumentSchema, copyDocument } from './tools/copy-document.js';
|
|
|
29
29
|
import { DeleteDocumentSchema, deleteDocument } from './tools/delete-document.js';
|
|
30
30
|
import { GetCommentsSchema, getComments } from './tools/get-comments.js';
|
|
31
31
|
import { AddCommentSchema, addComment } from './tools/add-comment.js';
|
|
32
|
-
|
|
32
|
+
import { ReadDocumentStructureSchema, readDocumentStructure } from './tools/read-document-structure.js';
|
|
33
|
+
const VERSION = '0.4.2';
|
|
33
34
|
const QUIET = process.env.GDOCS_MCP_QUIET === '1';
|
|
34
35
|
const server = new McpServer({
|
|
35
36
|
name: 'gdocs-mcp',
|
|
@@ -125,6 +126,8 @@ registerTool('copy_document', 'Create a copy of a Google Doc with a new title. I
|
|
|
125
126
|
registerTool('delete_document', 'Move a Google Doc to trash. Requires confirmTitle matching the exact document title.', DeleteDocumentSchema, deleteDocument);
|
|
126
127
|
registerTool('get_comments', 'List comments on a document with author, content, resolved status, and replies.', GetCommentsSchema, getComments);
|
|
127
128
|
registerTool('add_comment', 'Add a comment anchored to specific text in the document. Anchors to first occurrence of quotedText.', AddCommentSchema, addComment);
|
|
129
|
+
// v0.4.2 structure tool
|
|
130
|
+
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);
|
|
128
131
|
async function main() {
|
|
129
132
|
const transport = new StdioServerTransport();
|
|
130
133
|
await server.connect(transport);
|
|
@@ -10,11 +10,23 @@ export const ExtractDocumentStylesSchema = z.object({
|
|
|
10
10
|
export async function extractDocumentStyles(args) {
|
|
11
11
|
const auth = getAuthClient();
|
|
12
12
|
const docs = google.docs({ version: 'v1', auth });
|
|
13
|
+
const apiStart = performance.now();
|
|
13
14
|
const doc = await docs.documents.get({ documentId: args.documentId });
|
|
15
|
+
const bodyContent = doc.data.body?.content || [];
|
|
16
|
+
// Step 1: Extract named style definitions (baseline)
|
|
17
|
+
const namedStyleDefs = extractNamedStyleDefinitions(doc.data.namedStyles);
|
|
18
|
+
// Step 2: Extract actual rendered styles from paragraphs (majority vote)
|
|
19
|
+
const renderedStyles = extractRenderedStyles(bodyContent);
|
|
20
|
+
// Step 3: Merge — rendered values take priority, collect warnings
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const mergedStyles = mergeStyles(namedStyleDefs, renderedStyles, warnings);
|
|
23
|
+
// Step 4: Propagate inherited properties from NORMAL_TEXT to headings
|
|
24
|
+
propagateInheritedProperties(mergedStyles);
|
|
25
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
14
26
|
const preset = {
|
|
15
27
|
document: extractDocumentStyle(doc.data.documentStyle),
|
|
16
|
-
styles:
|
|
17
|
-
table: extractTableStyles(
|
|
28
|
+
styles: mergedStyles,
|
|
29
|
+
table: extractTableStyles(bodyContent),
|
|
18
30
|
};
|
|
19
31
|
if (args.saveAs) {
|
|
20
32
|
savePreset(args.saveAs, preset);
|
|
@@ -23,7 +35,9 @@ export async function extractDocumentStyles(args) {
|
|
|
23
35
|
documentId: args.documentId,
|
|
24
36
|
title: doc.data.title,
|
|
25
37
|
preset,
|
|
38
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
26
39
|
savedAs: args.saveAs || null,
|
|
40
|
+
_apiMs: apiMs,
|
|
27
41
|
};
|
|
28
42
|
}
|
|
29
43
|
function extractDocumentStyle(docStyle) {
|
|
@@ -42,7 +56,51 @@ function extractDocumentStyle(docStyle) {
|
|
|
42
56
|
config.pageHeight = docStyle.pageSize.height.magnitude;
|
|
43
57
|
return config;
|
|
44
58
|
}
|
|
45
|
-
function
|
|
59
|
+
function extractStyleFromRaw(ts, ps) {
|
|
60
|
+
const config = {};
|
|
61
|
+
if (ts.weightedFontFamily?.fontFamily)
|
|
62
|
+
config.fontFamily = ts.weightedFontFamily.fontFamily;
|
|
63
|
+
if (ts.fontSize?.magnitude !== undefined)
|
|
64
|
+
config.fontSize = ts.fontSize.magnitude;
|
|
65
|
+
if (ts.bold !== undefined)
|
|
66
|
+
config.bold = ts.bold;
|
|
67
|
+
if (ts.italic !== undefined)
|
|
68
|
+
config.italic = ts.italic;
|
|
69
|
+
if (ts.underline !== undefined)
|
|
70
|
+
config.underline = ts.underline;
|
|
71
|
+
if (ts.strikethrough !== undefined)
|
|
72
|
+
config.strikethrough = ts.strikethrough;
|
|
73
|
+
if (ts.smallCaps !== undefined)
|
|
74
|
+
config.smallCaps = ts.smallCaps;
|
|
75
|
+
if (ts.foregroundColor?.color?.rgbColor)
|
|
76
|
+
config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
|
|
77
|
+
if (ts.backgroundColor?.color?.rgbColor)
|
|
78
|
+
config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
|
|
79
|
+
if (ts.baselineOffset)
|
|
80
|
+
config.baselineOffset = ts.baselineOffset;
|
|
81
|
+
if (ps.alignment)
|
|
82
|
+
config.alignment = ps.alignment;
|
|
83
|
+
if (ps.lineSpacing !== undefined)
|
|
84
|
+
config.lineSpacing = ps.lineSpacing;
|
|
85
|
+
if (ps.spaceAbove?.magnitude !== undefined)
|
|
86
|
+
config.spaceAbove = ps.spaceAbove.magnitude;
|
|
87
|
+
if (ps.spaceBelow?.magnitude !== undefined)
|
|
88
|
+
config.spaceBelow = ps.spaceBelow.magnitude;
|
|
89
|
+
if (ps.indentFirstLine?.magnitude !== undefined)
|
|
90
|
+
config.indentFirstLine = ps.indentFirstLine.magnitude;
|
|
91
|
+
if (ps.indentStart?.magnitude !== undefined)
|
|
92
|
+
config.indentStart = ps.indentStart.magnitude;
|
|
93
|
+
if (ps.indentEnd?.magnitude !== undefined)
|
|
94
|
+
config.indentEnd = ps.indentEnd.magnitude;
|
|
95
|
+
if (ps.keepLinesTogether !== undefined)
|
|
96
|
+
config.keepLinesTogether = ps.keepLinesTogether;
|
|
97
|
+
if (ps.keepWithNext !== undefined)
|
|
98
|
+
config.keepWithNext = ps.keepWithNext;
|
|
99
|
+
if (ps.direction)
|
|
100
|
+
config.direction = ps.direction;
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
function extractNamedStyleDefinitions(namedStyles) {
|
|
46
104
|
const result = {};
|
|
47
105
|
if (!namedStyles?.styles)
|
|
48
106
|
return result;
|
|
@@ -50,59 +108,122 @@ function extractNamedStyles(namedStyles) {
|
|
|
50
108
|
const type = style.namedStyleType;
|
|
51
109
|
if (!VALID_NAMED_STYLE_TYPES.includes(type))
|
|
52
110
|
continue;
|
|
53
|
-
const config = {};
|
|
54
|
-
const ts = style.textStyle || {};
|
|
55
|
-
const ps = style.paragraphStyle || {};
|
|
56
|
-
// Text style
|
|
57
|
-
if (ts.weightedFontFamily?.fontFamily)
|
|
58
|
-
config.fontFamily = ts.weightedFontFamily.fontFamily;
|
|
59
|
-
if (ts.fontSize?.magnitude !== undefined)
|
|
60
|
-
config.fontSize = ts.fontSize.magnitude;
|
|
61
|
-
if (ts.bold !== undefined)
|
|
62
|
-
config.bold = ts.bold;
|
|
63
|
-
if (ts.italic !== undefined)
|
|
64
|
-
config.italic = ts.italic;
|
|
65
|
-
if (ts.underline !== undefined)
|
|
66
|
-
config.underline = ts.underline;
|
|
67
|
-
if (ts.strikethrough !== undefined)
|
|
68
|
-
config.strikethrough = ts.strikethrough;
|
|
69
|
-
if (ts.smallCaps !== undefined)
|
|
70
|
-
config.smallCaps = ts.smallCaps;
|
|
71
|
-
if (ts.foregroundColor?.color?.rgbColor)
|
|
72
|
-
config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
|
|
73
|
-
if (ts.backgroundColor?.color?.rgbColor)
|
|
74
|
-
config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
|
|
75
|
-
if (ts.baselineOffset)
|
|
76
|
-
config.baselineOffset = ts.baselineOffset;
|
|
77
|
-
// Paragraph style
|
|
78
|
-
if (ps.alignment)
|
|
79
|
-
config.alignment = ps.alignment;
|
|
80
|
-
if (ps.lineSpacing !== undefined)
|
|
81
|
-
config.lineSpacing = ps.lineSpacing;
|
|
82
|
-
if (ps.spaceAbove?.magnitude !== undefined)
|
|
83
|
-
config.spaceAbove = ps.spaceAbove.magnitude;
|
|
84
|
-
if (ps.spaceBelow?.magnitude !== undefined)
|
|
85
|
-
config.spaceBelow = ps.spaceBelow.magnitude;
|
|
86
|
-
if (ps.indentFirstLine?.magnitude !== undefined)
|
|
87
|
-
config.indentFirstLine = ps.indentFirstLine.magnitude;
|
|
88
|
-
if (ps.indentStart?.magnitude !== undefined)
|
|
89
|
-
config.indentStart = ps.indentStart.magnitude;
|
|
90
|
-
if (ps.indentEnd?.magnitude !== undefined)
|
|
91
|
-
config.indentEnd = ps.indentEnd.magnitude;
|
|
92
|
-
if (ps.keepLinesTogether !== undefined)
|
|
93
|
-
config.keepLinesTogether = ps.keepLinesTogether;
|
|
94
|
-
if (ps.keepWithNext !== undefined)
|
|
95
|
-
config.keepWithNext = ps.keepWithNext;
|
|
96
|
-
if (ps.direction)
|
|
97
|
-
config.direction = ps.direction;
|
|
111
|
+
const config = extractStyleFromRaw(style.textStyle || {}, style.paragraphStyle || {});
|
|
98
112
|
if (Object.keys(config).length > 0) {
|
|
99
113
|
result[type] = config;
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
116
|
return result;
|
|
103
117
|
}
|
|
118
|
+
// Majority vote: returns the most common value for a property across samples
|
|
119
|
+
function majorityVote(values) {
|
|
120
|
+
const counts = new Map();
|
|
121
|
+
for (const v of values) {
|
|
122
|
+
if (v === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const key = JSON.stringify(v);
|
|
125
|
+
const existing = counts.get(key);
|
|
126
|
+
if (existing) {
|
|
127
|
+
existing.count++;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
counts.set(key, { value: v, count: 1 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let winner = undefined;
|
|
134
|
+
let maxCount = 0;
|
|
135
|
+
for (const { value, count } of counts.values()) {
|
|
136
|
+
if (count > maxCount) {
|
|
137
|
+
maxCount = count;
|
|
138
|
+
winner = value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return winner;
|
|
142
|
+
}
|
|
143
|
+
function extractRenderedStyles(content) {
|
|
144
|
+
const result = {};
|
|
145
|
+
// Collect up to 10 paragraphs per style type
|
|
146
|
+
const samplesByType = {};
|
|
147
|
+
for (const element of content) {
|
|
148
|
+
if (!element.paragraph)
|
|
149
|
+
continue;
|
|
150
|
+
const styleType = element.paragraph.paragraphStyle?.namedStyleType;
|
|
151
|
+
if (!styleType || !VALID_NAMED_STYLE_TYPES.includes(styleType))
|
|
152
|
+
continue;
|
|
153
|
+
const text = element.paragraph.elements
|
|
154
|
+
?.map((el) => el.textRun?.content || '').join('').trim();
|
|
155
|
+
if (!text)
|
|
156
|
+
continue;
|
|
157
|
+
if (!samplesByType[styleType])
|
|
158
|
+
samplesByType[styleType] = [];
|
|
159
|
+
if (samplesByType[styleType].length >= 10)
|
|
160
|
+
continue;
|
|
161
|
+
// Read paragraph-level text style from first text run
|
|
162
|
+
const firstRunTextStyle = element.paragraph.elements?.[0]?.textRun?.textStyle || {};
|
|
163
|
+
const paragraphStyle = element.paragraph.paragraphStyle || {};
|
|
164
|
+
const config = extractStyleFromRaw(firstRunTextStyle, paragraphStyle);
|
|
165
|
+
samplesByType[styleType].push(config);
|
|
166
|
+
}
|
|
167
|
+
// Majority vote per property across sampled paragraphs
|
|
168
|
+
const allProperties = [
|
|
169
|
+
'fontFamily', 'fontSize', 'bold', 'italic', 'underline', 'strikethrough',
|
|
170
|
+
'smallCaps', 'color', 'backgroundColor', 'baselineOffset',
|
|
171
|
+
'alignment', 'lineSpacing', 'spaceAbove', 'spaceBelow',
|
|
172
|
+
'indentFirstLine', 'indentStart', 'indentEnd',
|
|
173
|
+
'keepLinesTogether', 'keepWithNext', 'direction',
|
|
174
|
+
];
|
|
175
|
+
for (const [styleType, samples] of Object.entries(samplesByType)) {
|
|
176
|
+
const merged = {};
|
|
177
|
+
for (const prop of allProperties) {
|
|
178
|
+
const values = samples.map(s => s[prop]);
|
|
179
|
+
const winner = majorityVote(values);
|
|
180
|
+
if (winner !== undefined) {
|
|
181
|
+
;
|
|
182
|
+
merged[prop] = winner;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(merged).length > 0) {
|
|
186
|
+
result[styleType] = merged;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function mergeStyles(definitions, rendered, warnings) {
|
|
192
|
+
const merged = {};
|
|
193
|
+
const allTypes = new Set([...Object.keys(definitions), ...Object.keys(rendered)]);
|
|
194
|
+
for (const type of allTypes) {
|
|
195
|
+
const def = definitions[type] || {};
|
|
196
|
+
const ren = rendered[type] || {};
|
|
197
|
+
// Check for differences and log warnings
|
|
198
|
+
for (const [key, renValue] of Object.entries(ren)) {
|
|
199
|
+
const defValue = def[key];
|
|
200
|
+
if (defValue !== undefined && renValue !== undefined && JSON.stringify(defValue) !== JSON.stringify(renValue)) {
|
|
201
|
+
warnings.push(`${type}.${key}: definition says ${JSON.stringify(defValue)}, rendered shows ${JSON.stringify(renValue)} (using rendered)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Rendered values override definitions
|
|
205
|
+
merged[type] = { ...def, ...ren };
|
|
206
|
+
}
|
|
207
|
+
return merged;
|
|
208
|
+
}
|
|
209
|
+
// Propagate fontFamily, color, bold, italic from NORMAL_TEXT to headings that don't specify them
|
|
210
|
+
function propagateInheritedProperties(styles) {
|
|
211
|
+
const normalText = styles['NORMAL_TEXT'];
|
|
212
|
+
if (!normalText)
|
|
213
|
+
return;
|
|
214
|
+
const propertiesToPropagate = ['fontFamily', 'color', 'bold', 'italic'];
|
|
215
|
+
for (const [type, config] of Object.entries(styles)) {
|
|
216
|
+
if (type === 'NORMAL_TEXT')
|
|
217
|
+
continue;
|
|
218
|
+
for (const prop of propertiesToPropagate) {
|
|
219
|
+
if (config[prop] === undefined && normalText[prop] !== undefined) {
|
|
220
|
+
;
|
|
221
|
+
config[prop] = normalText[prop];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
104
226
|
function extractTableStyles(content) {
|
|
105
|
-
// Find first table and extract its styling as the template
|
|
106
227
|
for (const element of content) {
|
|
107
228
|
if (!element.table)
|
|
108
229
|
continue;
|
|
@@ -127,7 +248,17 @@ function extractTableStyles(content) {
|
|
|
127
248
|
config.cellPadding = cs.paddingTop.magnitude;
|
|
128
249
|
}
|
|
129
250
|
}
|
|
130
|
-
// Check
|
|
251
|
+
// Check header text style
|
|
252
|
+
const headerTextRun = firstRow.tableCells?.[0]?.content?.[0]?.paragraph?.elements?.[0]?.textRun;
|
|
253
|
+
if (headerTextRun?.textStyle) {
|
|
254
|
+
const hts = headerTextRun.textStyle;
|
|
255
|
+
if (hts.bold !== undefined)
|
|
256
|
+
config.headerBold = hts.bold;
|
|
257
|
+
if (hts.foregroundColor?.color?.rgbColor) {
|
|
258
|
+
config.headerTextColor = rgbToHex(hts.foregroundColor.color.rgbColor);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Check third row for alternating background
|
|
131
262
|
if (table.tableRows?.length > 2) {
|
|
132
263
|
const thirdRow = table.tableRows[2];
|
|
133
264
|
const thirdCell = thirdRow?.tableCells?.[0];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const ReadDocumentStructureSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
}, z.core.$strip>;
|
|
5
|
+
interface StructureElement {
|
|
6
|
+
type: 'paragraph' | 'table' | 'sectionBreak' | 'tableOfContents';
|
|
7
|
+
startIndex: number;
|
|
8
|
+
endIndex: number;
|
|
9
|
+
style?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
truncated?: boolean;
|
|
12
|
+
rows?: number;
|
|
13
|
+
cols?: number;
|
|
14
|
+
listId?: string;
|
|
15
|
+
nestingLevel?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function readDocumentStructure(args: z.infer<typeof ReadDocumentStructureSchema>): Promise<{
|
|
18
|
+
documentId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
totalElements: number;
|
|
21
|
+
elements: StructureElement[];
|
|
22
|
+
_apiMs: number;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
const TEXT_PREVIEW_LENGTH = 80;
|
|
5
|
+
// Field mask: only structural data, no full text styling
|
|
6
|
+
const FIELD_MASK = [
|
|
7
|
+
'title',
|
|
8
|
+
'body.content.startIndex',
|
|
9
|
+
'body.content.endIndex',
|
|
10
|
+
'body.content.paragraph.paragraphStyle.namedStyleType',
|
|
11
|
+
'body.content.paragraph.bullet',
|
|
12
|
+
'body.content.paragraph.elements.startIndex',
|
|
13
|
+
'body.content.paragraph.elements.endIndex',
|
|
14
|
+
'body.content.paragraph.elements.textRun.content',
|
|
15
|
+
'body.content.paragraph.elements.inlineObjectElement',
|
|
16
|
+
'body.content.table.rows',
|
|
17
|
+
'body.content.table.columns',
|
|
18
|
+
'body.content.sectionBreak',
|
|
19
|
+
'body.content.tableOfContents',
|
|
20
|
+
].join(',');
|
|
21
|
+
export const ReadDocumentStructureSchema = z.object({
|
|
22
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
23
|
+
});
|
|
24
|
+
export async function readDocumentStructure(args) {
|
|
25
|
+
const auth = getAuthClient();
|
|
26
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
27
|
+
const apiStart = performance.now();
|
|
28
|
+
const res = await docs.documents.get({
|
|
29
|
+
documentId: args.documentId,
|
|
30
|
+
fields: FIELD_MASK,
|
|
31
|
+
});
|
|
32
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
33
|
+
const content = res.data.body?.content || [];
|
|
34
|
+
const elements = [];
|
|
35
|
+
for (const element of content) {
|
|
36
|
+
const startIndex = element.startIndex ?? 0;
|
|
37
|
+
const endIndex = element.endIndex ?? 0;
|
|
38
|
+
if (element.sectionBreak !== undefined) {
|
|
39
|
+
elements.push({ type: 'sectionBreak', startIndex, endIndex });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (element.tableOfContents !== undefined) {
|
|
43
|
+
elements.push({ type: 'tableOfContents', startIndex, endIndex });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (element.table) {
|
|
47
|
+
elements.push({
|
|
48
|
+
type: 'table',
|
|
49
|
+
startIndex,
|
|
50
|
+
endIndex,
|
|
51
|
+
rows: element.table.rows ?? 0,
|
|
52
|
+
cols: element.table.columns ?? 0,
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (element.paragraph) {
|
|
57
|
+
const style = element.paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
|
|
58
|
+
// Build text preview from elements
|
|
59
|
+
let hasInlineObject = false;
|
|
60
|
+
let fullText = '';
|
|
61
|
+
for (const el of element.paragraph.elements || []) {
|
|
62
|
+
if (el.inlineObjectElement) {
|
|
63
|
+
hasInlineObject = true;
|
|
64
|
+
}
|
|
65
|
+
if (el.textRun?.content) {
|
|
66
|
+
fullText += el.textRun.content;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Clean trailing newline
|
|
70
|
+
fullText = fullText.replace(/\n$/, '');
|
|
71
|
+
// Determine text preview
|
|
72
|
+
let text;
|
|
73
|
+
let truncated;
|
|
74
|
+
if (!fullText && hasInlineObject) {
|
|
75
|
+
text = '[image]';
|
|
76
|
+
truncated = false;
|
|
77
|
+
}
|
|
78
|
+
else if (fullText.length > TEXT_PREVIEW_LENGTH) {
|
|
79
|
+
text = fullText.substring(0, TEXT_PREVIEW_LENGTH) + '...';
|
|
80
|
+
truncated = true;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
text = fullText;
|
|
84
|
+
truncated = false;
|
|
85
|
+
}
|
|
86
|
+
const entry = {
|
|
87
|
+
type: 'paragraph',
|
|
88
|
+
startIndex,
|
|
89
|
+
endIndex,
|
|
90
|
+
style,
|
|
91
|
+
text,
|
|
92
|
+
truncated,
|
|
93
|
+
};
|
|
94
|
+
// Add list metadata if present
|
|
95
|
+
const bullet = element.paragraph.bullet;
|
|
96
|
+
if (bullet) {
|
|
97
|
+
if (bullet.listId)
|
|
98
|
+
entry.listId = bullet.listId;
|
|
99
|
+
if (bullet.nestingLevel !== undefined && bullet.nestingLevel !== null) {
|
|
100
|
+
entry.nestingLevel = bullet.nestingLevel;
|
|
101
|
+
}
|
|
102
|
+
else if (bullet.listId) {
|
|
103
|
+
entry.nestingLevel = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
elements.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
documentId: args.documentId,
|
|
111
|
+
title: res.data.title || 'Untitled',
|
|
112
|
+
totalElements: elements.length,
|
|
113
|
+
elements,
|
|
114
|
+
_apiMs: apiMs,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/package.json
CHANGED