gogcli-mcp 2.0.11 → 2.2.0

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/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.0.11",
10
+ "version": "2.2.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.0.11",
15
+ "version": "2.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -39,7 +39,7 @@ export function registerCalendarTools(server: McpServer): void {
39
39
  });
40
40
 
41
41
  server.registerTool('gog_calendar_create', {
42
- description: 'Create a calendar event.',
42
+ description: 'Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description — Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients).',
43
43
  annotations: { destructiveHint: false },
44
44
  inputSchema: {
45
45
  calendarId: z.string().describe('Calendar ID (use "primary" for the default calendar)'),
@@ -50,19 +50,21 @@ export function registerCalendarTools(server: McpServer): void {
50
50
  location: z.string().optional().describe('Event location'),
51
51
  attendees: z.string().optional().describe('Attendee emails, comma-separated'),
52
52
  allDay: z.boolean().optional().describe('All-day event (use date-only in from/to)'),
53
+ withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)'),
53
54
  account: accountParam,
54
55
  },
55
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, account }) => {
56
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, withZoom, account }) => {
56
57
  const args = ['calendar', 'create', calendarId, `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
57
58
  if (description) args.push(`--description=${description}`);
58
59
  if (location) args.push(`--location=${location}`);
59
60
  if (attendees) args.push(`--attendees=${attendees}`);
60
61
  if (allDay) args.push('--all-day');
62
+ if (withZoom) args.push('--with-zoom');
61
63
  return runOrDiagnose(args, { account });
62
64
  });
63
65
 
64
66
  server.registerTool('gog_calendar_update', {
65
- description: 'Update an existing calendar event.',
67
+ description: 'Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it (each are independent — use one per call).',
66
68
  annotations: { destructiveHint: false },
67
69
  inputSchema: {
68
70
  calendarId: z.string().describe('Calendar ID'),
@@ -73,9 +75,12 @@ export function registerCalendarTools(server: McpServer): void {
73
75
  description: z.string().optional().describe('New description'),
74
76
  location: z.string().optional().describe('New location'),
75
77
  attendees: z.string().optional().describe('New attendee emails, comma-separated (replaces existing)'),
78
+ withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event'),
79
+ regenerateZoom: z.boolean().optional().describe('Replace the event\'s existing Zoom video conference'),
80
+ removeZoom: z.boolean().optional().describe('Remove the event\'s Zoom video conference'),
76
81
  account: accountParam,
77
82
  },
78
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, account }) => {
83
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, withZoom, regenerateZoom, removeZoom, account }) => {
79
84
  const args = ['calendar', 'update', calendarId, eventId];
80
85
  if (summary !== undefined) args.push(`--summary=${summary}`);
81
86
  if (from !== undefined) args.push(`--from=${from}`);
@@ -83,6 +88,9 @@ export function registerCalendarTools(server: McpServer): void {
83
88
  if (description !== undefined) args.push(`--description=${description}`);
84
89
  if (location !== undefined) args.push(`--location=${location}`);
85
90
  if (attendees !== undefined) args.push(`--attendees=${attendees}`);
91
+ if (withZoom) args.push('--with-zoom');
92
+ if (regenerateZoom) args.push('--regenerate-zoom');
93
+ if (removeZoom) args.push('--remove-zoom');
86
94
  return runOrDiagnose(args, { account });
87
95
  });
88
96
 
@@ -4,7 +4,7 @@ import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
5
5
  export function registerGmailTools(server: McpServer): void {
6
6
  server.registerTool('gog_gmail_search', {
7
- description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread").',
7
+ description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).',
8
8
  annotations: { readOnlyHint: true },
9
9
  inputSchema: {
10
10
  query: z.string().describe('Gmail search query'),
@@ -0,0 +1,64 @@
1
+ // A1-notation helpers for the fail_if_not_empty write guard. Kept separate
2
+ // from sheets.ts so the pure range/value math is unit-testable in isolation.
3
+
4
+ // 1 -> "A", 26 -> "Z", 27 -> "AA" (bijective base-26).
5
+ function colToLetter(n: number): string {
6
+ let s = '';
7
+ while (n > 0) {
8
+ const rem = (n - 1) % 26;
9
+ s = String.fromCharCode(65 + rem) + s;
10
+ n = Math.floor((n - 1) / 26);
11
+ }
12
+ return s;
13
+ }
14
+
15
+ // "A" -> 1, "AA" -> 27.
16
+ function letterToCol(s: string): number {
17
+ let n = 0;
18
+ for (const ch of s.toUpperCase()) {
19
+ n = n * 26 + (ch.charCodeAt(0) - 64);
20
+ }
21
+ return n;
22
+ }
23
+
24
+ // Given the target `range` of an update and the shape of the values being
25
+ // written, return the range the guard should read to cover every written cell.
26
+ //
27
+ // A bare anchor cell ("Sheet1!A1") is expanded to the full written area
28
+ // ("Sheet1!A1:D70"). An explicit range (contains ":") or a named range is
29
+ // returned unchanged — the caller asked us to check exactly that span.
30
+ export function expandAnchorRange(range: string, rows: number, cols: number): string {
31
+ const bang = range.lastIndexOf('!');
32
+ const sheet = bang >= 0 ? range.slice(0, bang + 1) : '';
33
+ const cell = bang >= 0 ? range.slice(bang + 1) : range;
34
+ const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
35
+ if (!m) return range;
36
+ const startCol = letterToCol(m[1]);
37
+ const startRow = parseInt(m[2], 10);
38
+ const endCol = colToLetter(startCol + cols - 1);
39
+ const endRow = startRow + rows - 1;
40
+ return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
41
+ }
42
+
43
+ // Count cells holding data in the JSON output of `gog sheets get`
44
+ // ({"values":[[...]]}). Empty strings and whitespace-only cells don't count;
45
+ // numbers (including 0) and other primitives do. Returns -1 when the output
46
+ // can't be parsed, so the caller can fail safe rather than assume "empty".
47
+ export function countNonEmptyCells(getOutput: string): number {
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = JSON.parse(getOutput);
51
+ } catch {
52
+ return -1;
53
+ }
54
+ const values = (parsed as { values?: unknown })?.values;
55
+ if (!Array.isArray(values)) return 0;
56
+ let count = 0;
57
+ for (const row of values) {
58
+ if (!Array.isArray(row)) continue;
59
+ for (const cell of row) {
60
+ if (cell !== null && String(cell).trim() !== '') count++;
61
+ }
62
+ }
63
+ return count;
64
+ }
@@ -1,12 +1,25 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
3
+ import { run } from '../runner.js';
4
+ import { accountParam, runOrDiagnose, registerRunTool, toText, diagnose } from './utils.js';
5
+ import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
4
6
 
5
7
  // Cell value type: matches what gog sheets --values-json accepts (passed
6
8
  // straight to the Sheets API as userEnteredValue). Strings starting with
7
9
  // "=" are treated as formulas by gog's default --input=USER_ENTERED.
8
10
  const cellValueParam = z.union([z.string(), z.number(), z.boolean(), z.null()]);
9
11
 
12
+ // gog sheets update/append/clear all support -n/--dry-run ("Do not make
13
+ // changes; print intended actions and exit successfully"). Exposing it gives
14
+ // agents a no-op preview before committing a write to a live sheet.
15
+ const dryRunParam = z.boolean().optional().describe(
16
+ 'Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing.',
17
+ );
18
+
19
+ const failIfNotEmptyParam = z.boolean().optional().describe(
20
+ 'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.',
21
+ );
22
+
10
23
  export function registerSheetsTools(server: McpServer): void {
11
24
  server.registerTool('gog_sheets_get', {
12
25
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -27,13 +40,37 @@ export function registerSheetsTools(server: McpServer): void {
27
40
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
28
41
  range: z.string().describe('Top-left cell or range in A1 notation, e.g. Sheet1!A1'),
29
42
  values: z.array(z.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
43
+ dry_run: dryRunParam,
44
+ fail_if_not_empty: failIfNotEmptyParam,
30
45
  account: accountParam,
31
46
  },
32
- }, async ({ spreadsheetId, range, values, account }) => {
33
- return runOrDiagnose(
34
- ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
35
- { account },
36
- );
47
+ }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
48
+ const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
49
+ if (fail_if_not_empty && values.length > 0 && cols > 0) {
50
+ const readRange = expandAnchorRange(range, values.length, cols);
51
+ let existing: string;
52
+ try {
53
+ existing = await run(['sheets', 'get', spreadsheetId, readRange], { account });
54
+ } catch (err) {
55
+ // Couldn't read the target — refuse to write rather than risk an
56
+ // overwrite, and diagnose the read failure (auth/transient/etc.) the
57
+ // same way runOrDiagnose would, since this path bypasses it.
58
+ return diagnose(err);
59
+ }
60
+ const occupied = countNonEmptyCells(existing);
61
+ if (occupied !== 0) {
62
+ const detail = occupied < 0
63
+ ? 'could not be verified as empty'
64
+ : `already contains data in ${occupied} cell(s)`;
65
+ return toText(
66
+ `Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. ` +
67
+ 'Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.',
68
+ );
69
+ }
70
+ }
71
+ const args = ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
72
+ if (dry_run) args.push('--dry-run');
73
+ return runOrDiagnose(args, { account });
37
74
  });
38
75
 
39
76
  server.registerTool('gog_sheets_append', {
@@ -43,13 +80,13 @@ export function registerSheetsTools(server: McpServer): void {
43
80
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
44
81
  range: z.string().describe('Range indicating which sheet/columns to append to, e.g. Sheet1!A:C'),
45
82
  values: z.array(z.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
83
+ dry_run: dryRunParam,
46
84
  account: accountParam,
47
85
  },
48
- }, async ({ spreadsheetId, range, values, account }) => {
49
- return runOrDiagnose(
50
- ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
51
- { account },
52
- );
86
+ }, async ({ spreadsheetId, range, values, account, dry_run }) => {
87
+ const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
88
+ if (dry_run) args.push('--dry-run');
89
+ return runOrDiagnose(args, { account });
53
90
  });
54
91
 
55
92
  server.registerTool('gog_sheets_clear', {
@@ -58,14 +95,17 @@ export function registerSheetsTools(server: McpServer): void {
58
95
  inputSchema: {
59
96
  spreadsheetId: z.string().describe('Spreadsheet ID'),
60
97
  range: z.string().describe('Range in A1 notation to clear'),
98
+ dry_run: dryRunParam,
61
99
  account: accountParam,
62
100
  },
63
- }, async ({ spreadsheetId, range, account }) => {
64
- return runOrDiagnose(['sheets', 'clear', spreadsheetId, range], { account });
101
+ }, async ({ spreadsheetId, range, account, dry_run }) => {
102
+ const args = ['sheets', 'clear', spreadsheetId, range];
103
+ if (dry_run) args.push('--dry-run');
104
+ return runOrDiagnose(args, { account });
65
105
  });
66
106
 
67
107
  server.registerTool('gog_sheets_metadata', {
68
- description: 'Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.',
108
+ description: 'Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet\'s current size before writing — a write outside the grid fails.',
69
109
  annotations: { readOnlyHint: true },
70
110
  inputSchema: {
71
111
  spreadsheetId: z.string().describe('Spreadsheet ID'),
@@ -5,7 +5,8 @@ import { run } from '../runner.js';
5
5
  export type ToolResult = { content: [{ type: 'text'; text: string }] };
6
6
 
7
7
  export const accountParam = z.string().optional().describe(
8
- 'Google account email to use (overrides GOG_ACCOUNT env var)',
8
+ 'Google account email to use, e.g. you@gmail.com must be the full address, not a bare username. ' +
9
+ 'Overrides the GOG_ACCOUNT env var. Omit to use the single configured account.',
9
10
  );
10
11
 
11
12
  // Canonical ID descriptors. Use these instead of redefining the same
@@ -100,6 +101,11 @@ const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid
100
101
  const TRANSIENT_ERROR_PATTERN =
101
102
  /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
102
103
 
104
+ // gogcli rejects writes whose range falls outside the sheet's current grid
105
+ // (e.g. writing to column AP on a 41-column sheet) with a "exceeds grid limits"
106
+ // error and no remediation. Point the caller at the tool that grows the grid.
107
+ const GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
108
+
103
109
  const AUTH_HINT =
104
110
  '\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. ' +
105
111
  'Ask the user if they would like to re-authenticate.';
@@ -108,6 +114,57 @@ const TRANSIENT_HINT =
108
114
  '\n\nThis error is often transient. Retry the same call before trying a different approach ' +
109
115
  '(do not fall back to smaller writes or row-by-row operations).';
110
116
 
117
+ const GRID_LIMIT_HINT =
118
+ '\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
119
+ 'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
120
+
121
+ // Reduce `gog auth list --json` output to just the configured email addresses.
122
+ // The raw JSON also carries OAuth scopes, the Google subject id, and creation
123
+ // timestamps — none of which belong in an error surfaced to the model, and
124
+ // which were previously echoed verbatim on every failure. Falls back to the
125
+ // trimmed raw text if the output isn't the expected JSON shape (e.g. a plain
126
+ // email string), so unexpected output still degrades gracefully.
127
+ export function formatAccountList(raw: string): string {
128
+ try {
129
+ const parsed = JSON.parse(raw) as { accounts?: unknown };
130
+ if (Array.isArray(parsed?.accounts)) {
131
+ return parsed.accounts
132
+ .map((a) => (a as { email?: string })?.email)
133
+ .filter(Boolean)
134
+ .join('\n');
135
+ }
136
+ } catch {
137
+ // not JSON — fall through to the raw text
138
+ }
139
+ return raw.trim();
140
+ }
141
+
142
+ // Turn a thrown error into a diagnosed ToolResult: the error text, an
143
+ // actionable hint when the failure class is recognised (auth / transient /
144
+ // off-grid write), and the list of configured accounts. Callers that need to
145
+ // surface a failure without going through runOrDiagnose (e.g. a pre-write
146
+ // verification read that must abort) can reuse this so the error keeps the
147
+ // same diagnostic quality as everywhere else.
148
+ export async function diagnose(err: unknown): Promise<ToolResult> {
149
+ const errText = toError(err).content[0].text;
150
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
151
+ const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
152
+ const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
153
+ const hint = isAuthError
154
+ ? AUTH_HINT
155
+ : isTransientError
156
+ ? TRANSIENT_HINT
157
+ : isGridLimitError
158
+ ? GRID_LIMIT_HINT
159
+ : '';
160
+ try {
161
+ const accounts = formatAccountList(await run(['auth', 'list']));
162
+ return toText(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
163
+ } catch {
164
+ return toText(`${errText}${hint}`);
165
+ }
166
+ }
167
+
111
168
  export async function runOrDiagnose(
112
169
  args: string[],
113
170
  options: { account?: string },
@@ -115,16 +172,6 @@ export async function runOrDiagnose(
115
172
  try {
116
173
  return toText(await run(args, options));
117
174
  } catch (err) {
118
- const base = toError(err);
119
- const errText = base.content[0].text;
120
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
121
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
122
- const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : '';
123
- try {
124
- const accounts = await run(['auth', 'list']);
125
- return toText(`${errText}\n\nConfigured accounts:\n${accounts}${hint}`);
126
- } catch {
127
- return toText(`${errText}${hint}`);
128
- }
175
+ return diagnose(err);
129
176
  }
130
177
  }
@@ -97,6 +97,41 @@ describe('gog_calendar_create', () => {
97
97
  );
98
98
  });
99
99
 
100
+ // gog 0.18.0: --with-zoom attaches a Zoom conference via description-mode
101
+ // integration (native conference card not supported for non-Workspace-Marketplace
102
+ // OAuth clients).
103
+ it('passes --with-zoom when withZoom is true', async () => {
104
+ vi.mocked(runner.run).mockResolvedValue('{}');
105
+ const handlers = setupHandlers();
106
+ await handlers.get('gog_calendar_create')!({
107
+ calendarId: 'primary',
108
+ summary: 'Sync',
109
+ from: '2026-04-14T09:00:00Z',
110
+ to: '2026-04-14T09:30:00Z',
111
+ withZoom: true,
112
+ });
113
+ expect(runner.run).toHaveBeenCalledWith(
114
+ [
115
+ 'calendar', 'create', 'primary',
116
+ '--summary=Sync', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
117
+ '--with-zoom',
118
+ ],
119
+ { account: undefined },
120
+ );
121
+ });
122
+
123
+ it('omits --with-zoom when false', async () => {
124
+ vi.mocked(runner.run).mockResolvedValue('{}');
125
+ const handlers = setupHandlers();
126
+ await handlers.get('gog_calendar_create')!({
127
+ calendarId: 'primary', summary: 's', from: 'f', to: 't', withZoom: false,
128
+ });
129
+ expect(runner.run).toHaveBeenCalledWith(
130
+ ['calendar', 'create', 'primary', '--summary=s', '--from=f', '--to=t'],
131
+ { account: undefined },
132
+ );
133
+ });
134
+
100
135
  it('returns error text on failure', async () => {
101
136
  vi.mocked(runner.run).mockRejectedValue(new Error('Create failed'));
102
137
  const handlers = setupHandlers();
@@ -143,6 +178,52 @@ describe('gog_calendar_update', () => {
143
178
  );
144
179
  });
145
180
 
181
+ // gog 0.18.0 Zoom flags: with-zoom adds, regenerate-zoom replaces, remove-zoom strips.
182
+ it('passes --with-zoom / --regenerate-zoom / --remove-zoom independently', async () => {
183
+ vi.mocked(runner.run).mockResolvedValue('{}');
184
+ const handlers = setupHandlers();
185
+ await handlers.get('gog_calendar_update')!({
186
+ calendarId: 'primary', eventId: 'evt1', withZoom: true,
187
+ });
188
+ expect(runner.run).toHaveBeenCalledWith(
189
+ ['calendar', 'update', 'primary', 'evt1', '--with-zoom'],
190
+ { account: undefined },
191
+ );
192
+
193
+ vi.clearAllMocks();
194
+ vi.mocked(runner.run).mockResolvedValue('{}');
195
+ await handlers.get('gog_calendar_update')!({
196
+ calendarId: 'primary', eventId: 'evt1', regenerateZoom: true,
197
+ });
198
+ expect(runner.run).toHaveBeenCalledWith(
199
+ ['calendar', 'update', 'primary', 'evt1', '--regenerate-zoom'],
200
+ { account: undefined },
201
+ );
202
+
203
+ vi.clearAllMocks();
204
+ vi.mocked(runner.run).mockResolvedValue('{}');
205
+ await handlers.get('gog_calendar_update')!({
206
+ calendarId: 'primary', eventId: 'evt1', removeZoom: true,
207
+ });
208
+ expect(runner.run).toHaveBeenCalledWith(
209
+ ['calendar', 'update', 'primary', 'evt1', '--remove-zoom'],
210
+ { account: undefined },
211
+ );
212
+ });
213
+
214
+ it('omits zoom flags when all false', async () => {
215
+ vi.mocked(runner.run).mockResolvedValue('{}');
216
+ const handlers = setupHandlers();
217
+ await handlers.get('gog_calendar_update')!({
218
+ calendarId: 'primary', eventId: 'evt1',
219
+ withZoom: false, regenerateZoom: false, removeZoom: false,
220
+ });
221
+ expect(runner.run).toHaveBeenCalledWith(
222
+ ['calendar', 'update', 'primary', 'evt1'],
223
+ { account: undefined },
224
+ );
225
+ });
226
+
146
227
  it('returns error text on failure', async () => {
147
228
  vi.mocked(runner.run).mockRejectedValue(new Error('Update failed'));
148
229
  const handlers = setupHandlers();
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { expandAnchorRange, countNonEmptyCells } from '../../src/tools/sheets-a1.js';
3
+
4
+ describe('expandAnchorRange', () => {
5
+ it('expands a bare anchor cell to the full written area', () => {
6
+ expect(expandAnchorRange('A1', 3, 2)).toBe('A1:B3');
7
+ });
8
+
9
+ it('preserves a sheet-name prefix when expanding', () => {
10
+ expect(expandAnchorRange('Sheet1!A1', 70, 4)).toBe('Sheet1!A1:D70');
11
+ });
12
+
13
+ it('generates multi-letter end columns past Z', () => {
14
+ expect(expandAnchorRange('A1', 1, 27)).toBe('A1:AA1');
15
+ });
16
+
17
+ it('parses multi-letter start columns', () => {
18
+ expect(expandAnchorRange('AA5', 1, 1)).toBe('AA5:AA5');
19
+ });
20
+
21
+ it('uppercases a lowercase anchor column', () => {
22
+ expect(expandAnchorRange('b2', 1, 1)).toBe('B2:B2');
23
+ });
24
+
25
+ it('leaves an explicit A1 range (with a colon) unchanged', () => {
26
+ expect(expandAnchorRange('Sheet1!A1:D70', 5, 5)).toBe('Sheet1!A1:D70');
27
+ });
28
+
29
+ it('leaves a named range unchanged', () => {
30
+ expect(expandAnchorRange('MyNamedRange', 5, 5)).toBe('MyNamedRange');
31
+ });
32
+ });
33
+
34
+ describe('countNonEmptyCells', () => {
35
+ it('counts cells that hold data across ragged rows', () => {
36
+ expect(countNonEmptyCells('{"values":[["a","b"],["c"]]}')).toBe(3);
37
+ });
38
+
39
+ it('treats empty strings as empty', () => {
40
+ expect(countNonEmptyCells('{"values":[["","",""]]}')).toBe(0);
41
+ });
42
+
43
+ it('treats whitespace-only cells as empty', () => {
44
+ expect(countNonEmptyCells('{"values":[[" "]]}')).toBe(0);
45
+ });
46
+
47
+ it('counts numbers and zero as data, skips nulls', () => {
48
+ expect(countNonEmptyCells('{"values":[[1,0,null,"x"]]}')).toBe(3);
49
+ });
50
+
51
+ it('returns 0 when there is no values key', () => {
52
+ expect(countNonEmptyCells('{}')).toBe(0);
53
+ });
54
+
55
+ it('returns 0 when values is not an array', () => {
56
+ expect(countNonEmptyCells('{"values":"foo"}')).toBe(0);
57
+ });
58
+
59
+ it('returns 0 when the parsed JSON is null', () => {
60
+ expect(countNonEmptyCells('null')).toBe(0);
61
+ });
62
+
63
+ it('skips non-array rows', () => {
64
+ expect(countNonEmptyCells('{"values":[null,["a"]]}')).toBe(1);
65
+ });
66
+
67
+ it('returns -1 when the output is not valid JSON', () => {
68
+ expect(countNonEmptyCells('not json')).toBe(-1);
69
+ });
70
+ });