@piotr-agier/google-drive-mcp 1.7.2 → 1.7.4

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
@@ -240,13 +240,20 @@ Add this configuration to use the Docker container with Claude Desktop:
240
240
 
241
241
  The server supports multiple methods for providing OAuth credentials (in order of priority):
242
242
 
243
- #### 1. **Environment Variable** (Recommended)
243
+ #### 1. **Environment Variable** (Highest Priority)
244
244
  ```bash
245
245
  export GOOGLE_DRIVE_OAUTH_CREDENTIALS="/path/to/your/gcp-oauth.keys.json"
246
246
  ```
247
247
 
248
- #### 2. **Default File Location**
249
- Place `gcp-oauth.keys.json` in the project root directory
248
+ #### 2. **Config Directory** (Recommended)
249
+ Place `gcp-oauth.keys.json` in the XDG config directory:
250
+ ```
251
+ ~/.config/google-drive-mcp/gcp-oauth.keys.json
252
+ ```
253
+ This is the recommended location — it works reliably with `npx`, global installs, and local setups.
254
+
255
+ #### 3. **Project Root** (Legacy Fallback)
256
+ Place `gcp-oauth.keys.json` in the project root directory. This still works for local development but is unreliable with `npx` or global installs.
250
257
 
251
258
  ### OAuth Scope Configuration
252
259
 
@@ -580,11 +587,12 @@ Add the server to your Claude Desktop configuration:
580
587
  - `uploadToSameFolder`: Upload to same folder as document (optional, default: true)
581
588
 
582
589
  #### Comments
583
- - **listComments** - List all comments in a Google Document
590
+ - **listComments** - List all comments in a Google Document with position context, character offsets, and full reply chains
584
591
  - `documentId`: Document ID
585
592
  - `includeDeleted`: Include deleted comments (optional, default: false)
586
593
  - `pageSize`: Max comments to return, 1-100 (optional, default: 100)
587
594
  - `pageToken`: Token for next page of results (optional)
595
+ - Returns surrounding context and Docs API character offsets for each comment using a two-tiered approach (Docs API text matching, DOCX export fallback for ambiguous matches)
588
596
 
589
597
  - **getComment** - Get a specific comment with its full thread of replies
590
598
  - `documentId`: Document ID
@@ -942,15 +950,15 @@ After revoking access, you'll need to re-authenticate the next time you use the
942
950
  #### "OAuth credentials not found"
943
951
  ```
944
952
  OAuth credentials not found. Please provide credentials using one of these methods:
945
- 1. Environment variable:
953
+ 1. Config directory (recommended):
954
+ Place your gcp-oauth.keys.json file in: ~/.config/google-drive-mcp/
955
+ 2. Environment variable:
946
956
  export GOOGLE_DRIVE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json"
947
- 2. Default file path:
948
- Place your gcp-oauth.keys.json file in the package root directory.
949
957
  ```
950
958
 
951
959
  **Solution:**
952
960
  - Download credentials from Google Cloud Console
953
- - Either set the environment variable or place the file in the project root
961
+ - Place the file in `~/.config/google-drive-mcp/gcp-oauth.keys.json` (recommended), or set the environment variable
954
962
  - Ensure the file has proper read permissions
955
963
 
956
964
  #### "Authentication failed" or Browser doesn't open
@@ -1143,7 +1151,7 @@ npm run typecheck # Type checking without compilation
1143
1151
  - `npm run typecheck` - Run TypeScript type checking only
1144
1152
  - `npm run lint` - Run TypeScript type checking (alias for typecheck)
1145
1153
  - `npm run prepare` - Auto-runs build before npm publish
1146
- - `npm test` - Run tests (placeholder - no tests implemented yet)
1154
+ - `npm test` - Run unit tests
1147
1155
 
1148
1156
  ## Advanced Configuration
1149
1157
 
@@ -1155,7 +1163,8 @@ npm run typecheck # Type checking without compilation
1155
1163
  | Variable | Description | Example |
1156
1164
  |----------|-------------|---------|
1157
1165
  | `GOOGLE_DRIVE_OAUTH_CREDENTIALS` | Path to your OAuth credentials JSON file | `/home/user/secrets/oauth.json` |
1158
- | *(or place file at)* | Default location: `gcp-oauth.keys.json` in project root | `./gcp-oauth.keys.json` |
1166
+ | *(or place file at)* | Config directory (recommended): `~/.config/google-drive-mcp/gcp-oauth.keys.json` | `~/.config/google-drive-mcp/gcp-oauth.keys.json` |
1167
+ | *(or place file at)* | Project root (legacy fallback): `gcp-oauth.keys.json` | `./gcp-oauth.keys.json` |
1159
1168
 
