@l22-io/orchard-mcp 0.3.2 → 0.6.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.
Files changed (43) hide show
  1. package/README.md +11 -8
  2. package/build/bridge.d.ts +13 -2
  3. package/build/bridge.js +140 -23
  4. package/build/bridge.js.map +1 -1
  5. package/build/index.js +11 -1
  6. package/build/index.js.map +1 -1
  7. package/build/tools/contacts.d.ts +2 -0
  8. package/build/tools/contacts.js +37 -0
  9. package/build/tools/contacts.js.map +1 -0
  10. package/build/tools/files.js +4 -1
  11. package/build/tools/files.js.map +1 -1
  12. package/build/tools/keynote.d.ts +2 -0
  13. package/build/tools/keynote.js +198 -0
  14. package/build/tools/keynote.js.map +1 -0
  15. package/build/tools/mail.js +69 -13
  16. package/build/tools/mail.js.map +1 -1
  17. package/build/tools/notes.d.ts +2 -0
  18. package/build/tools/notes.js +83 -0
  19. package/build/tools/notes.js.map +1 -0
  20. package/build/tools/numbers.d.ts +2 -0
  21. package/build/tools/numbers.js +167 -0
  22. package/build/tools/numbers.js.map +1 -0
  23. package/build/tools/pages.d.ts +2 -0
  24. package/build/tools/pages.js +143 -0
  25. package/build/tools/pages.js.map +1 -0
  26. package/package.json +12 -9
  27. package/scripts/postinstall.sh +77 -8
  28. package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
  29. package/swift/.build/AppleBridge.app.sha256 +1 -0
  30. package/swift/Package.resolved +14 -0
  31. package/swift/Package.swift +28 -0
  32. package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
  33. package/swift/Sources/AppleBridge/Calendar.swift +221 -0
  34. package/swift/Sources/AppleBridge/Contacts.swift +225 -0
  35. package/swift/Sources/AppleBridge/Doctor.swift +252 -0
  36. package/swift/Sources/AppleBridge/Files.swift +474 -0
  37. package/swift/Sources/AppleBridge/JSON.swift +57 -0
  38. package/swift/Sources/AppleBridge/Keynote.swift +599 -0
  39. package/swift/Sources/AppleBridge/Mail.swift +854 -0
  40. package/swift/Sources/AppleBridge/Notes.swift +263 -0
  41. package/swift/Sources/AppleBridge/Numbers.swift +601 -0
  42. package/swift/Sources/AppleBridge/Pages.swift +467 -0
  43. package/swift/Sources/AppleBridge/Reminders.swift +347 -0
