google-tools-mcp 1.0.17 → 1.1.1

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.
@@ -6,21 +6,103 @@ import * as fs from 'fs/promises';
6
6
  import * as path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import * as os from 'os';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
9
11
  import { getTokenPath, getConfigDir, SCOPES } from '../auth.js';
10
12
  import { resetClients, withAuthRetry, getAuthClientIfReady } from '../clients.js';
11
13
  import { logger } from '../logger.js';
12
14
  import { google } from 'googleapis';
13
15
 
16
+ const execAsync = promisify(exec);
17
+
18
+ const REPO = 'karthikcsq/google-tools-mcp';
19
+
20
+ async function tryGhCli(title, body, label) {
21
+ // Probe for gh CLI
22
+ try {
23
+ await execAsync('gh --version');
24
+ } catch {
25
+ return { ok: false, reason: 'gh CLI not installed' };
26
+ }
27
+ // Probe for auth
28
+ try {
29
+ await execAsync('gh auth status');
30
+ } catch {
31
+ return { ok: false, reason: 'gh CLI not authenticated (run: gh auth login)' };
32
+ }
33
+ // Write body to a temp file to avoid shell-escaping issues with newlines/quotes.
34
+ const tmpFile = path.join(os.tmpdir(), `gtm-feedback-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
35
+ try {
36
+ await fs.writeFile(tmpFile, body, 'utf8');
37
+ const { stdout } = await execAsync(
38
+ `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --label ${JSON.stringify(label)} --body-file ${JSON.stringify(tmpFile)}`,
39
+ { maxBuffer: 10 * 1024 * 1024 }
40
+ );
41
+ const issueUrl = stdout.trim().split('\n').pop();
42
+ return { ok: true, issueUrl };
43
+ } catch (err) {
44
+ return { ok: false, reason: `gh CLI failed: ${err.stderr || err.message || err}` };
45
+ } finally {
46
+ try { await fs.unlink(tmpFile); } catch {}
47
+ }
48
+ }
49
+
50
+ function openBrowser(url) {
51
+ const platform = process.platform;
52
+ let cmd;
53
+ if (platform === 'win32') {
54
+ cmd = `start "" "${url}"`;
55
+ } else if (platform === 'darwin') {
56
+ cmd = `open "${url}"`;
57
+ } else {
58
+ cmd = `xdg-open "${url}"`;
59
+ }
60
+ return new Promise((resolve) => {
61
+ exec(cmd, (err) => resolve(!err));
62
+ });
63
+ }
64
+
14
65
  // ---------------------------------------------------------------------------
15
- // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant.
66
+ // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant
67
+ // and appends a troubleshoot/feedback hint to errors.
16
68
  // ---------------------------------------------------------------------------
69
+ const ERROR_HINT =
70
+ '\n\nIf this error is unexpected or unclear, you can:\n' +
71
+ ' • Call the `troubleshoot` tool to run a health check (auth, API connectivity, recent logs).\n' +
72
+ ' • Call the `feedback` tool to file a bug report with diagnostics auto-attached.';
73
+
74
+ // Tools that should NOT have the hint appended (would be circular/noisy).
75
+ const HINT_EXCLUDED_TOOLS = new Set(['troubleshoot', 'feedback', 'help', 'logout']);
76
+
77
+ function appendHintToError(error, toolName) {
78
+ if (HINT_EXCLUDED_TOOLS.has(toolName)) return error;
79
+ if (!error) return error;
80
+ // Avoid double-appending if something else (or a retry) already added it.
81
+ const existingMsg = error.message || '';
82
+ if (existingMsg.includes('`troubleshoot` tool')) return error;
83
+ try {
84
+ error.message = existingMsg + ERROR_HINT;
85
+ } catch {
86
+ // Some error types have non-writable message; fall back to a new error.
87
+ const wrapped = new Error(existingMsg + ERROR_HINT);
88
+ wrapped.cause = error;
89
+ return wrapped;
90
+ }
91
+ return error;
92
+ }
93
+
17
94
  function wrapServerWithAuthRetry(server) {
18
95
  const originalAddTool = server.addTool.bind(server);
19
96
  server.addTool = function (toolDef) {
20
97
  const originalExecute = toolDef.execute;
98
+ const toolName = toolDef.name;
21
99
  if (originalExecute) {
22
- toolDef.execute = function (...args) {
23
- return withAuthRetry(() => originalExecute.apply(this, args));
100
+ toolDef.execute = async function (...args) {
101
+ try {
102
+ return await withAuthRetry(() => originalExecute.apply(this, args));
103
+ } catch (err) {
104
+ throw appendHintToError(err, toolName);
105
+ }
24
106
  };
25
107
  }
26
108
  return originalAddTool(toolDef);
@@ -109,15 +191,22 @@ export async function registerAllTools(server) {
109
191
  server.addTool({
110
192
  name: 'help',
111
193
  description:
112
- 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. Call this when you need guidance on how to use the Google Workspace tools.',
194
+ 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. ' +
195
+ 'Call this when you need guidance on how to use the Google Workspace tools. ' +
196
+ 'Also available: `troubleshoot` (run health check when tools fail) and `feedback` (submit bug reports/feature requests with diagnostics auto-attached).',
113
197
  parameters: z.object({}),
114
198
  execute: async () => {
115
199
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
116
200
  const readmePath = path.resolve(__dirname, '..', '..', 'README.md');
201
+ const diagnosticsSection = '\n\n## Diagnostics & Feedback\n\n' +
202
+ '- **`troubleshoot`** — Run a health check when tools fail (checks auth, API connectivity, config, recent logs).\n' +
203
+ '- **`feedback`** — Submit a bug report or feature request with diagnostics auto-attached (files via GitHub CLI or browser).\n' +
204
+ '- **`help`** — Show this documentation.\n';
117
205
  try {
118
- return await fs.readFile(readmePath, 'utf-8');
206
+ const readme = await fs.readFile(readmePath, 'utf-8');
207
+ return readme + diagnosticsSection;
119
208
  } catch {
120
- return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.';
209
+ return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.' + diagnosticsSection;
121
210
  }
122
211
  },
123
212
  });
@@ -275,7 +364,7 @@ export async function registerAllTools(server) {
275
364
  server.addTool({
276
365
  name: 'feedback',
277
366
  description:
278
- 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info and generates a pre-filled GitHub issue URL.',
367
+ 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info, then files the issue via the GitHub CLI (`gh`) if available, or falls back to opening a pre-filled GitHub issue URL in the user\'s browser.',
279
368
  parameters: z.object({
280
369
  type: z.enum(['bug', 'feature']).describe('Type of feedback'),
281
370
  title: z.string().describe('Short summary'),
@@ -334,21 +423,38 @@ export async function registerAllTools(server) {
334
423
  `</details>`,
335
424
  ].join('\n');
336
425
 
337
- // Build GitHub issue URL
338
426
  const label = args.type === 'bug' ? 'bug' : 'enhancement';
427
+
428
+ // Try gh CLI first
429
+ const ghResult = await tryGhCli(args.title, body, label);
430
+ if (ghResult.ok) {
431
+ return JSON.stringify({
432
+ method: 'gh-cli',
433
+ issueUrl: ghResult.issueUrl,
434
+ note: 'Issue filed successfully via GitHub CLI.',
435
+ }, null, 2);
436
+ }
437
+
438
+ // Fallback: open pre-filled GitHub issue URL in the user's browser
339
439
  const params = new URLSearchParams({
340
440
  title: args.title,
341
441
  body,
342
442
  labels: label,
343
443
  });
344
- const url = `https://github.com/karthikcsq/google-tools-mcp/issues/new?${params.toString()}`;
444
+ const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
445
+ const opened = await openBrowser(url);
345
446
 
346
447
  return JSON.stringify({
448
+ method: 'browser-fallback',
449
+ ghCliUnavailableReason: ghResult.reason,
347
450
  url,
451
+ browserOpened: opened,
348
452
  markdown: body,
349
453
  note: url.length > 8000
350
- ? 'The URL may be too long for some browsers. Use the markdown body below to create the issue manually.'
351
- : 'Open the URL to create a pre-filled GitHub issue.',
454
+ ? 'The URL may be too long for some browsers. Use the markdown body to create the issue manually.'
455
+ : opened
456
+ ? 'Opened the pre-filled GitHub issue in your browser. Click "Submit new issue" to file it.'
457
+ : 'Could not auto-open browser. Please open the URL manually.',
352
458
  }, null, 2);
353
459
  },
354
460
  });
