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.
- package/LICENSE +21 -0
- package/README.md +709 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +483 -0
- package/build/index.js.map +1 -0
- package/build/services/fileConfig.d.ts +7 -0
- package/build/services/fileConfig.d.ts.map +1 -0
- package/build/services/fileConfig.js +52 -0
- package/build/services/fileConfig.js.map +1 -0
- package/build/services/numbersManager.d.ts +175 -0
- package/build/services/numbersManager.d.ts.map +1 -0
- package/build/services/numbersManager.js +357 -0
- package/build/services/numbersManager.js.map +1 -0
- package/build/tools/doctor.d.ts +22 -0
- package/build/tools/doctor.d.ts.map +1 -0
- package/build/tools/doctor.js +96 -0
- package/build/tools/doctor.js.map +1 -0
- package/build/tools/resourcesAndPrompts.d.ts +15 -0
- package/build/tools/resourcesAndPrompts.d.ts.map +1 -0
- package/build/tools/resourcesAndPrompts.js +77 -0
- package/build/tools/resourcesAndPrompts.js.map +1 -0
- package/build/tools/respond.d.ts +29 -0
- package/build/tools/respond.d.ts.map +1 -0
- package/build/tools/respond.js +44 -0
- package/build/tools/respond.js.map +1 -0
- package/build/types.d.ts +128 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/applescript.d.ts +90 -0
- package/build/utils/applescript.d.ts.map +1 -0
- package/build/utils/applescript.js +248 -0
- package/build/utils/applescript.js.map +1 -0
- package/build/utils/python.d.ts +12 -0
- package/build/utils/python.d.ts.map +1 -0
- package/build/utils/python.js +101 -0
- package/build/utils/python.js.map +1 -0
- package/package.json +91 -0
- package/requirements.txt +1 -0
- package/scripts/setup.sh +46 -0
- 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()
|