1160
1169
  **Optional** (for customization):
1161
1170
  | Variable | Description | Default | Example |
package/dist/index.js CHANGED
@@ -29,14 +29,16 @@ function getProjectRoot() {
29
29
  const projectRoot = path.join(__dirname2, "..", "..");
30
30
  return path.resolve(projectRoot);
31
31
  }
32
+ function getConfigDir() {
33
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
34
+ return path.join(configHome, "google-drive-mcp");
35
+ }
32
36
  function getSecureTokenPath() {
33
37
  const customTokenPath = process.env.GOOGLE_DRIVE_MCP_TOKEN_PATH;
34
38
  if (customTokenPath) {
35
39
  return path.resolve(customTokenPath);
36
40
  }
37
- const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
38
- const tokenDir = path.join(configHome, "google-drive-mcp");
39
- return path.join(tokenDir, "tokens.json");
41
+ return path.join(getConfigDir(), "tokens.json");
40
42
  }
41
43
  function getLegacyTokenPath() {
42
44
  const projectRoot = getProjectRoot();
@@ -49,26 +51,29 @@ function getAdditionalLegacyPaths() {
49
51
  path.join(process.cwd(), ".gcp-saved-tokens.json")
50
52
  ].filter(Boolean);
51
53
  }
52
- function getKeysFilePath() {
54
+ function getKeysFilePaths() {
55
+ const paths = [];
53
56
  const envCredentialsPath = process.env.GOOGLE_DRIVE_OAUTH_CREDENTIALS;
54
57
  if (envCredentialsPath) {
55
- return path.resolve(envCredentialsPath);
58
+ paths.push(path.resolve(envCredentialsPath));
56
59
  }
60
+ paths.push(path.join(getConfigDir(), "gcp-oauth.keys.json"));
57
61
  const projectRoot = getProjectRoot();
58
- const keysPath = path.join(projectRoot, "gcp-oauth.keys.json");
59
- return keysPath;
62
+ paths.push(path.join(projectRoot, "gcp-oauth.keys.json"));
63
+ return paths;
60
64
  }
61
65
  function generateCredentialsErrorMessage() {
66
+ const configDir = getConfigDir();
62
67
  return `
63
68
  OAuth credentials not found. Please provide credentials using one of these methods:
64
69
 
65
- 1. Environment variable:
70
+ 1. Config directory (recommended):
71
+ Place your gcp-oauth.keys.json file in: ${configDir}/
72
+
73
+ 2. Environment variable:
66
74
  Set GOOGLE_DRIVE_OAUTH_CREDENTIALS to the path of your credentials file:
67
75
  export GOOGLE_DRIVE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json"
68
76
 
69
- 2. Default file path:
70
- Place your gcp-oauth.keys.json file in the package root directory.
71
-
72
77
  Token storage:
73
78
  - Tokens are saved to: ${getSecureTokenPath()}
74
79
  - To use a custom token location, set GOOGLE_DRIVE_MCP_TOKEN_PATH environment variable
@@ -83,9 +88,7 @@ To get OAuth credentials:
83
88
  }
84
89
 
85
90
  // src/auth/client.ts
86
- async function loadCredentialsFromFile() {
87
- const keysContent = await fs.readFile(getKeysFilePath(), "utf-8");
88
- const keys = JSON.parse(keysContent);
91
+ function parseCredentialsFile(keys) {
89
92
  if (keys.installed) {
90
93
  const { client_id, client_secret, redirect_uris } = keys.installed;
91
94
  return { client_id, client_secret, redirect_uris };
@@ -102,6 +105,21 @@ async function loadCredentialsFromFile() {
102
105
  throw new Error('Invalid credentials file format. Expected either "installed", "web" object or direct client_id field.');
103
106
  }
104
107
  }
108
+ async function loadCredentialsFromFile() {
109
+ const paths = getKeysFilePaths();
110
+ for (const keysPath of paths) {
111
+ try {
112
+ const keysContent = await fs.readFile(keysPath, "utf-8");
113
+ const keys = JSON.parse(keysContent);
114
+ return parseCredentialsFile(keys);
115
+ } catch (err) {
116
+ if (err instanceof SyntaxError || err instanceof Error && err.message.includes("Invalid credentials")) {
117
+ throw new Error(`Invalid credentials file at ${keysPath}: ${err.message}`);
118
+ }
119
+ }
120
+ }
121
+ throw new Error(`Credentials file not found. Searched: ${paths.join(", ")}`);
122
+ }
105
123
  async function loadCredentialsWithFallback() {
106
124
  try {
107
125
  return await loadCredentialsFromFile();
@@ -2252,12 +2270,17 @@ ${JSON.stringify({ message }, null, 2)}` }],
2252
2270
  // src/tools/docs.ts