@@ -1,6 +1,7 @@
1
1
  import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
+ import { guardMutation, trackMutation } from '../../readTracker.js';
4
5
  export function register(server) {
5
6
  server.addTool({
6
7
  name: 'batchWrite',
@@ -25,6 +26,7 @@ export function register(server) {
25
26
  .describe('How input data should be interpreted. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed by a user.'),
26
27
  }),
27
28
  execute: async (args, { log }) => {
29
+ await guardMutation(args.spreadsheetId);
28
30
  const sheets = await getSheetsClient();
29
31
  const rangeNames = args.data.map((d) => d.range).join(', ');
30
32
  log.info(`Batch writing to ${args.data.length} range(s) in spreadsheet ${args.spreadsheetId}: ${rangeNames}`);
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { guardMutation, trackMutation } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'clearRange',
@@ -13,6 +14,7 @@ export function register(server) {
13
14
  range: z.string().describe('A1 notation range to clear (e.g., "A1:B10" or "Sheet1!A1:B10").'),
14
15
  }),
15
16
  execute: async (args, { log }) => {
17
+ await guardMutation(args.spreadsheetId);
16
18
  const sheets = await getSheetsClient();
17
19
  log.info(`Clearing range ${args.range} in spreadsheet ${args.spreadsheetId}`);
18
20
  try {
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { trackRead } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'getSpreadsheetInfo',
@@ -16,6 +17,7 @@ export function register(server) {
16
17
  log.info(`Getting info for spreadsheet: ${args.spreadsheetId}`);
17
18
  try {
18
19
  const metadata = await SheetsHelpers.getSpreadsheetMetadata(sheets, args.spreadsheetId);
20
+ trackRead(args.spreadsheetId);
19
21
  const sheetList = metadata.sheets || [];
20
22
  return JSON.stringify({
21
23
  title: metadata.properties?.title || 'Untitled',
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { trackRead } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'readSpreadsheet',
@@ -22,6 +23,7 @@ export function register(server) {
22
23
  log.info(`Reading spreadsheet ${args.spreadsheetId}, range: ${args.range}`);
23
24
  try {
24
25
  const response = await SheetsHelpers.readRange(sheets, args.spreadsheetId, args.range, args.valueRenderOption);
26
+ trackRead(args.spreadsheetId);
25
27
  const values = response.values || [];
26
28
  return JSON.stringify({ range: args.range, values }, null, 2);
27
29
  }
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { guardMutation, trackMutation } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'writeSpreadsheet',
@@ -23,6 +24,7 @@ export function register(server) {
23
24
  .describe('How input data should be interpreted. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed by a user.'),
24
25
  }),
25
26
  execute: async (args, { log }) => {
27
+ await guardMutation(args.spreadsheetId);
26
28
  const sheets = await getSheetsClient();
27
29
  log.info(`Writing to spreadsheet ${args.spreadsheetId}, range: ${args.range}`);
28
30
  try {
@@ -1,15 +1,21 @@
1
+ import * as fs from 'fs/promises';
1
2
  import { UserError } from 'fastmcp';
2
3
  import { z } from 'zod';
3
4
  import { getDocsClient } from '../../clients.js';
4
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
5
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
7
  import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
8
+ import { guardMutation, trackMutation } from '../../readTracker.js';
7
9
  export function register(server) {
8
10
  server.addTool({
9
11
  name: 'appendMarkdown',
10
- description: 'Appends formatted content to the end of a document using markdown syntax. Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. Use this instead of appendText when you need formatting.',
12
+ description: 'Best for adding new formatted content to the end of a document. ' +
13
+ 'Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. ' +
14
+ 'Use this instead of appendText when you need formatting. ' +
15
+ 'To edit existing content, use modifyText (single-location) or replaceDocumentWithMarkdown (section/full rewrite).',
11
16
  parameters: DocumentIdParameter.extend({
12
- markdown: z.string().min(1).describe('The markdown content to append.'),
17
+ markdown: z.string().optional().describe('The markdown content to append. For content longer than ~2000 characters, prefer writing to a local file first and passing filePath instead.'),
18
+ filePath: z.string().optional().describe('Path to a local markdown file to use as content. Takes precedence over the markdown parameter.'),
13
19
  addNewlineIfNeeded: z
14
20
  .boolean()
15
21
  .optional()
@@ -26,8 +32,22 @@ export function register(server) {
26
32
  .describe('If true, the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title.'),
27
33
  }),
28
34
  execute: async (args, { log }) => {
35
+ await guardMutation(args.documentId);
29
36
  const docs = await getDocsClient();
30
- log.info(`Appending markdown to doc ${args.documentId} (${args.markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
37
+ // Resolve markdown content from filePath or inline parameter
38
+ let markdown = args.markdown;
39
+ if (args.filePath) {
40
+ try {
41
+ markdown = await fs.readFile(args.filePath, 'utf-8');
42
+ log.info(`Read ${markdown.length} chars from file: ${args.filePath}`);
43
+ } catch (err) {
44
+ throw new UserError(`Failed to read file at "${args.filePath}": ${err.message}`);
45
+ }
46
+ }
47
+ if (!markdown || markdown.length === 0) {
48
+ throw new UserError('Either markdown or filePath must be provided with non-empty content.');
49
+ }
50
+ log.info(`Appending markdown to doc ${args.documentId} (${markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
31
51
  try {
32
52
  // 1. Get document end index
33
53
  const doc = await docs.documents.get({
@@ -72,7 +92,7 @@ export function register(server) {
72
92
  log.info(`Added spacing, new start index: ${startIndex}`);
73
93
  }
74
94
  // 3. Convert and append markdown
75
- const result = await insertMarkdown(docs, args.documentId, args.markdown, {
95
+ const result = await insertMarkdown(docs, args.documentId, markdown, {
76
96
  startIndex,
77
97
  tabId: args.tabId,
78
98
  firstHeadingAsTitle: args.firstHeadingAsTitle,
@@ -80,7 +100,8 @@ export function register(server) {
80
100
  const debugSummary = formatInsertResult(result);
81
101
  log.info(debugSummary);
82
102
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
83
- return `${docUrl}\nSuccessfully appended ${args.markdown.length} characters of markdown.\n\n${debugSummary}`;
103
+ trackMutation(args.documentId);
104
+ return `${docUrl}\nSuccessfully appended ${markdown.length} characters of markdown.\n\n${debugSummary}`;
84
105
  }
85
106
  catch (error) {
86
107
  log.error(`Error appending markdown: ${error.message}`);
@@ -1,15 +1,22 @@
1
+ import * as fs from 'fs/promises';
1
2
  import { UserError } from 'fastmcp';
2
3
  import { z } from 'zod';
3
4
  import { getDocsClient } from '../../clients.js';
4
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
5
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
7
  import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
8
+ import { guardMutation, trackMutation } from '../../readTracker.js';
7
9
  export function register(server) {
8
10
  server.addTool({
9
11
  name: 'replaceDocumentWithMarkdown',
10
- description: "Replaces the entire document body with content parsed from markdown. Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes.",
12
+ description: "Best for rewriting entire sections or full documents. Replaces the entire document body with content parsed from markdown. " +
13
+ "Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. " +
14
+ "Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes. " +
15
+ "For small single-location edits (one line or paragraph), use modifyText instead. " +
16
+ "To add content without rewriting, use appendMarkdown.",
11
17
  parameters: DocumentIdParameter.extend({
12
- markdown: z.string().min(1).describe('The markdown content to apply to the document.'),
18
+ markdown: z.string().optional().describe('The markdown content to apply to the document. For content longer than ~2000 characters, prefer writing to a local file first and passing filePath instead.'),
19
+ filePath: z.string().optional().describe('Path to a local markdown file to use as content. Takes precedence over the markdown parameter. Use this for large documents to avoid truncation.'),
13
20
  preserveTitle: z
14
21
  .boolean()
15
22
  .optional()
@@ -26,8 +33,22 @@ export function register(server) {
26
33
  .describe('If true (default), the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title. Set to false if the first H1 should remain a Heading 1.'),
27
34
  }),
28
35
  execute: async (args, { log }) => {
36
+ await guardMutation(args.documentId);
29
37
  const docs = await getDocsClient();
30
- log.info(`Replacing doc ${args.documentId} with markdown (${args.markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
38
+ // Resolve markdown content from filePath or inline parameter
39
+ let markdown = args.markdown;
40
+ if (args.filePath) {
41
+ try {
42
+ markdown = await fs.readFile(args.filePath, 'utf-8');
43
+ log.info(`Read ${markdown.length} chars from file: ${args.filePath}`);
44
+ } catch (err) {
45
+ throw new UserError(`Failed to read file at "${args.filePath}": ${err.message}`);
46
+ }
47
+ }
48
+ if (!markdown || markdown.length === 0) {
49
+ throw new UserError('Either markdown or filePath must be provided with non-empty content.');
50
+ }
51
+ log.info(`Replacing doc ${args.documentId} with markdown (${markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
31
52
  try {
32
53
  // 1. Get document structure
33
54
  const doc = await docs.documents.get({
@@ -133,15 +154,16 @@ export function register(server) {
133
154
  }
134
155
  // 5. Convert markdown and insert (indices calculated for empty document)
135
156
  log.info(`Inserting markdown starting at index ${startIndex} (after delete, document should be empty)`);
136
- const result = await insertMarkdown(docs, args.documentId, args.markdown, {
157
+ const result = await insertMarkdown(docs, args.documentId, markdown, {
137
158
  startIndex,
138
159
  tabId: args.tabId,
139
160
  firstHeadingAsTitle: args.firstHeadingAsTitle,
140
161
  });
141
162
  const debugSummary = formatInsertResult(result);
142
163
  log.info(debugSummary);
164
+ trackMutation(args.documentId);
143
165
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
144
- return `${docUrl}\nSuccessfully replaced document content with ${args.markdown.length} characters of markdown.\n\n${debugSummary}`;
166
+ return `${docUrl}\nSuccessfully replaced document content with ${markdown.length} characters of markdown.\n\n${debugSummary}`;
145
167
  }
146
168
  catch (error) {
147
169
  log.error(`Error replacing document with markdown: ${error.message}`);
package/dist/types.js CHANGED
@@ -66,8 +66,7 @@ export const TextFindParameter = z.object({
66
66
  .int()
67
67
  .min(1)
68
68
  .optional()
69
- .default(1)
70
- .describe('Which instance of the text to target (1st, 2nd, etc.). Defaults to 1.'),
69
+ .describe('Which instance of the text to target (1st, 2nd, etc.). Required when multiple matches exist — omit to auto-select if only one match.'),
71
70
  });
72
71
  // --- Style Parameter Schemas ---
73
72
  export const TextStyleParameters = z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.0.17",
3
+ "version": "1.1.1",
4
4
  "description": "Combined Google Workspace MCP server (Drive, Docs, Sheets, Gmail) with lazy-loaded tool categories",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,14 +17,22 @@
17
17
  "sheets",
18
18
  "docs"
19
19
  ],
20
+ "scripts": {
21
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
22
+ "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage"
23
+ },
20
24
  "license": "MIT",
21
25
  "dependencies": {
22
26
  "fastmcp": "^3.24.0",
23
27
  "google-auth-library": "^10.5.0",
24
28
  "googleapis": "^171.4.0",
25
- "markdown-it": "^14.1.0",
26
29
  "mammoth": "^1.9.0",
30
+ "markdown-it": "^14.1.0",
27
31
  "pdf-parse": "^1.1.1",
28
32
  "zod": "^3.24.2"
33
+ },
34
+ "devDependencies": {
35
+ "@jest/globals": "^30.3.0",
36
+ "jest": "^30.3.0"
29
37
  }
30
38
  }