apple-numbers-mcp 0.5.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +709 -0
  3. package/build/index.d.ts +3 -0
  4. package/build/index.d.ts.map +1 -0
  5. package/build/index.js +483 -0
  6. package/build/index.js.map +1 -0
  7. package/build/services/fileConfig.d.ts +7 -0
  8. package/build/services/fileConfig.d.ts.map +1 -0
  9. package/build/services/fileConfig.js +52 -0
  10. package/build/services/fileConfig.js.map +1 -0
  11. package/build/services/numbersManager.d.ts +175 -0
  12. package/build/services/numbersManager.d.ts.map +1 -0
  13. package/build/services/numbersManager.js +357 -0
  14. package/build/services/numbersManager.js.map +1 -0
  15. package/build/tools/doctor.d.ts +22 -0
  16. package/build/tools/doctor.d.ts.map +1 -0
  17. package/build/tools/doctor.js +96 -0
  18. package/build/tools/doctor.js.map +1 -0
  19. package/build/tools/resourcesAndPrompts.d.ts +15 -0
  20. package/build/tools/resourcesAndPrompts.d.ts.map +1 -0
  21. package/build/tools/resourcesAndPrompts.js +77 -0
  22. package/build/tools/resourcesAndPrompts.js.map +1 -0
  23. package/build/tools/respond.d.ts +29 -0
  24. package/build/tools/respond.d.ts.map +1 -0
  25. package/build/tools/respond.js +44 -0
  26. package/build/tools/respond.js.map +1 -0
  27. package/build/types.d.ts +128 -0
  28. package/build/types.d.ts.map +1 -0
  29. package/build/types.js +2 -0
  30. package/build/types.js.map +1 -0
  31. package/build/utils/applescript.d.ts +90 -0
  32. package/build/utils/applescript.d.ts.map +1 -0
  33. package/build/utils/applescript.js +248 -0
  34. package/build/utils/applescript.js.map +1 -0
  35. package/build/utils/python.d.ts +12 -0
  36. package/build/utils/python.d.ts.map +1 -0
  37. package/build/utils/python.js +101 -0
  38. package/build/utils/python.js.map +1 -0
  39. package/package.json +91 -0
  40. package/requirements.txt +1 -0
  41. package/scripts/setup.sh +46 -0
  42. package/src/utils/numbers_reader.py +779 -0
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bridge script: reads and writes .numbers files using numbers-parser and outputs JSON.
4
+ Called by the TypeScript MCP server via child_process.
5
+ """
6
+
7
+ import sys
8
+ import json
9
+ import argparse
10
+ from pathlib import Path
11
+
12
+ try:
13
+ from numbers_parser import Document
14
+ except ImportError:
15
+ print(json.dumps({
16
+ "error": "numbers-parser not installed. Run: pip3 install numbers-parser"
17
+ }))
18
+ sys.exit(1)
19
+
20
+
21
+ def cell_to_serializable(cell):
22
+ """Convert a numbers-parser cell to a JSON-serializable dict."""
23
+ val = cell.value
24
+ if val is None:
25
+ return {"value": None, "type": "empty"}
26
+
27
+ from datetime import datetime, date, time, timedelta
28
+ if isinstance(val, bool):
29
+ return {"value": val, "type": "boolean"}
30
+ elif isinstance(val, (int, float)):
31
+ # Round floats that are within epsilon of an integer
32
+ if isinstance(val, float):
33
+ rounded = round(val)
34
+ if abs(val - rounded) < 1e-9:
35
+ val = rounded
36
+ return {"value": val, "type": "number"}
37
+ elif isinstance(val, (datetime, date)):
38
+ iso = val.isoformat()
39
+ # Strip T00:00:00 from pure dates (no meaningful time component)
40
+ if isinstance(val, datetime) and val.hour == 0 and val.minute == 0 and val.second == 0 and val.microsecond == 0:
41
+ iso = val.date().isoformat()
42
+ return {"value": iso, "type": "date"}
43
+ elif isinstance(val, time):
44
+ return {"value": val.isoformat(), "type": "duration"}
45
+ elif isinstance(val, timedelta):
46
+ return {"value": str(val), "type": "duration"}
47
+ elif isinstance(val, str):
48
+ return {"value": val, "type": "string"}
49
+ else:
50
+ return {"value": str(val), "type": "string"}
51
+
52
+
53
+ def _format_cell_value(val):
54
+ """Format a raw cell value as a clean string (for headers, etc.)."""
55
+ if val is None:
56
+ return None
57
+ from datetime import datetime, date
58
+ if isinstance(val, datetime) and val.hour == 0 and val.minute == 0 and val.second == 0 and val.microsecond == 0:
59
+ return val.date().isoformat()
60
+ if isinstance(val, date):
61
+ return val.isoformat()
62
+ if isinstance(val, float):
63
+ rounded = round(val)
64
+ if abs(val - rounded) < 1e-9:
65
+ return str(rounded)
66
+ return str(val)
67
+
68
+
69
+ def get_table_info(table, sheet_name):
70
+ """Get metadata about a table."""
71
+ headers = []
72
+ if table.num_rows > 0:
73
+ for col in range(table.num_cols):
74
+ cell = table.cell(0, col)
75
+ formatted = _format_cell_value(cell.value)
76
+ headers.append(formatted if formatted is not None else f"Column_{col}")
77
+ return {
78
+ "name": table.name,
79
+ "sheetName": sheet_name,
80
+ "numRows": table.num_rows,
81
+ "numCols": table.num_cols,
82
+ "headerRow": headers,
83
+ }
84
+
85
+
86
+ def cmd_info(args):
87
+ """Get file structure: sheets, tables, dimensions."""
88
+ doc = Document(args.file)
89
+ sheets = []
90
+ for sheet in doc.sheets:
91
+ tables = [get_table_info(t, sheet.name) for t in sheet.tables]
92
+ sheets.append({"name": sheet.name, "tables": tables})
93
+ default_sheet = sheets[0]["name"] if sheets else ""
94
+ dt = doc.default_table
95
+ if dt:
96
+ for s in doc.sheets:
97
+ if any(t.name == dt.name for t in s.tables):
98
+ default_sheet = s.name
99
+ break
100
+ result = {
101
+ "path": str(Path(args.file).resolve()),
102
+ "sheets": sheets,
103
+ "defaultSheet": default_sheet,
104
+ }
105
+ print(json.dumps(result))
106
+
107
+
108
+ def cmd_read(args):
109
+ """Read table data as rows, with optional range filtering."""
110
+ doc = Document(args.file)
111
+ sheet = _find_sheet(doc, args.sheet)
112
+ table = _find_table(sheet, args.table)
113
+
114
+ # Determine column indices to include
115
+ col_indices = list(range(table.num_cols))
116
+ if args.columns:
117
+ requested = json.loads(args.columns)
118
+ col_indices = []
119
+ all_headers = []
120
+ for c in range(table.num_cols):
121
+ cell = table.cell(0, c)
122
+ formatted = _format_cell_value(cell.value)
123
+ all_headers.append(formatted if formatted is not None else f"Column_{c}")
124
+ for col_ref in requested:
125
+ if isinstance(col_ref, int):
126
+ col_indices.append(col_ref)
127
+ elif isinstance(col_ref, str):
128
+ # Match by header name (case-insensitive)
129
+ found = False
130
+ for idx, h in enumerate(all_headers):
131
+ if h.lower() == col_ref.lower():
132
+ col_indices.append(idx)
133
+ found = True
134
+ break
135
+ if not found:
136
+ raise ValueError(f"Column '{col_ref}' not found. Available: {all_headers}")
137
+
138
+ headers = []
139
+ for col in col_indices:
140
+ cell = table.cell(0, col)
141
+ formatted = _format_cell_value(cell.value)
142
+ headers.append(formatted if formatted is not None else f"Column_{col}")
143
+
144
+ # Determine row range
145
+ default_start = 0 if args.include_header_row else 1
146
+ start_row = int(args.start_row) if args.start_row is not None else default_start
147
+ end_row = int(args.end_row) if args.end_row is not None else table.num_rows - 1
148
+
149
+ rows = []
150
+ for row_idx in range(start_row, min(end_row + 1, table.num_rows)):
151
+ row = []
152
+ for col_idx in col_indices:
153
+ cell = table.cell(row_idx, col_idx)
154
+ converted = cell_to_serializable(cell)
155
+ row.append(converted["value"])
156
+ rows.append(row)
157
+
158
+ result = {
159
+ "sheetName": sheet.name,
160
+ "tableName": table.name,
161
+ "headers": headers,
162
+ "rows": rows,
163
+ "numRows": len(rows),
164
+ "numCols": len(col_indices),
165
+ }
166
+ print(json.dumps(result))
167
+
168
+
169
+ def cmd_search(args):
170
+ """Search for a value across all cells."""
171
+ doc = Document(args.file)
172
+ query = args.query.lower()
173
+ results = []
174
+ sheets_to_search = [_find_sheet(doc, args.sheet)] if args.sheet else doc.sheets
175
+
176
+ for sheet in sheets_to_search:
177
+ for table in sheet.tables:
178
+ headers = []
179
+ for col in range(table.num_cols):
180
+ cell = table.cell(0, col)
181
+ formatted = _format_cell_value(cell.value)
182
+ headers.append(formatted if formatted is not None else f"Column_{col}")
183
+ for row_idx in range(table.num_rows):
184
+ for col_idx in range(table.num_cols):
185
+ cell = table.cell(row_idx, col_idx)
186
+ if cell.value is not None and query in str(cell.value).lower():
187
+ results.append({
188
+ "sheetName": sheet.name,
189
+ "tableName": table.name,
190
+ "row": row_idx,
191
+ "col": col_idx,
192
+ "header": headers[col_idx] if col_idx < len(headers) else f"Column_{col_idx}",
193
+ "value": cell_to_serializable(cell)["value"],
194
+ })
195
+ print(json.dumps({"results": results, "count": len(results)}))
196
+
197
+
198
+ def cmd_export(args):
199
+ """Export a table to CSV, TSV, or JSON."""
200
+ doc = Document(args.file)
201
+ sheet = _find_sheet(doc, args.sheet)
202
+ table = _find_table(sheet, args.table)
203
+ output_path = Path(args.output)
204
+
205
+ headers = []
206
+ for col in range(table.num_cols):
207
+ cell = table.cell(0, col)
208
+ formatted = _format_cell_value(cell.value)
209
+ headers.append(formatted if formatted is not None else f"Column_{col}")
210
+
211
+ rows = []
212
+ for row_idx in range(1, table.num_rows):
213
+ row = []
214
+ for col_idx in range(table.num_cols):
215
+ cell = table.cell(row_idx, col_idx)
216
+ row.append(cell_to_serializable(cell)["value"])
217
+ rows.append(row)
218
+
219
+ if args.format == "json":
220
+ data = [dict(zip(headers, row)) for row in rows]
221
+ output_path.write_text(json.dumps(data, indent=2, default=str))
222
+ elif args.format in ("csv", "tsv"):
223
+ import csv
224
+ delimiter = "\t" if args.format == "tsv" else ","
225
+ with open(output_path, "w", newline="") as f:
226
+ writer = csv.writer(f, delimiter=delimiter)
227
+ writer.writerow(headers)
228
+ for row in rows:
229
+ writer.writerow([str(v) if v is not None else "" for v in row])
230
+
231
+ result = {
232
+ "outputPath": str(output_path.resolve()),
233
+ "format": args.format,
234
+ "rowCount": len(rows),
235
+ "sheetName": sheet.name,
236
+ "tableName": table.name,
237
+ }
238
+ print(json.dumps(result))
239
+
240
+
241
+ def cmd_cell(args):
242
+ """Read a single cell value, optionally with metadata."""
243
+ doc = Document(args.file)
244
+ sheet = _find_sheet(doc, args.sheet)
245
+ table = _find_table(sheet, args.table)
246
+ row, col = int(args.row), int(args.col)
247
+ if row >= table.num_rows or col >= table.num_cols:
248
+ print(json.dumps({"error": f"Cell ({row},{col}) out of range. Table is {table.num_rows}x{table.num_cols}"}))
249
+ return
250
+ cell = table.cell(row, col)
251
+ result = cell_to_serializable(cell)
252
+ result["row"] = row
253
+ result["col"] = col
254
+ if args.verbose:
255
+ result["formula"] = getattr(cell, "formula", None)
256
+ result["isFormula"] = getattr(cell, "is_formula", False)
257
+ result["isMerged"] = getattr(cell, "is_merged", False)
258
+ fv = getattr(cell, "formatted_value", None)
259
+ result["formattedValue"] = str(fv) if fv is not None else None
260
+ print(json.dumps(result))
261
+
262
+
263
+ def _coerce_value(raw, type_hint=None):
264
+ """Coerce a JSON value to the appropriate Python type for writing."""
265
+ if raw is None:
266
+ return None
267
+ if type_hint == "boolean" or (type_hint is None and isinstance(raw, bool)):
268
+ return bool(raw)
269
+ if type_hint == "number" or (type_hint is None and isinstance(raw, (int, float))):
270
+ if isinstance(raw, float) and raw == int(raw):
271
+ return int(raw)
272
+ return raw if isinstance(raw, (int, float)) else float(raw)
273
+ if type_hint == "date":
274
+ from datetime import datetime
275
+ if isinstance(raw, str):
276
+ return datetime.fromisoformat(raw)
277
+ return raw
278
+ # Default: string
279
+ return str(raw)
280
+
281
+
282
+ def cmd_create(args):
283
+ """Create a new .numbers file with headers and optional data rows."""
284
+ headers = json.loads(args.headers)
285
+ rows = json.loads(args.rows) if args.rows else []
286
+ num_rows = 1 + len(rows) # header row + data rows
287
+ num_cols = len(headers)
288
+
289
+ doc = Document(num_rows=num_rows, num_cols=num_cols)
290
+ sheet = doc.sheets[0]
291
+ if args.sheet_name:
292
+ sheet.name = args.sheet_name
293
+ table = sheet.tables[0]
294
+ if args.table_name:
295
+ table.name = args.table_name
296
+
297
+ for col_idx, header in enumerate(headers):
298
+ if header is not None:
299
+ if header is not None:
300
+ table.write(0, col_idx, str(header))
301
+
302
+ rows_written = 0
303
+ for row_idx, row in enumerate(rows, start=1):
304
+ for col_idx, val in enumerate(row):
305
+ coerced = _coerce_value(val)
306
+ if coerced is not None:
307
+ table.write(row_idx, col_idx, coerced)
308
+ rows_written += 1
309
+
310
+ doc.save(args.file)
311
+ result = {
312
+ "path": str(Path(args.file).resolve()),
313
+ "sheetName": sheet.name,
314
+ "tableName": table.name,
315
+ "numHeaders": len(headers),
316
+ "numRows": rows_written,
317
+ }
318
+ print(json.dumps(result))
319
+
320
+
321
+ def cmd_set_cell(args):
322
+ """Write a single cell value in an existing file."""
323
+ doc = Document(args.file)
324
+ sheet = _find_sheet(doc, args.sheet)
325
+ table = _find_table(sheet, args.table)
326
+ row, col = int(args.row), int(args.col)
327
+ value = _coerce_value(json.loads(args.value), args.type)
328
+ if value is not None:
329
+ table.write(row, col, value)
330
+ doc.save(args.file)
331
+ result = {
332
+ "path": str(Path(args.file).resolve()),
333
+ "sheetName": sheet.name,
334
+ "tableName": table.name,
335
+ "row": row,
336
+ "col": col,
337
+ "value": str(value),
338
+ }
339
+ print(json.dumps(result))
340
+
341
+
342
+ def cmd_set_cells(args):
343
+ """Write multiple cell values in one operation."""
344
+ doc = Document(args.file)
345
+ sheet = _find_sheet(doc, args.sheet)
346
+ table = _find_table(sheet, args.table)
347
+ updates = json.loads(args.updates)
348
+ cells_written = 0
349
+ for upd in updates:
350
+ row, col = int(upd["row"]), int(upd["col"])
351
+ value = _coerce_value(upd["value"], upd.get("type"))
352
+ if value is not None:
353
+ table.write(row, col, value)
354
+ cells_written += 1
355
+ doc.save(args.file)
356
+ result = {
357
+ "path": str(Path(args.file).resolve()),
358
+ "sheetName": sheet.name,
359
+ "tableName": table.name,
360
+ "cellsWritten": cells_written,
361
+ }
362
+ print(json.dumps(result))
363
+
364
+
365
+ def cmd_add_rows(args):
366
+ """Append rows of data to an existing table."""
367
+ doc = Document(args.file)
368
+ sheet = _find_sheet(doc, args.sheet)
369
+ table = _find_table(sheet, args.table)
370
+ rows = json.loads(args.rows)
371
+ start_row = table.num_rows
372
+ for row_offset, row in enumerate(rows):
373
+ has_value = False
374
+ for col_idx, val in enumerate(row):
375
+ coerced = _coerce_value(val)
376
+ if coerced is not None:
377
+ table.write(start_row + row_offset, col_idx, coerced)
378
+ has_value = True
379
+ # Ensure empty rows still expand the table
380
+ if not has_value:
381
+ table.write(start_row + row_offset, 0, "")
382
+ doc.save(args.file)
383
+ result = {
384
+ "path": str(Path(args.file).resolve()),
385
+ "sheetName": sheet.name,
386
+ "tableName": table.name,
387
+ "rowsAdded": len(rows),
388
+ "startRow": start_row,
389
+ "newTotalRows": table.num_rows,
390
+ }
391
+ print(json.dumps(result))
392
+
393
+
394
+ def cmd_delete_rows(args):
395
+ """Delete rows from a table by index range."""
396
+ doc = Document(args.file)
397
+ sheet = _find_sheet(doc, args.sheet)
398
+ table = _find_table(sheet, args.table)
399
+ start_row = int(args.start_row)
400
+ end_row = int(args.end_row)
401
+ if start_row > end_row:
402
+ raise ValueError(f"start_row ({start_row}) must be <= end_row ({end_row})")
403
+ if start_row >= table.num_rows or end_row >= table.num_rows:
404
+ raise ValueError(f"Row range [{start_row},{end_row}] out of bounds. Table has {table.num_rows} rows.")
405
+ num_to_delete = end_row - start_row + 1
406
+ # delete_row(num_rows, start_row) deletes num_rows starting at start_row
407
+ table.delete_row(num_to_delete, start_row)
408
+ rows_deleted = num_to_delete
409
+ doc.save(args.file)
410
+ result = {
411
+ "path": str(Path(args.file).resolve()),
412
+ "sheetName": sheet.name,
413
+ "tableName": table.name,
414
+ "rowsDeleted": rows_deleted,
415
+ "newTotalRows": table.num_rows,
416
+ }
417
+ print(json.dumps(result))
418
+
419
+
420
+ def cmd_add_sheet(args):
421
+ """Add a new sheet to an existing file."""
422
+ doc = Document(args.file)
423
+ headers = json.loads(args.headers) if args.headers else None
424
+ num_rows = int(args.num_rows) if args.num_rows else (1 if headers else 12)
425
+ num_cols = int(args.num_cols) if args.num_cols else (len(headers) if headers else 8)
426
+ doc.add_sheet(args.sheet_name, num_rows=num_rows, num_cols=num_cols)
427
+ sheet = doc.sheets[-1]
428
+ table = sheet.tables[0]
429
+ if args.table_name:
430
+ table.name = args.table_name
431
+ if headers:
432
+ for col_idx, header in enumerate(headers):
433
+ if header is not None:
434
+ table.write(0, col_idx, str(header))
435
+ doc.save(args.file)
436
+ result = {
437
+ "path": str(Path(args.file).resolve()),
438
+ "sheetName": sheet.name,
439
+ "tableName": table.name,
440
+ "numRows": table.num_rows,
441
+ "numCols": table.num_cols,
442
+ }
443
+ print(json.dumps(result))
444
+
445
+
446
+ def cmd_add_table(args):
447
+ """Add a new table to an existing sheet."""
448
+ doc = Document(args.file)
449
+ sheet = _find_sheet(doc, args.sheet)
450
+ headers = json.loads(args.headers) if args.headers else None
451
+ num_rows = int(args.num_rows) if args.num_rows else (1 if headers else 12)
452
+ num_cols = int(args.num_cols) if args.num_cols else (len(headers) if headers else 8)
453
+ table_name = args.table_name or None
454
+ sheet.add_table(table_name, num_rows=num_rows, num_cols=num_cols)
455
+ table = sheet.tables[-1]
456
+ if headers:
457
+ for col_idx, header in enumerate(headers):
458
+ if header is not None:
459
+ table.write(0, col_idx, str(header))
460
+ doc.save(args.file)
461
+ result = {
462
+ "path": str(Path(args.file).resolve()),
463
+ "sheetName": sheet.name,
464
+ "tableName": table.name,
465
+ "numRows": table.num_rows,
466
+ "numCols": table.num_cols,
467
+ }
468
+ print(json.dumps(result))
469
+
470
+
471
+ def cmd_import(args):
472
+ """Import a CSV/TSV/JSON file into a new .numbers file."""
473
+ import csv as csv_mod
474
+ input_path = Path(args.input)
475
+ if not input_path.exists():
476
+ raise FileNotFoundError(f"Input file not found: {input_path}")
477
+
478
+ fmt = args.format
479
+ if fmt == "auto":
480
+ ext = input_path.suffix.lower()
481
+ fmt = {"csv": "csv", ".csv": "csv", ".tsv": "tsv", ".json": "json"}.get(ext, "csv")
482
+
483
+ headers = []
484
+ rows = []
485
+ if fmt in ("csv", "tsv"):
486
+ delimiter = "\t" if fmt == "tsv" else ","
487
+ with open(input_path, newline="", encoding="utf-8") as f:
488
+ reader = csv_mod.reader(f, delimiter=delimiter)
489
+ headers = next(reader, [])
490
+ for row in reader:
491
+ converted = []
492
+ for val in row:
493
+ converted.append(_auto_convert(val))
494
+ rows.append(converted)
495
+ elif fmt == "json":
496
+ data = json.loads(input_path.read_text(encoding="utf-8"))
497
+ if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
498
+ headers = list(data[0].keys())
499
+ for item in data:
500
+ rows.append([item.get(h) for h in headers])
501
+ elif isinstance(data, list) and len(data) > 0 and isinstance(data[0], list):
502
+ headers = [f"Column_{i}" for i in range(len(data[0]))]
503
+ rows = data
504
+ else:
505
+ raise ValueError("JSON must be an array of objects or array of arrays")
506
+
507
+ num_rows = 1 + len(rows)
508
+ num_cols = len(headers) if headers else 1
509
+ doc = Document(num_rows=num_rows, num_cols=num_cols)
510
+ sheet = doc.sheets[0]
511
+ if args.sheet_name:
512
+ sheet.name = args.sheet_name
513
+ table = sheet.tables[0]
514
+ if args.table_name:
515
+ table.name = args.table_name
516
+
517
+ for col_idx, header in enumerate(headers):
518
+ table.write(0, col_idx, str(header))
519
+ for row_idx, row in enumerate(rows, start=1):
520
+ for col_idx, val in enumerate(row):
521
+ coerced = _coerce_value(val)
522
+ if coerced is not None:
523
+ table.write(row_idx, col_idx, coerced)
524
+
525
+ doc.save(args.output)
526
+ result = {
527
+ "path": str(Path(args.output).resolve()),
528
+ "inputPath": str(input_path.resolve()),
529
+ "format": fmt,
530
+ "sheetName": sheet.name,
531
+ "tableName": table.name,
532
+ "numHeaders": len(headers),
533
+ "numRows": len(rows),
534
+ }
535
+ print(json.dumps(result))
536
+
537
+
538
+ def _auto_convert(val):
539
+ """Try to convert a CSV string value to its natural type."""
540
+ if val == "":
541
+ return None
542
+ if val.lower() in ("true", "false"):
543
+ return val.lower() == "true"
544
+ try:
545
+ iv = int(val)
546
+ return iv
547
+ except ValueError:
548
+ pass
549
+ try:
550
+ fv = float(val)
551
+ return fv
552
+ except ValueError:
553
+ pass
554
+ return val
555
+
556
+
557
+ def cmd_update_rows(args):
558
+ """Write full rows by index."""
559
+ doc = Document(args.file)
560
+ sheet = _find_sheet(doc, args.sheet)
561
+ table = _find_table(sheet, args.table)
562
+ updates = json.loads(args.updates)
563
+ rows_updated = 0
564
+ for upd in updates:
565
+ row_idx = int(upd["row"])
566
+ values = upd["values"]
567
+ for col_idx, val in enumerate(values):
568
+ coerced = _coerce_value(val)
569
+ if coerced is not None:
570
+ table.write(row_idx, col_idx, coerced)
571
+ rows_updated += 1
572
+ doc.save(args.file)
573
+ result = {
574
+ "path": str(Path(args.file).resolve()),
575
+ "sheetName": sheet.name,
576
+ "tableName": table.name,
577
+ "rowsUpdated": rows_updated,
578
+ }
579
+ print(json.dumps(result))
580
+
581
+
582
+ def cmd_rename_sheet(args):
583
+ """Rename a sheet."""
584
+ doc = Document(args.file)
585
+ sheet = _find_sheet(doc, args.sheet)
586
+ old_name = sheet.name
587
+ sheet.name = args.new_name
588
+ doc.save(args.file)
589
+ result = {
590
+ "path": str(Path(args.file).resolve()),
591
+ "oldName": old_name,
592
+ "newName": sheet.name,
593
+ }
594
+ print(json.dumps(result))
595
+
596
+
597
+ def cmd_rename_table(args):
598
+ """Rename a table."""
599
+ doc = Document(args.file)
600
+ sheet = _find_sheet(doc, args.sheet)
601
+ table = _find_table(sheet, args.table)
602
+ old_name = table.name
603
+ table.name = args.new_name
604
+ doc.save(args.file)
605
+ result = {
606
+ "path": str(Path(args.file).resolve()),
607
+ "sheetName": sheet.name,
608
+ "oldName": old_name,
609
+ "newName": table.name,
610
+ }
611
+ print(json.dumps(result))
612
+
613
+
614
+ def _find_sheet(doc, sheet_name=None):
615
+ if not sheet_name:
616
+ return doc.sheets[0]
617
+ for s in doc.sheets:
618
+ if s.name.lower() == sheet_name.lower():
619
+ return s
620
+ names = [s.name for s in doc.sheets]
621
+ raise ValueError(f"Sheet '{sheet_name}' not found. Available: {names}")
622
+
623
+
624
+ def _find_table(sheet, table_name=None):
625
+ if not table_name:
626
+ return sheet.tables[0]
627
+ for t in sheet.tables:
628
+ if t.name.lower() == table_name.lower():
629
+ return t
630
+ names = [t.name for t in sheet.tables]
631
+ raise ValueError(f"Table '{table_name}' not found in sheet '{sheet.name}'. Available: {names}")
632
+
633
+
634
+ def main():
635
+ parser = argparse.ArgumentParser(description="Apple Numbers file reader")
636
+ subparsers = parser.add_subparsers(dest="command", required=True)
637
+
638
+ # info
639
+ p_info = subparsers.add_parser("info")
640
+ p_info.add_argument("file")
641
+
642
+ # read
643
+ p_read = subparsers.add_parser("read")
644
+ p_read.add_argument("file")
645
+ p_read.add_argument("--sheet", default=None)
646
+ p_read.add_argument("--table", default=None)
647
+ p_read.add_argument("--include-header-row", action="store_true")
648
+ p_read.add_argument("--start-row", default=None, help="Start row index (0-based, inclusive)")
649
+ p_read.add_argument("--end-row", default=None, help="End row index (0-based, inclusive)")
650
+ p_read.add_argument("--columns", default=None, help="JSON array of column indices or header names")
651
+
652
+ # search
653
+ p_search = subparsers.add_parser("search")
654
+ p_search.add_argument("file")
655
+ p_search.add_argument("query")
656
+ p_search.add_argument("--sheet", default=None)
657
+
658
+ # export
659
+ p_export = subparsers.add_parser("export")
660
+ p_export.add_argument("file")
661
+ p_export.add_argument("format", choices=["csv", "tsv", "json"])
662
+ p_export.add_argument("output")
663
+ p_export.add_argument("--sheet", default=None)
664
+ p_export.add_argument("--table", default=None)
665
+
666
+ # cell
667
+ p_cell = subparsers.add_parser("cell")
668
+ p_cell.add_argument("file")
669
+ p_cell.add_argument("sheet")
670
+ p_cell.add_argument("table")
671
+ p_cell.add_argument("row")
672
+ p_cell.add_argument("col")
673
+ p_cell.add_argument("--verbose", action="store_true")
674
+
675
+ # create
676
+ p_create = subparsers.add_parser("create")
677
+ p_create.add_argument("file")
678
+ p_create.add_argument("headers", help="JSON array of header strings")
679
+ p_create.add_argument("--sheet-name", default=None)
680
+ p_create.add_argument("--table-name", default=None)
681
+ p_create.add_argument("--rows", default=None, help="JSON array of row arrays")
682
+
683
+ # set-cell
684
+ p_set_cell = subparsers.add_parser("set-cell")
685
+ p_set_cell.add_argument("file")
686
+ p_set_cell.add_argument("row")
687
+ p_set_cell.add_argument("col")
688
+ p_set_cell.add_argument("value", help="JSON-encoded value")
689
+ p_set_cell.add_argument("--sheet", default=None)
690
+ p_set_cell.add_argument("--table", default=None)
691
+ p_set_cell.add_argument("--type", default=None, choices=["string", "number", "boolean", "date"])
692
+
693
+ # set-cells
694
+ p_set_cells = subparsers.add_parser("set-cells")
695
+ p_set_cells.add_argument("file")
696
+ p_set_cells.add_argument("updates", help="JSON array of {row, col, value, type?}")
697
+ p_set_cells.add_argument("--sheet", default=None)
698
+ p_set_cells.add_argument("--table", default=None)
699
+
700
+ # add-rows
701
+ p_add_rows = subparsers.add_parser("add-rows")
702
+ p_add_rows.add_argument("file")
703
+ p_add_rows.add_argument("rows", help="JSON array of row arrays")
704
+ p_add_rows.add_argument("--sheet", default=None)
705
+ p_add_rows.add_argument("--table", default=None)
706
+
707
+ # delete-rows
708
+ p_del_rows = subparsers.add_parser("delete-rows")
709
+ p_del_rows.add_argument("file")
710
+ p_del_rows.add_argument("start_row")
711
+ p_del_rows.add_argument("end_row")
712
+ p_del_rows.add_argument("--sheet", default=None)
713
+ p_del_rows.add_argument("--table", default=None)
714
+
715
+ # add-sheet
716
+ p_add_sheet = subparsers.add_parser("add-sheet")
717
+ p_add_sheet.add_argument("file")
718
+ p_add_sheet.add_argument("sheet_name")
719
+ p_add_sheet.add_argument("--table-name", default=None)
720
+ p_add_sheet.add_argument("--headers", default=None, help="JSON array of header strings")
721
+ p_add_sheet.add_argument("--num-rows", default=None)
722
+ p_add_sheet.add_argument("--num-cols", default=None)
723
+
724
+ # add-table
725
+ p_add_table = subparsers.add_parser("add-table")
726
+ p_add_table.add_argument("file")
727
+ p_add_table.add_argument("--sheet", default=None)
728
+ p_add_table.add_argument("--table-name", default=None)
729
+ p_add_table.add_argument("--headers", default=None, help="JSON array of header strings")
730
+ p_add_table.add_argument("--num-rows", default=None)
731
+ p_add_table.add_argument("--num-cols", default=None)
732
+
733
+ # import
734
+ p_import = subparsers.add_parser("import")
735
+ p_import.add_argument("input", help="Path to CSV/TSV/JSON file")
736
+ p_import.add_argument("output", help="Path for output .numbers file")
737
+ p_import.add_argument("--format", default="auto", choices=["auto", "csv", "tsv", "json"])
738
+ p_import.add_argument("--sheet-name", default=None)
739
+ p_import.add_argument("--table-name", default=None)
740
+
741
+ # update-rows
742
+ p_upd_rows = subparsers.add_parser("update-rows")
743
+ p_upd_rows.add_argument("file")
744
+ p_upd_rows.add_argument("updates", help='JSON array of {row, values: [...]}')
745
+ p_upd_rows.add_argument("--sheet", default=None)
746
+ p_upd_rows.add_argument("--table", default=None)
747
+
748
+ # rename-sheet
749
+ p_ren_sheet = subparsers.add_parser("rename-sheet")
750
+ p_ren_sheet.add_argument("file")
751
+ p_ren_sheet.add_argument("new_name")
752
+ p_ren_sheet.add_argument("--sheet", default=None)
753
+
754
+ # rename-table
755
+ p_ren_table = subparsers.add_parser("rename-table")
756
+ p_ren_table.add_argument("file")
757
+ p_ren_table.add_argument("new_name")
758
+ p_ren_table.add_argument("--sheet", default=None)
759
+ p_ren_table.add_argument("--table", default=None)
760
+
761
+ args = parser.parse_args()
762
+ commands = {
763
+ "info": cmd_info, "read": cmd_read, "search": cmd_search,
764
+ "export": cmd_export, "cell": cmd_cell, "create": cmd_create,
765
+ "set-cell": cmd_set_cell, "set-cells": cmd_set_cells,
766
+ "add-rows": cmd_add_rows, "delete-rows": cmd_delete_rows,
767
+ "add-sheet": cmd_add_sheet, "add-table": cmd_add_table,
768
+ "import": cmd_import, "update-rows": cmd_update_rows,
769
+ "rename-sheet": cmd_rename_sheet, "rename-table": cmd_rename_table,
770
+ }
771
+ try:
772
+ commands[args.command](args)
773
+ except Exception as e:
774
+ print(json.dumps({"error": str(e)}))
775
+ sys.exit(1)
776
+
777
+
778
+ if __name__ == "__main__":
779
+ main()