2253
2271
  var docs_exports = {};
2254
2272
  __export(docs_exports, {
2273
+ buildFlatTextFromDoc: () => buildFlatTextFromDoc,
2274
+ extractRowCells: () => extractRowCells,
2255
2275
  handleTool: () => handleTool2,
2276
+ matchDocxToDriveComments: () => matchDocxToDriveComments,
2277
+ resolveContextFromDocx: () => resolveContextFromDocx,
2256
2278
  toolDefinitions: () => toolDefinitions2
2257
2279
  });
2258
2280
  import { z as z2 } from "zod";
2259
2281
  import { createReadStream as createReadStream2, existsSync as existsSync3 } from "fs";
2260
2282
  import { basename as basename3, extname as extname3 } from "path";
2283
+ import JSZip from "jszip";
2261
2284
  function hexToRgbColor(hex) {
2262
2285
  if (!hex) return null;
2263
2286
  let hexClean = hex.startsWith("#") ? hex.slice(1) : hex;
@@ -2280,6 +2303,28 @@ function rgbColorToHex(color) {
2280
2303
  const b = Math.round((rgb.blue || 0) * 255);
2281
2304
  return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
2282
2305
  }
2306
+ function collectAllTabsWithLevel(tabs, level = 0) {
2307
+ const result = [];
2308
+ for (const tab of tabs) {
2309
+ result.push({ tab, level });
2310
+ if (tab.childTabs && tab.childTabs.length > 0) {
2311
+ result.push(...collectAllTabsWithLevel(tab.childTabs, level + 1));
2312
+ }
2313
+ }
2314
+ return result;
2315
+ }
2316
+ function findTabById(tabs, targetId) {
2317
+ for (const tab of tabs) {
2318
+ if (tab.tabProperties?.tabId === targetId) {
2319
+ return tab;
2320
+ }
2321
+ if (tab.childTabs && tab.childTabs.length > 0) {
2322
+ const found = findTabById(tab.childTabs, targetId);
2323
+ if (found) return found;
2324
+ }
2325
+ }
2326
+ return null;
2327
+ }
2283
2328
  async function executeBatchUpdate(ctx, documentId, requests) {
2284
2329
  if (!requests || requests.length === 0) {
2285
2330
  return {};
@@ -2589,6 +2634,236 @@ async function uploadImageToDriveHelper(ctx, localFilePath, parentFolderId, make
2589
2634
  }
2590
2635
  return webContentLink;
2591
2636
  }
2637
+ var MAX_ROW_XML_DISTANCE = 1e5;
2638
+ var MAX_PARAGRAPH_XML_DISTANCE = 5e4;
2639
+ var MAX_PARAGRAPH_CONTEXT_LENGTH = 300;
2640
+ function buildFlatTextFromDoc(docData) {
2641
+ function extractSegments(bodyContent) {
2642
+ const segs = [];
2643
+ function fromElements(elements) {
2644
+ for (const el of elements) {
2645
+ if (el.textRun?.content && el.startIndex != null) {
2646
+ segs.push({ text: el.textRun.content, startIndex: el.startIndex });
2647
+ }
2648
+ }
2649
+ }
2650
+ for (const el of bodyContent) {
2651
+ if (el.paragraph?.elements) {
2652
+ fromElements(el.paragraph.elements);
2653
+ } else if (el.table) {
2654
+ for (const row of el.table.tableRows || []) {
2655
+ for (const cell of row.tableCells || []) {
2656
+ for (const cc of cell.content || []) {
2657
+ if (cc.paragraph?.elements) fromElements(cc.paragraph.elements);
2658
+ if (cc.table) {
2659
+ const nested = extractSegments([cc]);
2660
+ segs.push(...nested);
2661
+ }
2662
+ }
2663
+ }
2664
+ }
2665
+ }
2666
+ }
2667
+ return segs;
2668
+ }
2669
+ const allSegments = [];
2670
+ const tabs = docData.tabs;
2671
+ if (tabs && tabs.length > 0) {
2672
+ for (const tab of tabs) {
2673
+ const bc = tab.documentTab?.body?.content;
2674
+ if (bc) allSegments.push(...extractSegments(bc));
2675
+ }
2676
+ } else if (docData.body?.content) {
2677
+ allSegments.push(...extractSegments(docData.body.content));
2678
+ }
2679
+ let flatText = "";
2680
+ const offsetMap = [];
2681
+ for (const seg of allSegments) {
2682
+ for (let i = 0; i < seg.text.length; i++) {
2683
+ offsetMap.push(seg.startIndex + i);
2684
+ flatText += seg.text[i];
2685
+ }
2686
+ }
2687
+ return { flatText, offsetMap };
2688
+ }
2689
+ function extractRowCells(rowXml) {
2690
+ const cells = [];
2691
+ let searchFrom = 0;
2692
+ while (true) {
2693
+ const tcStart1 = rowXml.indexOf("<w:tc>", searchFrom);
2694
+ const tcStart2 = rowXml.indexOf("<w:tc ", searchFrom);
2695
+ const tcStart = tcStart1 === -1 && tcStart2 === -1 ? -1 : tcStart1 === -1 ? tcStart2 : tcStart2 === -1 ? tcStart1 : Math.min(tcStart1, tcStart2);
2696
+ if (tcStart === -1) break;
2697
+ const tcEnd = rowXml.indexOf("</w:tc>", tcStart);
2698
+ if (tcEnd === -1) break;
2699
+ const cellXml = rowXml.substring(tcStart, tcEnd);
2700
+ const tTexts = [];
2701
+ const tRegex = /<w:t[^>]*>([^<]*)<\/w:t>/g;
2702
+ let t;
2703
+ while ((t = tRegex.exec(cellXml)) !== null) tTexts.push(t[1]);
2704
+ if (tTexts.length > 0) cells.push(tTexts.join(""));
2705
+ searchFrom = tcEnd + 7;
2706
+ }
2707
+ return cells;
2708
+ }
2709
+ async function resolveContextFromDocx(docxData) {
2710
+ const zip = await JSZip.loadAsync(docxData);
2711
+ const commentsXml = await zip.file("word/comments.xml")?.async("string");
2712
+ const documentXml = await zip.file("word/document.xml")?.async("string");
2713
+ if (!commentsXml || !documentXml) return null;
2714
+ const docxComments = /* @__PURE__ */ new Map();
2715
+ const commentTagRegex = /<w:comment\s+[^>]*?w:id="(\d+)"[^>]*>/g;
2716
+ let cMatch;
2717
+ while ((cMatch = commentTagRegex.exec(commentsXml)) !== null) {
2718
+ const id = parseInt(cMatch[1]);
2719
+ const tagStr = cMatch[0];
2720
+ const authorMatch = tagStr.match(/w:author="([^"]*)"/);
2721
+ const dateMatch = tagStr.match(/w:date="([^"]*)"/);
2722
+ const author = authorMatch ? authorMatch[1] : "";
2723
+ const date = dateMatch ? dateMatch[1] : "";
2724
+ const endPos = commentsXml.indexOf("</w:comment>", cMatch.index);
2725
+ if (endPos !== -1) {
2726
+ const body = commentsXml.substring(cMatch.index, endPos);
2727
+ const texts = [];
2728
+ const tRegex = /<w:t[^>]*>([^<]*)<\/w:t>/g;
2729
+ let tMatch;
2730
+ while ((tMatch = tRegex.exec(body)) !== null) {
2731
+ texts.push(tMatch[1]);
2732
+ }
2733
+ docxComments.set(id, { author, date, content: texts.join("") });
2734
+ }
2735
+ }
2736
+ const contextsBefore = /* @__PURE__ */ new Map();
2737
+ const contextsAfter = /* @__PURE__ */ new Map();
2738
+ const rowCells = /* @__PURE__ */ new Map();
2739
+ const rangeStartRegex = /<w:commentRangeStart\s+w:id="(\d+)"\/>/g;
2740
+ let rMatch;
2741
+ while ((rMatch = rangeStartRegex.exec(documentXml)) !== null) {
2742
+ const docxId = parseInt(rMatch[1]);
2743
+ const startPos = rMatch.index;
2744
+ const trStart = documentXml.lastIndexOf("<w:tr>", startPos);
2745
+ const trEnd = documentXml.indexOf("</w:tr>", startPos);
2746
+ if (trStart !== -1 && trEnd !== -1 && startPos - trStart < MAX_ROW_XML_DISTANCE) {
2747
+ const rowXml = documentXml.substring(trStart, trEnd);
2748
+ const cellTexts = extractRowCells(rowXml);
2749
+ const commentMarker = `commentRangeStart w:id="${docxId}"`;
2750
+ let commentCellIdx = -1;
2751
+ let cellSearchFrom = 0;
2752
+ for (let ci = 0; ci < cellTexts.length; ci++) {
2753
+ const tcStart1 = rowXml.indexOf("<w:tc>", cellSearchFrom);
2754
+ const tcStart2 = rowXml.indexOf("<w:tc ", cellSearchFrom);
2755
+ const tcStart = tcStart1 === -1 && tcStart2 === -1 ? -1 : tcStart1 === -1 ? tcStart2 : tcStart2 === -1 ? tcStart1 : Math.min(tcStart1, tcStart2);
2756
+ if (tcStart === -1) break;
2757
+ const tcEnd = rowXml.indexOf("</w:tc>", tcStart);
2758
+ if (tcEnd === -1) break;
2759
+ const cellXml = rowXml.substring(tcStart, tcEnd);
2760
+ if (cellXml.includes(commentMarker)) {
2761
+ commentCellIdx = ci;
2762
+ }
2763
+ cellSearchFrom = tcEnd + 7;
2764
+ }
2765
+ if (cellTexts.length > 0) {
2766
+ const allTexts = cellTexts;
2767
+ rowCells.set(docxId, allTexts);
2768
+ if (commentCellIdx !== -1) {
2769
+ const before = cellTexts.slice(0, commentCellIdx);
2770
+ let after = cellTexts.slice(commentCellIdx + 1);
2771
+ if (commentCellIdx === cellTexts.length - 1) {
2772
+ const nextTrStart = documentXml.indexOf("<w:tr>", trEnd);
2773
+ const nextTrEnd = nextTrStart !== -1 ? documentXml.indexOf("</w:tr>", nextTrStart) : -1;
2774
+ if (nextTrStart !== -1 && nextTrEnd !== -1) {
2775
+ const nextRowXml = documentXml.substring(nextTrStart, nextTrEnd);
2776
+ after = extractRowCells(nextRowXml);
2777
+ }
2778
+ }
2779
+ const commentText = cellTexts[commentCellIdx];
2780
+ contextsBefore.set(docxId, [...before, commentText].join(" | "));
2781
+ contextsAfter.set(docxId, [commentText, ...after].join(" | "));
2782
+ } else {
2783
+ contextsBefore.set(docxId, allTexts.join(" | "));
2784
+ contextsAfter.set(docxId, "");
2785
+ }
2786
+ continue;
2787
+ }
2788
+ }
2789
+ const pStart = documentXml.lastIndexOf("<w:p ", startPos);
2790
+ const pEnd = documentXml.indexOf("</w:p>", startPos);
2791
+ if (pStart !== -1 && pEnd !== -1 && startPos - pStart < MAX_PARAGRAPH_XML_DISTANCE) {
2792
+ const pXml = documentXml.substring(pStart, pEnd);
2793
+ const pTexts = [];
2794
+ const tRegex = /<w:t[^>]*>([^<]*)<\/w:t>/g;
2795
+ let t;
2796
+ while ((t = tRegex.exec(pXml)) !== null) pTexts.push(t[1]);
2797
+ const pText = pTexts.join("").trim();
2798
+ if (pText) {
2799
+ contextsBefore.set(docxId, pText.length > MAX_PARAGRAPH_CONTEXT_LENGTH ? pText.substring(0, MAX_PARAGRAPH_CONTEXT_LENGTH) + "..." : pText);
2800
+ contextsAfter.set(docxId, "");
2801
+ }
2802
+ }
2803
+ }
2804
+ return { docxComments, contextsBefore, contextsAfter, rowCells };
2805
+ }
2806
+ function matchDocxToDriveComments(driveComments, docxResult, contextMap, flatText, offsetMap) {
2807
+ const { docxComments, contextsBefore, contextsAfter } = docxResult;
2808
+ for (const comment of driveComments) {
2809
+ if (contextMap.has(comment.id)) continue;
2810
+ if (comment.resolved) continue;
2811
+ const apiAuthor = comment.author?.displayName || "";
2812
+ const apiDate = (comment.createdTime || "").replace(/\.\d+Z$/, "Z");
2813
+ let matchedDocxId = null;
2814
+ for (const [docxId, docxComment] of docxComments) {
2815
+ if (docxComment.author === apiAuthor && docxComment.date === apiDate) {
2816
+ matchedDocxId = docxId;
2817
+ break;
2818
+ }
2819
+ }
2820
+ if (matchedDocxId !== null) {
2821
+ const ctxBefore = contextsBefore.get(matchedDocxId) || "";
2822
+ const ctxAfter = contextsAfter.get(matchedDocxId) || "";
2823
+ if (ctxBefore || ctxAfter) {
2824
+ const entry = {
2825
+ contextBefore: ctxBefore,
2826
+ contextAfter: ctxAfter
2827
+ };
2828
+ const quoted = comment.quotedFileContent?.value;
2829
+ if (quoted && flatText && offsetMap.length > 0 && ctxBefore) {
2830
+ const beforePattern = ctxBefore.split(" | ").join("\n");
2831
+ const findAll = (pattern) => {
2832
+ const results = [];
2833
+ let from = 0;
2834
+ while (true) {
2835
+ const idx = flatText.indexOf(pattern, from);
2836
+ if (idx === -1) break;
2837
+ results.push(idx);
2838
+ from = idx + 1;
2839
+ }
2840
+ return results;
2841
+ };
2842
+ let matches = findAll(beforePattern);
2843
+ if (matches.length !== 1 && ctxAfter) {
2844
+ const afterCells = ctxAfter.split(" | ");
2845
+ const afterWithoutAnchor = afterCells.slice(1).join("\n");
2846
+ if (afterWithoutAnchor) {
2847
+ const fullPattern = beforePattern + "\n" + afterWithoutAnchor;
2848
+ matches = findAll(fullPattern);
2849
+ }
2850
+ }
2851
+ if (matches.length === 1) {
2852
+ const patternStart = matches[0];
2853
+ const qIdx = patternStart + beforePattern.length - quoted.length;
2854
+ const endIdx = qIdx + quoted.length - 1;
2855
+ if (endIdx < offsetMap.length && flatText.substring(qIdx, qIdx + quoted.length) === quoted) {
2856
+ entry.startIndex = offsetMap[qIdx];
2857
+ entry.endIndex = offsetMap[endIdx] + 1;
2858
+ }
2859
+ }
2860
+ }
2861
+ contextMap.set(comment.id, entry);
2862
+ }
2863
+ docxComments.delete(matchedDocxId);
2864
+ }
2865
+ }
2866
+ }
2592
2867
  var CreateGoogleDocSchema = z2.object({
2593
2868
  name: z2.string().min(1, "Document name is required"),
2594
2869
  content: z2.string(),
@@ -3418,12 +3693,17 @@ Link: ${doc.webViewLink}` }],
3418
3693
  const tabs = document.data.tabs;
3419
3694
  let formattedContent = "Document content with indices:\n\n";
3420
3695
  let totalLength = 0;
3421
- if (tabs && tabs.length > 1) {
3422
- for (const tab of tabs) {
3423
- const title = tab.tabProperties?.title || "Untitled";
3696
+ if (tabs && tabs.length > 0) {
3697
+ const allTabs = collectAllTabsWithLevel(tabs);
3698
+ const isMultiTab = allTabs.length > 1;
3699
+ for (const { tab, level } of allTabs) {
3424
3700
  const bodyContent = tab.documentTab?.body?.content;
3425
- formattedContent += `=== Tab: ${title} ===
3701
+ if (isMultiTab) {
3702
+ const title = tab.tabProperties?.title || "Untitled";
3703
+ const indent = " ".repeat(level);
3704
+ formattedContent += `${indent}=== Tab: ${title} ===
3426
3705
  `;
3706
+ }
3427
3707
  if (bodyContent) {
3428
3708
  const segments = extractSegments2(bodyContent);
3429
3709
  trackFonts2(segments);
@@ -3432,15 +3712,9 @@ Link: ${doc.webViewLink}` }],
3432
3712
  totalLength += segments[segments.length - 1].endIndex;
3433
3713
  }
3434
3714
  }
3435
- formattedContent += "\n";
3436
- }
3437
- } else if (tabs && tabs.length === 1) {
3438
- const bodyContent = tabs[0].documentTab?.body?.content;
3439
- if (bodyContent) {
3440
- const segments = extractSegments2(bodyContent);
3441
- trackFonts2(segments);
3442
- formattedContent += formatSegments2(segments);
3443
- totalLength = segments.length > 0 ? segments[segments.length - 1].endIndex : 0;
3715
+ if (isMultiTab) {
3716
+ formattedContent += "\n";
3717
+ }
3444
3718
  }
3445
3719
  } else {
3446
3720
  const bodyContent = document.data.body?.content;
@@ -3581,7 +3855,7 @@ Total length: ${totalLength} characters`
3581
3855
  const tabs = doc.tabs;
3582
3856
  if (tabs && tabs.length > 0) {
3583
3857
  if (a.tabId) {
3584
- const tab = tabs.find((t) => t.tabProperties?.tabId === a.tabId);
3858
+ const tab = findTabById(tabs, a.tabId);
3585
3859
  if (!tab) {
3586
3860
  return errorResponse(`Tab with ID "${a.tabId}" not found. Use listDocumentTabs to see available tabs.`);
3587
3861
  }
@@ -3589,21 +3863,23 @@ Total length: ${totalLength} characters`
3589
3863
  if (bodyContent) {
3590
3864
  text = extractText2(bodyContent);
3591
3865
  }
3592
- } else if (tabs.length > 1) {
3593
- for (const tab of tabs) {
3594
- const title = tab.tabProperties?.title || "Untitled";
3595
- text += `=== Tab: ${title} ===
3596
- `;
3866
+ } else {
3867
+ const allTabs = collectAllTabsWithLevel(tabs);
3868
+ const isMultiTab = allTabs.length > 1;
3869
+ for (const { tab, level } of allTabs) {
3597
3870
  const bodyContent = tab.documentTab?.body?.content;
3871
+ if (isMultiTab) {
3872
+ const title = tab.tabProperties?.title || "Untitled";
3873
+ const indent = " ".repeat(level);
3874
+ text += `${indent}=== Tab: ${title} ===
3875
+ `;
3876
+ }
3598
3877
  if (bodyContent) {
3599
3878
  text += extractText2(bodyContent);
3600
3879
  }
3601
- text += "\n";
3602
- }
3603
- } else {
3604
- const bodyContent = tabs[0].documentTab?.body?.content;
3605
- if (bodyContent) {
3606
- text = extractText2(bodyContent);
3880
+ if (isMultiTab) {
3881
+ text += "\n";
3882
+ }
3607
3883
  }
3608
3884
  }
3609
3885
  } else {
@@ -3933,7 +4209,7 @@ ${text}`;
3933
4209
  const a = validation.data;
3934
4210
  const response = await ctx.getDrive().comments.list({
3935
4211
  fileId: a.documentId,
3936
- fields: "comments(id,content,quotedFileContent,author,createdTime,resolved,replies),nextPageToken",
4212
+ fields: "comments(id,content,quotedFileContent,author,createdTime,resolved,replies(id,content,author,createdTime)),nextPageToken",
3937
4213
  pageSize: a.pageSize || 100,
3938
4214
  pageToken: a.pageToken,
3939
4215
  includeDeleted: a.includeDeleted || false
@@ -3946,18 +4222,124 @@ ${text}`;
3946
4222
  isError: false
3947
4223
  };
3948
4224
  }
4225
+ const contextMap = /* @__PURE__ */ new Map();
4226
+ let flatText = "";
4227
+ let offsetMap = [];
4228
+ let needsDocxFallback = false;
4229
+ let isGoogleDoc = false;
4230
+ try {
4231
+ const fileInfo = await ctx.getDrive().files.get({
4232
+ fileId: a.documentId,
4233
+ fields: "mimeType",
4234
+ supportsAllDrives: true
4235
+ });
4236
+ isGoogleDoc = fileInfo.data.mimeType === "application/vnd.google-apps.document";
4237
+ } catch (err) {
4238
+ ctx.log("Failed to check file MIME type:", err);
4239
+ }
4240
+ if (isGoogleDoc) {
4241
+ try {
4242
+ let getContext2 = function(matchStart, matchLen) {
4243
+ const matchText = flatText.substring(matchStart, matchStart + matchLen);
4244
+ const beforeStart = Math.max(0, matchStart - 120);
4245
+ let before = flatText.substring(beforeStart, matchStart).trim();
4246
+ if (beforeStart > 0) before = "..." + before;
4247
+ before = before + matchText;
4248
+ const afterEnd = Math.min(flatText.length, matchStart + matchLen + 120);
4249
+ let after = flatText.substring(matchStart + matchLen, afterEnd).trim();
4250
+ if (afterEnd < flatText.length) after = after + "...";
4251
+ after = matchText + after;
4252
+ return { before, after };
4253
+ };
4254
+ var getContext = getContext2;
4255
+ const docs = ctx.google.docs({ version: "v1", auth: ctx.authClient });
4256
+ const docResponse = await docs.documents.get({
4257
+ documentId: a.documentId,
4258
+ includeTabsContent: true
4259
+ });
4260
+ const result = buildFlatTextFromDoc(docResponse.data);
4261
+ flatText = result.flatText;
4262
+ offsetMap = result.offsetMap;
4263
+ const ambiguousComments = [];
4264
+ for (const comment of comments) {
4265
+ const quoted = comment.quotedFileContent?.value;
4266
+ if (!quoted) continue;
4267
+ const positions = [];
4268
+ let searchFrom = 0;
4269
+ while (true) {
4270
+ const idx = flatText.indexOf(quoted, searchFrom);
4271
+ if (idx === -1) break;
4272
+ positions.push(idx);
4273
+ searchFrom = idx + 1;
4274
+ }
4275
+ if (positions.length === 1) {
4276
+ const surrounding = getContext2(positions[0], quoted.length);
4277
+ const entry = {
4278
+ contextBefore: surrounding.before,
4279
+ contextAfter: surrounding.after
4280
+ };
4281
+ const endIdx = positions[0] + quoted.length - 1;
4282
+ if (endIdx < offsetMap.length) {
4283
+ entry.startIndex = offsetMap[positions[0]];
4284
+ entry.endIndex = offsetMap[endIdx] + 1;
4285
+ }
4286
+ if (comment.id) contextMap.set(comment.id, entry);
4287
+ } else if (positions.length > 1) {
4288
+ ambiguousComments.push(comment);
4289
+ }
4290
+ }
4291
+ needsDocxFallback = ambiguousComments.length > 0;
4292
+ } catch (err) {
4293
+ ctx.log("Tier 1 context extraction failed:", err);
4294
+ needsDocxFallback = true;
4295
+ }
4296
+ }
4297
+ if (needsDocxFallback) {
4298
+ const unresolved = comments.filter((c) => !contextMap.has(c.id) && !c.resolved);
4299
+ if (unresolved.length > 0) {
4300
+ try {
4301
+ const docxResponse = await ctx.getDrive().files.export({
4302
+ fileId: a.documentId,
4303
+ mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
4304
+ }, { responseType: "arraybuffer" });
4305
+ const docxResult = await resolveContextFromDocx(docxResponse.data);
4306
+ if (docxResult) {
4307
+ matchDocxToDriveComments(comments, docxResult, contextMap, flatText, offsetMap);
4308
+ }
4309
+ } catch (err) {
4310
+ ctx.log("Tier 2 DOCX context extraction failed:", err);
4311
+ }
4312
+ }
4313
+ }
3949
4314
  const formattedComments = comments.map((comment, index) => {
3950
- const replies = comment.replies?.length || 0;
3951
4315
  const status = comment.resolved ? " [RESOLVED]" : "";
3952
4316
  const author = comment.author?.displayName || "Unknown";
3953
4317
  const date = comment.createdTime ? new Date(comment.createdTime).toLocaleDateString() : "Unknown date";
3954
- const quotedText = comment.quotedFileContent?.value || "No quoted text";
3955
- const anchor = quotedText !== "No quoted text" ? ` (anchored to: "${quotedText.substring(0, 100)}${quotedText.length > 100 ? "..." : ""}")` : "";
3956
- let result = `${index + 1}. ${author} (${date})${status}${anchor}
3957
- ${comment.content}`;
3958
- if (replies > 0) {
3959
- result += `
3960
- \u2514\u2500 ${replies} ${replies === 1 ? "reply" : "replies"}`;
4318
+ const quotedText = comment.quotedFileContent?.value;
4319
+ const commentCtx = contextMap.get(comment.id);
4320
+ let positionInfo = "";
4321
+ const indexStr = commentCtx?.startIndex != null ? ` [chars ${commentCtx.startIndex}-${commentCtx.endIndex}]` : "";
4322
+ if (quotedText) {
4323
+ const snippet = quotedText.length > 100 ? quotedText.substring(0, 100) + "..." : quotedText;
4324
+ positionInfo = `
4325
+ Anchored to: "${snippet}"${indexStr}`;
4326
+ }
4327
+ if (commentCtx) {
4328
+ if (commentCtx.contextBefore) positionInfo += `
4329
+ Context before: "${commentCtx.contextBefore}"`;
4330
+ if (commentCtx.contextAfter) positionInfo += `
4331
+ Context after: "${commentCtx.contextAfter}"`;
4332
+ }
4333
+ let result = `${index + 1}. ${author} (${date})${status}${positionInfo}
4334
+ Comment: ${comment.content}`;
4335
+ if (comment.replies && comment.replies.length > 0) {
4336
+ for (const reply of comment.replies) {
4337
+ const replyAuthor = reply.author?.displayName || "Unknown";
4338
+ const replyDate = reply.createdTime ? new Date(reply.createdTime).toLocaleDateString() : "Unknown date";
4339
+ const replyContent = reply.content || "(empty)";
4340
+ result += `
4341
+ \u2514\u2500 ${replyAuthor} (${replyDate}): ${replyContent}`;
4342
+ }
3961
4343
  }
3962
4344
  result += `
3963
4345
  Comment ID: ${comment.id}`;