@@ -0,0 +1,601 @@
1
+ import Foundation
2
+
3
+ enum NumbersBridge {
4
+
5
+ // MARK: - Info
6
+
7
+ static func info(file: String) {
8
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
9
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
10
+ return
11
+ }
12
+ let escaped = escapeForAppleScript(resolvedFile)
13
+ let script = """
14
+ tell application "Numbers"
15
+ set doc to open POSIX file "\(escaped)"
16
+ set sheetCount to count of sheets of doc
17
+ set docName to name of doc
18
+ set result to {}
19
+ set tableCount to 0
20
+ repeat with s in sheets of doc
21
+ set tableCount to tableCount + (count of tables of s)
22
+ end repeat
23
+ close doc saving no
24
+ return docName & "|||" & (sheetCount as string) & "|||" & (tableCount as string)
25
+ end tell
26
+ """
27
+
28
+ guard let raw = runAppleScript(script) else { return }
29
+ let parts = raw.components(separatedBy: "|||")
30
+ guard parts.count >= 3 else {
31
+ JSONOutput.error("Unexpected response format from Numbers")
32
+ return
33
+ }
34
+
35
+ let result: [String: Any] = [
36
+ "name": parts[0],
37
+ "sheetCount": Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0,
38
+ "tableCount": Int(parts[2].trimmingCharacters(in: .whitespaces)) ?? 0,
39
+ "path": resolvedFile
40
+ ]
41
+ JSONOutput.success(result)
42
+ }
43
+
44
+ // MARK: - List Sheets
45
+
46
+ static func listSheets(file: String) {
47
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
48
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
49
+ return
50
+ }
51
+ let escaped = escapeForAppleScript(resolvedFile)
52
+ let script = """
53
+ tell application "Numbers"
54
+ set doc to open POSIX file "\(escaped)"
55
+ set resultList to {}
56
+ repeat with s in sheets of doc
57
+ set sheetName to name of s
58
+ set tableNames to {}
59
+ repeat with t in tables of s
60
+ set tName to name of t
61
+ set rowCount to row count of t
62
+ set colCount to column count of t
63
+ set end of tableNames to tName & "::" & (rowCount as string) & "::" & (colCount as string)
64
+ end repeat
65
+ set end of resultList to sheetName & "|||" & (my joinList(tableNames, "^^^"))
66
+ end repeat
67
+ close doc saving no
68
+ return my joinList(resultList, "###")
69
+ end tell
70
+
71
+ on joinList(theList, delim)
72
+ set oldDelim to AppleScript's text item delimiters
73
+ set AppleScript's text item delimiters to delim
74
+ set theResult to theList as string
75
+ set AppleScript's text item delimiters to oldDelim
76
+ return theResult
77
+ end joinList
78
+ """
79
+
80
+ guard let raw = runAppleScript(script) else { return }
81
+ let sheets = parseSheetList(raw)
82
+ JSONOutput.success(sheets)
83
+ }
84
+
85
+ // MARK: - Read (JXA)
86
+
87
+ static func read(file: String, sheet: String?, table: String?, range: String?) {
88
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
89
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
90
+ return
91
+ }
92
+ let escapedFile = escapeForJXA(resolvedFile)
93
+ let sheetSelector = sheet != nil ? "sheets.byName(\"\(escapeForJXA(sheet!))\")" : "sheets[0]"
94
+ let tableSelector = table != nil ? "tables.byName(\"\(escapeForJXA(table!))\")" : "tables[0]"
95
+
96
+ let rangeClause: String
97
+ if let range = range {
98
+ let parsed = parseA1Range(range)
99
+ rangeClause = """
100
+ var startRow = \(parsed.startRow); var startCol = \(parsed.startCol);
101
+ var endRow = \(parsed.endRow); var endCol = \(parsed.endCol);
102
+ """
103
+ } else {
104
+ rangeClause = """
105
+ var startRow = 0; var startCol = 0;
106
+ var endRow = tbl.rowCount() - 1; var endCol = tbl.columnCount() - 1;
107
+ """
108
+ }
109
+
110
+ let script = """
111
+ var app = Application("Numbers");
112
+ var doc = app.open(Path("\(escapedFile)"));
113
+ var sht = doc.\(sheetSelector);
114
+ var tbl = sht.\(tableSelector);
115
+ \(rangeClause)
116
+ var rows = [];
117
+ for (var r = startRow; r <= endRow; r++) {
118
+ var row = [];
119
+ for (var c = startCol; c <= endCol; c++) {
120
+ var cell = tbl.cells[r * tbl.columnCount() + c];
121
+ var val = cell.value();
122
+ row.push(val === null ? "" : val);
123
+ }
124
+ rows.push(row);
125
+ }
126
+ var result = JSON.stringify({
127
+ sheet: sht.name(),
128
+ table: tbl.name(),
129
+ rows: rows,
130
+ rowCount: endRow - startRow + 1,
131
+ columnCount: endCol - startCol + 1
132
+ });
133
+ doc.close({saving: "no"});
134
+ result;
135
+ """
136
+
137
+ guard let raw = runJXA(script) else { return }
138
+ guard let data = raw.data(using: .utf8),
139
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
140
+ JSONOutput.error("Failed to parse JXA JSON output")
141
+ return
142
+ }
143
+ JSONOutput.success(parsed)
144
+ }
145
+
146
+ // MARK: - Write (JXA)
147
+
148
+ static func write(file: String, sheet: String?, table: String?, range: String?, dataJSON: String) {
149
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
150
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
151
+ return
152
+ }
153
+ let escapedFile = escapeForJXA(resolvedFile)
154
+ let sheetSelector = sheet != nil ? "sheets.byName(\"\(escapeForJXA(sheet!))\")" : "sheets[0]"
155
+ let tableSelector = table != nil ? "tables.byName(\"\(escapeForJXA(table!))\")" : "tables[0]"
156
+
157
+ let startPos: String
158
+ if let range = range {
159
+ let parsed = parseA1Range(range)
160
+ startPos = "var startRow = \(parsed.startRow); var startCol = \(parsed.startCol);"
161
+ } else {
162
+ startPos = "var startRow = 0; var startCol = 0;"
163
+ }
164
+
165
+ let escapedData = escapeForJXA(dataJSON)
166
+
167
+ let script = """
168
+ var app = Application("Numbers");
169
+ var doc = app.open(Path("\(escapedFile)"));
170
+ var sht = doc.\(sheetSelector);
171
+ var tbl = sht.\(tableSelector);
172
+ \(startPos)
173
+ var data = JSON.parse("\(escapedData)");
174
+ var written = 0;
175
+ for (var r = 0; r < data.length; r++) {
176
+ var row = data[r];
177
+ for (var c = 0; c < row.length; c++) {
178
+ tbl.cells[(startRow + r) * tbl.columnCount() + (startCol + c)].value = row[c];
179
+ written++;
180
+ }
181
+ }
182
+ doc.close({saving: "yes"});
183
+ JSON.stringify({cellsWritten: written, rows: data.length, columns: data[0].length});
184
+ """
185
+
186
+ guard let raw = runJXA(script) else { return }
187
+ guard let data = raw.data(using: .utf8),
188
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
189
+ JSONOutput.error("Failed to parse JXA JSON output")
190
+ return
191
+ }
192
+ JSONOutput.success(parsed)
193
+ }
194
+
195
+ // MARK: - Get Formulas (JXA)
196
+
197
+ static func getFormulas(file: String, sheet: String?, table: String?, range: String?) {
198
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
199
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
200
+ return
201
+ }
202
+ let escapedFile = escapeForJXA(resolvedFile)
203
+ let sheetSelector = sheet != nil ? "sheets.byName(\"\(escapeForJXA(sheet!))\")" : "sheets[0]"
204
+ let tableSelector = table != nil ? "tables.byName(\"\(escapeForJXA(table!))\")" : "tables[0]"
205
+
206
+ let rangeClause: String
207
+ if let range = range {
208
+ let parsed = parseA1Range(range)
209
+ rangeClause = """
210
+ var startRow = \(parsed.startRow); var startCol = \(parsed.startCol);
211
+ var endRow = \(parsed.endRow); var endCol = \(parsed.endCol);
212
+ """
213
+ } else {
214
+ rangeClause = """
215
+ var startRow = 0; var startCol = 0;
216
+ var endRow = tbl.rowCount() - 1; var endCol = tbl.columnCount() - 1;
217
+ """
218
+ }
219
+
220
+ let script = """
221
+ var app = Application("Numbers");
222
+ var doc = app.open(Path("\(escapedFile)"));
223
+ var sht = doc.\(sheetSelector);
224
+ var tbl = sht.\(tableSelector);
225
+ \(rangeClause)
226
+ var rows = [];
227
+ for (var r = startRow; r <= endRow; r++) {
228
+ var row = [];
229
+ for (var c = startCol; c <= endCol; c++) {
230
+ var cell = tbl.cells[r * tbl.columnCount() + c];
231
+ var formula = cell.formula();
232
+ row.push(formula === null ? "" : formula);
233
+ }
234
+ rows.push(row);
235
+ }
236
+ var result = JSON.stringify({
237
+ sheet: sht.name(),
238
+ table: tbl.name(),
239
+ formulas: rows,
240
+ rowCount: endRow - startRow + 1,
241
+ columnCount: endCol - startCol + 1
242
+ });
243
+ doc.close({saving: "no"});
244
+ result;
245
+ """
246
+
247
+ guard let raw = runJXA(script) else { return }
248
+ guard let data = raw.data(using: .utf8),
249
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
250
+ JSONOutput.error("Failed to parse JXA JSON output")
251
+ return
252
+ }
253
+ JSONOutput.success(parsed)
254
+ }
255
+
256
+ // MARK: - Create
257
+
258
+ static func create(file: String, dataJSON: String?, template: String?) {
259
+ guard let resolvedFile = FilesBridge.validatePath(file, mustExist: false) else {
260
+ JSONOutput.error("Path is outside home directory: \(file)")
261
+ return
262
+ }
263
+ let escaped = escapeForAppleScript(resolvedFile)
264
+
265
+ let templateClause: String
266
+ if let template = template {
267
+ templateClause = "set doc to make new document with properties {document template:template \"\(escapeForAppleScript(template))\"}"
268
+ } else {
269
+ templateClause = "set doc to make new document"
270
+ }
271
+
272
+ let script = """
273
+ tell application "Numbers"
274
+ \(templateClause)
275
+ set docPath to POSIX file "\(escaped)"
276
+ save doc in docPath
277
+ close doc
278
+ return "ok"
279
+ end tell
280
+ """
281
+
282
+ guard let _ = runAppleScript(script) else { return }
283
+
284
+ if let dataJSON = dataJSON, !dataJSON.isEmpty {
285
+ write(file: resolvedFile, sheet: nil, table: nil, range: nil, dataJSON: dataJSON)
286
+ return
287
+ }
288
+
289
+ JSONOutput.success(["path": resolvedFile, "created": true])
290
+ }
291
+
292
+ // MARK: - Add Sheet
293
+
294
+ static func addSheet(file: String, name: String) {
295
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
296
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
297
+ return
298
+ }
299
+ let escapedFile = escapeForAppleScript(resolvedFile)
300
+ let escapedName = escapeForAppleScript(name)
301
+ let script = """
302
+ tell application "Numbers"
303
+ set doc to open POSIX file "\(escapedFile)"
304
+ tell doc
305
+ set newSheet to make new sheet with properties {name:"\(escapedName)"}
306
+ end tell
307
+ save doc
308
+ close doc
309
+ return "ok"
310
+ end tell
311
+ """
312
+
313
+ guard let _ = runAppleScript(script) else { return }
314
+ JSONOutput.success(["sheet": name, "added": true])
315
+ }
316
+
317
+ // MARK: - Remove Sheet
318
+
319
+ static func removeSheet(file: String, name: String) {
320
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
321
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
322
+ return
323
+ }
324
+ let escapedFile = escapeForAppleScript(resolvedFile)
325
+ let escapedName = escapeForAppleScript(name)
326
+ let script = """
327
+ tell application "Numbers"
328
+ set doc to open POSIX file "\(escapedFile)"
329
+ tell doc
330
+ delete sheet "\(escapedName)"
331
+ end tell
332
+ save doc
333
+ close doc
334
+ return "ok"
335
+ end tell
336
+ """
337
+
338
+ guard let _ = runAppleScript(script) else { return }
339
+ JSONOutput.success(["sheet": name, "removed": true])
340
+ }
341
+
342
+ // MARK: - Export
343
+
344
+ static func export(file: String, format: String, dest: String?) {
345
+ guard let resolvedFile = FilesBridge.validatePath(file) else {
346
+ JSONOutput.error("Path is outside home directory or does not exist: \(file)")
347
+ return
348
+ }
349
+ let escapedFile = escapeForAppleScript(resolvedFile)
350
+ let outputPath: String
351
+ if let dest = dest {
352
+ guard let resolvedDest = FilesBridge.validatePath(dest, mustExist: false) else {
353
+ JSONOutput.error("Destination path is outside home directory: \(dest)")
354
+ return
355
+ }
356
+ outputPath = resolvedDest
357
+ } else {
358
+ let ext: String
359
+ switch format {
360
+ case "csv": ext = "csv"
361
+ case "pdf": ext = "pdf"
362
+ case "xlsx": ext = "xlsx"
363
+ default: ext = format
364
+ }
365
+ outputPath = resolvedFile.replacingOccurrences(of: ".numbers", with: ".\(ext)")
366
+ }
367
+ let escapedOutput = escapeForAppleScript(outputPath)
368
+
369
+ let formatMap: [String: String] = [
370
+ "csv": "CSV",
371
+ "pdf": "PDF",
372
+ "xlsx": "Microsoft Excel"
373
+ ]
374
+ guard let numbersFormat = formatMap[format] else {
375
+ JSONOutput.error("Unsupported export format: \(format). Use csv, pdf, or xlsx.")
376
+ return
377
+ }
378
+
379
+ let script = """
380
+ tell application "Numbers"
381
+ set doc to open POSIX file "\(escapedFile)"
382
+ export doc to POSIX file "\(escapedOutput)" as \(numbersFormat)
383
+ close doc saving no
384
+ return "ok"
385
+ end tell
386
+ """
387
+
388
+ guard let _ = runAppleScript(script) else { return }
389
+ JSONOutput.success(["path": outputPath])
390
+ }
391
+
392
+ // MARK: - Search
393
+
394
+ static func search(query: String, limit: Int) {
395
+ let sanitized = query.replacingOccurrences(of: "'", with: "")
396
+ .replacingOccurrences(of: "\\", with: "")
397
+ let task = Process()
398
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/mdfind")
399
+ task.arguments = [
400
+ "kMDItemContentType == 'com.apple.iWork.numbers.sffnumbers' && kMDItemDisplayName == '*\(sanitized)*'cd"
401
+ ]
402
+
403
+ let pipe = Pipe()
404
+ task.standardOutput = pipe
405
+ task.standardError = Pipe()
406
+
407
+ do {
408
+ try task.run()
409
+ task.waitUntilExit()
410
+
411
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
412
+ guard let output = String(data: data, encoding: .utf8) else {
413
+ JSONOutput.success([])
414
+ return
415
+ }
416
+
417
+ let paths = output.components(separatedBy: "\n")
418
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
419
+ .filter { !$0.isEmpty }
420
+ .prefix(limit)
421
+
422
+ let fm = FileManager.default
423
+ let results: [[String: Any]] = paths.compactMap { path in
424
+ guard let attrs = try? fm.attributesOfItem(atPath: path) else { return nil }
425
+ var entry: [String: Any] = [
426
+ "path": path,
427
+ "name": URL(fileURLWithPath: path).lastPathComponent
428
+ ]
429
+ if let size = attrs[.size] as? Int {
430
+ entry["size"] = size
431
+ }
432
+ if let modified = attrs[.modificationDate] as? Date {
433
+ entry["modified"] = iso8601(modified)
434
+ }
435
+ return entry
436
+ }
437
+
438
+ JSONOutput.success(results)
439
+ } catch {
440
+ JSONOutput.error("Spotlight search failed: \(error.localizedDescription)")
441
+ }
442
+ }
443
+
444
+ // MARK: - AppleScript Execution
445
+
446
+ private static func runAppleScript(_ script: String) -> String? {
447
+ let task = Process()
448
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
449
+ task.arguments = ["-e", script]
450
+
451
+ let outPipe = Pipe()
452
+ let errPipe = Pipe()
453
+ task.standardOutput = outPipe
454
+ task.standardError = errPipe
455
+
456
+ do {
457
+ try task.run()
458
+ task.waitUntilExit()
459
+
460
+ if task.terminationStatus != 0 {
461
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
462
+ let errStr = String(data: errData, encoding: .utf8)?
463
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
464
+
465
+ if errStr.contains("-1743") || errStr.contains("not allowed") {
466
+ JSONOutput.error("Numbers automation permission denied. Grant access in System Settings > Privacy & Security > Automation.")
467
+ } else if errStr.contains("-600") || errStr.contains("not running") {
468
+ JSONOutput.error("Numbers is not running. It will be launched automatically on next attempt.")
469
+ } else {
470
+ JSONOutput.error("AppleScript error: \(errStr)")
471
+ }
472
+ return nil
473
+ }
474
+
475
+ let data = outPipe.fileHandleForReading.readDataToEndOfFile()
476
+ return String(data: data, encoding: .utf8)?
477
+ .trimmingCharacters(in: .whitespacesAndNewlines)
478
+ } catch {
479
+ JSONOutput.error("Failed to run osascript: \(error.localizedDescription)")
480
+ return nil
481
+ }
482
+ }
483
+
484
+ // MARK: - JXA Execution
485
+
486
+ private static func runJXA(_ script: String) -> String? {
487
+ let task = Process()
488
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
489
+ task.arguments = ["-l", "JavaScript", "-e", script]
490
+
491
+ let outPipe = Pipe()
492
+ let errPipe = Pipe()
493
+ task.standardOutput = outPipe
494
+ task.standardError = errPipe
495
+
496
+ do {
497
+ try task.run()
498
+ task.waitUntilExit()
499
+
500
+ if task.terminationStatus != 0 {
501
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
502
+ let errStr = String(data: errData, encoding: .utf8)?
503
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
504
+ JSONOutput.error("JXA error: \(errStr)")
505
+ return nil
506
+ }
507
+
508
+ let data = outPipe.fileHandleForReading.readDataToEndOfFile()
509
+ return String(data: data, encoding: .utf8)?
510
+ .trimmingCharacters(in: .whitespacesAndNewlines)
511
+ } catch {
512
+ JSONOutput.error("Failed to run osascript: \(error.localizedDescription)")
513
+ return nil
514
+ }
515
+ }
516
+
517
+ private static func escapeForAppleScript(_ str: String) -> String {
518
+ return str.replacingOccurrences(of: "\\", with: "\\\\")
519
+ .replacingOccurrences(of: "\"", with: "\\\"")
520
+ .replacingOccurrences(of: "\n", with: "\\n")
521
+ .replacingOccurrences(of: "\r", with: "\\r")
522
+ .replacingOccurrences(of: "\t", with: "\\t")
523
+ }
524
+
525
+ private static func escapeForJXA(_ str: String) -> String {
526
+ return str.replacingOccurrences(of: "\\", with: "\\\\")
527
+ .replacingOccurrences(of: "\"", with: "\\\"")
528
+ .replacingOccurrences(of: "\n", with: "\\n")
529
+ .replacingOccurrences(of: "\r", with: "\\r")
530
+ .replacingOccurrences(of: "\t", with: "\\t")
531
+ .replacingOccurrences(of: "\0", with: "")
532
+ .replacingOccurrences(of: "\u{2028}", with: "\\u2028")
533
+ .replacingOccurrences(of: "\u{2029}", with: "\\u2029")
534
+ }
535
+
536
+ // MARK: - A1 Range Parser
537
+
538
+ private struct CellRange {
539
+ let startRow: Int
540
+ let startCol: Int
541
+ let endRow: Int
542
+ let endCol: Int
543
+ }
544
+
545
+ private static func parseA1Range(_ range: String) -> CellRange {
546
+ let parts = range.uppercased().components(separatedBy: ":")
547
+ let start = parseA1Cell(parts[0])
548
+ let end = parts.count > 1 ? parseA1Cell(parts[1]) : start
549
+ return CellRange(startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col)
550
+ }
551
+
552
+ private static func parseA1Cell(_ cell: String) -> (row: Int, col: Int) {
553
+ var colStr = ""
554
+ var rowStr = ""
555
+ for char in cell {
556
+ if char.isLetter {
557
+ colStr.append(char)
558
+ } else {
559
+ rowStr.append(char)
560
+ }
561
+ }
562
+ var col = 0
563
+ for char in colStr {
564
+ col = col * 26 + Int(char.asciiValue! - 65) + 1
565
+ }
566
+ col -= 1
567
+ let row = (Int(rowStr) ?? 1) - 1
568
+ return (row, col)
569
+ }
570
+
571
+ // MARK: - Parsers
572
+
573
+ private static func parseSheetList(_ raw: String) -> [[String: Any]] {
574
+ guard !raw.isEmpty else { return [] }
575
+ let sheetChunks = raw.components(separatedBy: "###")
576
+ return sheetChunks.compactMap { chunk -> [String: Any]? in
577
+ let parts = chunk.components(separatedBy: "|||")
578
+ guard parts.count >= 1 else { return nil }
579
+
580
+ var sheet: [String: Any] = [
581
+ "name": parts[0].trimmingCharacters(in: .whitespaces)
582
+ ]
583
+
584
+ if parts.count > 1 && !parts[1].isEmpty {
585
+ let tableStrings = parts[1].components(separatedBy: "^^^")
586
+ let tables: [[String: Any]] = tableStrings.compactMap { tStr in
587
+ let fields = tStr.components(separatedBy: "::")
588
+ guard fields.count >= 3 else { return nil }
589
+ return [
590
+ "name": fields[0].trimmingCharacters(in: .whitespaces),
591
+ "rowCount": Int(fields[1].trimmingCharacters(in: .whitespaces)) ?? 0,
592
+ "columnCount": Int(fields[2].trimmingCharacters(in: .whitespaces)) ?? 0
593
+ ]
594
+ }
595
+ sheet["tables"] = tables
596
+ }
597
+
598
+ return sheet
599
+ }
600
+ }
601
+ }