@mattheworiordan/remi 0.1.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -0
  3. package/dist/cli/commands/add.d.ts +7 -0
  4. package/dist/cli/commands/add.js +36 -0
  5. package/dist/cli/commands/add.js.map +1 -0
  6. package/dist/cli/commands/authorize.d.ts +1 -0
  7. package/dist/cli/commands/authorize.js +75 -0
  8. package/dist/cli/commands/authorize.js.map +1 -0
  9. package/dist/cli/commands/complete.d.ts +3 -0
  10. package/dist/cli/commands/complete.js +9 -0
  11. package/dist/cli/commands/complete.js.map +1 -0
  12. package/dist/cli/commands/create-list.d.ts +1 -0
  13. package/dist/cli/commands/create-list.js +7 -0
  14. package/dist/cli/commands/create-list.js.map +1 -0
  15. package/dist/cli/commands/create-section.d.ts +1 -0
  16. package/dist/cli/commands/create-section.js +7 -0
  17. package/dist/cli/commands/create-section.js.map +1 -0
  18. package/dist/cli/commands/delete-list.d.ts +3 -0
  19. package/dist/cli/commands/delete-list.js +11 -0
  20. package/dist/cli/commands/delete-list.js.map +1 -0
  21. package/dist/cli/commands/delete-section.d.ts +1 -0
  22. package/dist/cli/commands/delete-section.js +7 -0
  23. package/dist/cli/commands/delete-section.js.map +1 -0
  24. package/dist/cli/commands/delete.d.ts +4 -0
  25. package/dist/cli/commands/delete.js +14 -0
  26. package/dist/cli/commands/delete.js.map +1 -0
  27. package/dist/cli/commands/doctor.d.ts +4 -0
  28. package/dist/cli/commands/doctor.js +180 -0
  29. package/dist/cli/commands/doctor.js.map +1 -0
  30. package/dist/cli/commands/list.d.ts +4 -0
  31. package/dist/cli/commands/list.js +11 -0
  32. package/dist/cli/commands/list.js.map +1 -0
  33. package/dist/cli/commands/lists.d.ts +1 -0
  34. package/dist/cli/commands/lists.js +7 -0
  35. package/dist/cli/commands/lists.js.map +1 -0
  36. package/dist/cli/commands/move.d.ts +3 -0
  37. package/dist/cli/commands/move.js +11 -0
  38. package/dist/cli/commands/move.js.map +1 -0
  39. package/dist/cli/commands/overdue.d.ts +1 -0
  40. package/dist/cli/commands/overdue.js +11 -0
  41. package/dist/cli/commands/overdue.js.map +1 -0
  42. package/dist/cli/commands/search.d.ts +1 -0
  43. package/dist/cli/commands/search.js +11 -0
  44. package/dist/cli/commands/search.js.map +1 -0
  45. package/dist/cli/commands/sections.d.ts +1 -0
  46. package/dist/cli/commands/sections.js +7 -0
  47. package/dist/cli/commands/sections.js.map +1 -0
  48. package/dist/cli/commands/today.d.ts +1 -0
  49. package/dist/cli/commands/today.js +11 -0
  50. package/dist/cli/commands/today.js.map +1 -0
  51. package/dist/cli/commands/upcoming.d.ts +3 -0
  52. package/dist/cli/commands/upcoming.js +12 -0
  53. package/dist/cli/commands/upcoming.js.map +1 -0
  54. package/dist/cli/commands/update.d.ts +7 -0
  55. package/dist/cli/commands/update.js +17 -0
  56. package/dist/cli/commands/update.js.map +1 -0
  57. package/dist/cli/index.d.ts +2 -0
  58. package/dist/cli/index.js +232 -0
  59. package/dist/cli/index.js.map +1 -0
  60. package/dist/cli/output.d.ts +26 -0
  61. package/dist/cli/output.js +234 -0
  62. package/dist/cli/output.js.map +1 -0
  63. package/dist/core/checksum.d.ts +9 -0
  64. package/dist/core/checksum.js +13 -0
  65. package/dist/core/checksum.js.map +1 -0
  66. package/dist/core/dateparse.d.ts +7 -0
  67. package/dist/core/dateparse.js +27 -0
  68. package/dist/core/dateparse.js.map +1 -0
  69. package/dist/core/errors.d.ts +26 -0
  70. package/dist/core/errors.js +34 -0
  71. package/dist/core/errors.js.map +1 -0
  72. package/dist/core/eventkit.d.ts +38 -0
  73. package/dist/core/eventkit.js +127 -0
  74. package/dist/core/eventkit.js.map +1 -0
  75. package/dist/core/lookup.d.ts +10 -0
  76. package/dist/core/lookup.js +32 -0
  77. package/dist/core/lookup.js.map +1 -0
  78. package/dist/core/membership.d.ts +30 -0
  79. package/dist/core/membership.js +92 -0
  80. package/dist/core/membership.js.map +1 -0
  81. package/dist/core/recurrence.d.ts +14 -0
  82. package/dist/core/recurrence.js +90 -0
  83. package/dist/core/recurrence.js.map +1 -0
  84. package/dist/core/reminderkit.d.ts +33 -0
  85. package/dist/core/reminderkit.js +154 -0
  86. package/dist/core/reminderkit.js.map +1 -0
  87. package/dist/core/sqlite.d.ts +65 -0
  88. package/dist/core/sqlite.js +175 -0
  89. package/dist/core/sqlite.js.map +1 -0
  90. package/dist/core/tokenmap.d.ts +29 -0
  91. package/dist/core/tokenmap.js +52 -0
  92. package/dist/core/tokenmap.js.map +1 -0
  93. package/dist/reminders-helper +0 -0
  94. package/dist/section-helper +0 -0
  95. package/dist/types.d.ts +81 -0
  96. package/dist/types.js +2 -0
  97. package/dist/types.js.map +1 -0
  98. package/package.json +63 -0
  99. package/src/swift/Info.plist +16 -0
  100. package/src/swift/build.sh +47 -0
  101. package/src/swift/reminders-helper.swift +697 -0
  102. package/src/swift/section-helper.swift +794 -0
@@ -0,0 +1,794 @@
1
+ /**
2
+ * remi - Section & Database Helper (compiled binary)
3
+ *
4
+ * This binary handles all operations requiring:
5
+ * 1. ReminderKit (private framework) — section CRUD with CloudKit sync
6
+ * 2. Direct SQLite access — membership sync, database queries
7
+ * 3. EventKit sync trigger — wake remindd after SQLite writes
8
+ *
9
+ * By running these in a compiled binary with an embedded Info.plist,
10
+ * macOS attributes permissions to "remi" rather than the terminal app.
11
+ * This means users grant permission to remi once, not to each terminal.
12
+ *
13
+ * Usage: section-helper <command> [args...]
14
+ *
15
+ * Section commands:
16
+ * list-sections <listName>
17
+ * create-section <listName> <sectionName>
18
+ * delete-section <listName> <sectionName>
19
+ * trigger-sync <listName>
20
+ *
21
+ * Database commands:
22
+ * db-find-db Find Reminders database path
23
+ * db-stats List/section/reminder counts
24
+ * db-find-list <listName> Find list Z_PK
25
+ * db-find-reminder <title> <listName> Find reminder Z_PK + hex UUID
26
+ * db-find-section <sectionName> <listName> Find section Z_PK + hex UUID
27
+ * db-read-memberships <listName> Read membership JSON
28
+ * db-read-tokenmap <listName> Read token map JSON
29
+ * db-write-membership-sync <listName> <membershipJSON> Atomic write + sync
30
+ *
31
+ * All output is JSON.
32
+ */
33
+
34
+ import Foundation
35
+ import ObjectiveC
36
+ import EventKit
37
+ import SQLite3
38
+ import CommonCrypto
39
+
40
+ // MARK: - Helpers
41
+
42
+ func outputJSON(_ dict: [String: Any]) {
43
+ if let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
44
+ let str = String(data: data, encoding: .utf8) {
45
+ print(str)
46
+ }
47
+ }
48
+
49
+ func callWithError(_ obj: NSObject, _ selName: String, _ args: [AnyObject] = []) -> (AnyObject?, NSError?) {
50
+ let sel = NSSelectorFromString(selName)
51
+ guard let method = class_getInstanceMethod(type(of: obj), sel) else {
52
+ return (nil, NSError(domain: "REMBridge", code: 1,
53
+ userInfo: [NSLocalizedDescriptionKey: "Method \(selName) not found on \(type(of: obj))"]))
54
+ }
55
+ let imp = method_getImplementation(method)
56
+ var error: NSError?
57
+ let result: AnyObject?
58
+
59
+ switch args.count {
60
+ case 0:
61
+ let fn = unsafeBitCast(imp, to: (@convention(c) (AnyObject, Selector, UnsafeMutablePointer<NSError?>) -> AnyObject?).self)
62
+ result = fn(obj, sel, &error)
63
+ case 1:
64
+ let fn = unsafeBitCast(imp, to: (@convention(c) (AnyObject, Selector, AnyObject, UnsafeMutablePointer<NSError?>) -> AnyObject?).self)
65
+ result = fn(obj, sel, args[0], &error)
66
+ default:
67
+ result = nil
68
+ }
69
+ return (result, error)
70
+ }
71
+
72
+ // MARK: - ReminderKit Bridge
73
+
74
+ class REMBridge {
75
+ let store: NSObject
76
+ let acctView: NSObject
77
+ let listsView: NSObject
78
+ let sectionsView: NSObject
79
+
80
+ init?() {
81
+ guard let bundle = Bundle(path: "/System/Library/PrivateFrameworks/ReminderKit.framework") else {
82
+ return nil
83
+ }
84
+ bundle.load()
85
+
86
+ guard let storeClass = NSClassFromString("REMStore") as? NSObject.Type,
87
+ let acctViewClass = NSClassFromString("REMAccountsDataView") as? NSObject.Type,
88
+ let listsViewClass = NSClassFromString("REMListsDataView") as? NSObject.Type,
89
+ let sectionsViewClass = NSClassFromString("REMListSectionsDataView") as? NSObject.Type else {
90
+ return nil
91
+ }
92
+
93
+ store = storeClass.init()
94
+
95
+ acctView = acctViewClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
96
+ _ = acctView.perform(NSSelectorFromString("initWithStore:"), with: store)
97
+
98
+ listsView = listsViewClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
99
+ _ = listsView.perform(NSSelectorFromString("initWithStore:"), with: store)
100
+
101
+ sectionsView = sectionsViewClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
102
+ _ = sectionsView.perform(NSSelectorFromString("initWithStore:"), with: store)
103
+ }
104
+
105
+ func findList(_ name: String) -> (storage: NSObject, account: NSObject)? {
106
+ let (accounts, _) = callWithError(acctView, "fetchAllAccountsWithError:")
107
+ guard let acctArray = accounts as? NSArray else { return nil }
108
+
109
+ for acct in acctArray {
110
+ let acctObj = acct as! NSObject
111
+ let (lists, _) = callWithError(listsView, "fetchListsInAccount:error:", [acctObj])
112
+ if let listArray = lists as? NSArray {
113
+ for list in listArray {
114
+ let listObj = list as! NSObject
115
+ if (listObj.value(forKey: "displayName") as? String) == name {
116
+ return (listObj, acctObj)
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return nil
122
+ }
123
+
124
+ func listSections(_ listName: String) -> [[String: String]] {
125
+ guard let (listStorage, _) = findList(listName) else { return [] }
126
+ let (sections, _) = callWithError(sectionsView, "fetchListSectionsInList:error:", [listStorage])
127
+ guard let secArray = sections as? NSArray else { return [] }
128
+
129
+ return secArray.map { sec in
130
+ let secObj = sec as! NSObject
131
+ return [
132
+ "name": secObj.value(forKey: "displayName") as? String ?? "",
133
+ "objectID": secObj.value(forKey: "objectID") as? String ?? "",
134
+ ]
135
+ }
136
+ }
137
+
138
+ /// Creates a section. Outputs its own JSON in all cases (idempotent, error, success).
139
+ func createSection(_ listName: String, _ sectionName: String) {
140
+ guard let (listStorage, account) = findList(listName) else {
141
+ outputJSON(["success": false, "error": "List '\(listName)' not found"])
142
+ return
143
+ }
144
+
145
+ // Idempotent: return success if section already exists
146
+ let existing = listSections(listName)
147
+ if existing.contains(where: { $0["name"] == sectionName }) {
148
+ outputJSON(["success": true, "message": "Section '\(sectionName)' already exists in '\(listName)'"])
149
+ return
150
+ }
151
+
152
+ let acctCaps = account.value(forKey: "capabilities") as AnyObject?
153
+
154
+ guard let saveReqClass = NSClassFromString("REMSaveRequest") as? NSObject.Type,
155
+ let listCIClass = NSClassFromString("REMListChangeItem") as? NSObject.Type else {
156
+ outputJSON(["success": false, "error": "Required ReminderKit classes not found"])
157
+ return
158
+ }
159
+
160
+ let saveReq = saveReqClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
161
+ _ = saveReq.perform(NSSelectorFromString("initWithStore:"), with: store)
162
+
163
+ let listCI = listCIClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
164
+
165
+ let initSel = NSSelectorFromString("initWithSaveRequest:storage:accountCapabilities:observeInitialValues:")
166
+ guard let initMethod = class_getInstanceMethod(listCIClass, initSel) else {
167
+ outputJSON(["success": false, "error": "initWithSaveRequest method not found"])
168
+ return
169
+ }
170
+ let initFn = unsafeBitCast(method_getImplementation(initMethod),
171
+ to: (@convention(c) (AnyObject, Selector, AnyObject, AnyObject, AnyObject?, Bool) -> AnyObject).self)
172
+ let initializedCI = initFn(listCI, initSel, saveReq, listStorage, acctCaps, false) as! NSObject
173
+
174
+ guard let sectionsCtx = initializedCI.perform(
175
+ NSSelectorFromString("sectionsContextChangeItem"))?.takeUnretainedValue() as? NSObject else {
176
+ outputJSON(["success": false, "error": "Could not get sections context"])
177
+ return
178
+ }
179
+
180
+ _ = saveReq.perform(
181
+ NSSelectorFromString("addListSectionWithDisplayName:toListSectionContextChangeItem:"),
182
+ with: sectionName as NSString,
183
+ with: sectionsCtx
184
+ )
185
+
186
+ if save(saveReq) {
187
+ outputJSON(["success": true, "message": "Created section '\(sectionName)' in '\(listName)'"])
188
+ }
189
+ }
190
+
191
+ func deleteSection(_ listName: String, _ sectionName: String) -> Bool {
192
+ guard let (listStorage, _) = findList(listName) else {
193
+ outputJSON(["success": false, "error": "List '\(listName)' not found"])
194
+ return false
195
+ }
196
+
197
+ let (sections, _) = callWithError(sectionsView, "fetchListSectionsInList:error:", [listStorage])
198
+ guard let secArray = sections as? NSArray else {
199
+ outputJSON(["success": false, "error": "No sections found"])
200
+ return false
201
+ }
202
+
203
+ var targetSection: NSObject?
204
+ for sec in secArray {
205
+ let secObj = sec as! NSObject
206
+ if (secObj.value(forKey: "displayName") as? String) == sectionName {
207
+ targetSection = secObj
208
+ break
209
+ }
210
+ }
211
+
212
+ guard let section = targetSection else {
213
+ outputJSON(["success": false, "error": "Section '\(sectionName)' not found in '\(listName)'"])
214
+ return false
215
+ }
216
+
217
+ guard let saveReqClass = NSClassFromString("REMSaveRequest") as? NSObject.Type else {
218
+ outputJSON(["success": false, "error": "REMSaveRequest not found"])
219
+ return false
220
+ }
221
+
222
+ let saveReq = saveReqClass.perform(NSSelectorFromString("alloc"))!.takeUnretainedValue() as! NSObject
223
+ _ = saveReq.perform(NSSelectorFromString("initWithStore:"), with: store)
224
+
225
+ // Must use updateListSection: for properly tracked deletions that sync via CloudKit
226
+ guard let sectionCI = saveReq.perform(
227
+ NSSelectorFromString("updateListSection:"), with: section
228
+ )?.takeUnretainedValue() as? NSObject else {
229
+ outputJSON(["success": false, "error": "Failed to get tracked section change item"])
230
+ return false
231
+ }
232
+
233
+ sectionCI.perform(NSSelectorFromString("removeFromList"))
234
+
235
+ return save(saveReq)
236
+ }
237
+
238
+ /// Trigger a CloudKit sync cycle by making a trivial EventKit edit.
239
+ ///
240
+ /// After membership data is written to SQLite with updated token maps (done by TypeScript),
241
+ /// we need remindd to wake up and run a push cycle. Toggling a trailing space on a
242
+ /// reminder's notes field triggers this without visually affecting the reminder.
243
+ func triggerSync(_ listName: String) -> Bool {
244
+ let eventStore = EKEventStore()
245
+ let semaphore = DispatchSemaphore(value: 0)
246
+ var accessGranted = false
247
+
248
+ eventStore.requestFullAccessToReminders { granted, _ in
249
+ accessGranted = granted
250
+ semaphore.signal()
251
+ }
252
+ semaphore.wait()
253
+
254
+ guard accessGranted else {
255
+ outputJSON(["success": false, "error": "Reminders access denied"])
256
+ return false
257
+ }
258
+
259
+ let calendars = eventStore.calendars(for: .reminder)
260
+ guard let calendar = calendars.first(where: { $0.title == listName }) else {
261
+ outputJSON(["success": false, "error": "List '\(listName)' not found via EventKit"])
262
+ return false
263
+ }
264
+
265
+ let predicate = eventStore.predicateForIncompleteReminders(
266
+ withDueDateStarting: nil, ending: nil, calendars: [calendar])
267
+ var reminders: [EKReminder]?
268
+ let fetchSemaphore = DispatchSemaphore(value: 0)
269
+ eventStore.fetchReminders(matching: predicate) { result in
270
+ reminders = result
271
+ fetchSemaphore.signal()
272
+ }
273
+ fetchSemaphore.wait()
274
+
275
+ guard let reminder = reminders?.first else {
276
+ outputJSON(["success": true,
277
+ "message": "No incomplete reminders to trigger sync — data will sync on next natural edit",
278
+ "warning": "no_reminders_for_sync_trigger"])
279
+ return true
280
+ }
281
+
282
+ // Toggle trailing space on notes to trigger sync
283
+ let currentNotes = reminder.notes ?? ""
284
+ if currentNotes.hasSuffix(" ") {
285
+ reminder.notes = String(currentNotes.dropLast())
286
+ } else {
287
+ reminder.notes = currentNotes + " "
288
+ }
289
+
290
+ do {
291
+ try eventStore.save(reminder, commit: true)
292
+ return true
293
+ } catch {
294
+ outputJSON(["success": false, "error": "Sync trigger save failed: \(error.localizedDescription)"])
295
+ return false
296
+ }
297
+ }
298
+
299
+ private func save(_ saveReq: NSObject) -> Bool {
300
+ let saveSel = NSSelectorFromString("saveSynchronouslyWithError:")
301
+ let saveMethod = class_getInstanceMethod(type(of: saveReq), saveSel)!
302
+ let saveFn = unsafeBitCast(method_getImplementation(saveMethod),
303
+ to: (@convention(c) (AnyObject, Selector, UnsafeMutablePointer<NSError?>) -> Bool).self)
304
+ var saveError: NSError?
305
+ let success = saveFn(saveReq, saveSel, &saveError)
306
+
307
+ if !success {
308
+ outputJSON(["success": false, "error": "Save failed: \(String(describing: saveError))"])
309
+ }
310
+ return success
311
+ }
312
+ }
313
+
314
+ // MARK: - Database Helper (SQLite C API for proper transactions)
315
+
316
+ class DBHelper {
317
+ /// Find the active Reminders SQLite database
318
+ static func findDb() -> String? {
319
+ let home = NSHomeDirectory()
320
+ let storesDir = "\(home)/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores"
321
+ let fm = FileManager.default
322
+
323
+ guard fm.fileExists(atPath: storesDir),
324
+ let files = try? fm.contentsOfDirectory(atPath: storesDir) else {
325
+ return nil
326
+ }
327
+
328
+ let sqliteFiles = files.filter { $0.hasSuffix(".sqlite") && !$0.contains("-wal") && !$0.contains("-shm") }
329
+
330
+ for file in sqliteFiles {
331
+ let dbPath = "\(storesDir)/\(file)"
332
+ var db: OpaquePointer?
333
+ guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { continue }
334
+ defer { sqlite3_close(db) }
335
+
336
+ var stmt: OpaquePointer?
337
+ guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM ZREMCDREMINDER", -1, &stmt, nil) == SQLITE_OK else { continue }
338
+ defer { sqlite3_finalize(stmt) }
339
+
340
+ if sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_int(stmt, 0) > 0 {
341
+ return dbPath
342
+ }
343
+ }
344
+ return nil
345
+ }
346
+
347
+ /// Open a database connection with WAL mode and busy timeout
348
+ static func openDb(_ path: String) -> OpaquePointer? {
349
+ var db: OpaquePointer?
350
+ guard sqlite3_open(path, &db) == SQLITE_OK else { return nil }
351
+ sqlite3_busy_timeout(db, 5000)
352
+ sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
353
+ return db
354
+ }
355
+
356
+ /// Get database stats
357
+ static func stats() {
358
+ guard let dbPath = findDb() else {
359
+ outputJSON(["success": false, "error": "Reminders database not found"])
360
+ return
361
+ }
362
+ guard let db = openDb(dbPath) else {
363
+ outputJSON(["success": false, "error": "Cannot open database"])
364
+ return
365
+ }
366
+ defer { sqlite3_close(db) }
367
+
368
+ func count(_ table: String) -> Int {
369
+ var stmt: OpaquePointer?
370
+ let sql = "SELECT COUNT(*) FROM \(table) WHERE ZMARKEDFORDELETION = 0"
371
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
372
+ defer { sqlite3_finalize(stmt) }
373
+ return sqlite3_step(stmt) == SQLITE_ROW ? Int(sqlite3_column_int(stmt, 0)) : 0
374
+ }
375
+
376
+ outputJSON([
377
+ "success": true,
378
+ "dbPath": dbPath,
379
+ "lists": count("ZREMCDBASELIST"),
380
+ "sections": count("ZREMCDBASESECTION"),
381
+ "reminders": count("ZREMCDREMINDER"),
382
+ ])
383
+ }
384
+
385
+ /// Find a list's Z_PK by name
386
+ static func findList(_ listName: String) -> Int? {
387
+ guard let dbPath = findDb(), let db = openDb(dbPath) else { return nil }
388
+ defer { sqlite3_close(db) }
389
+
390
+ var stmt: OpaquePointer?
391
+ let sql = "SELECT Z_PK FROM ZREMCDBASELIST WHERE ZNAME = ? AND ZMARKEDFORDELETION = 0"
392
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
393
+ defer { sqlite3_finalize(stmt) }
394
+
395
+ sqlite3_bind_text(stmt, 1, (listName as NSString).utf8String, -1, nil)
396
+ return sqlite3_step(stmt) == SQLITE_ROW ? Int(sqlite3_column_int(stmt, 0)) : nil
397
+ }
398
+
399
+ /// Find a reminder's Z_PK and hex UUID by title and list name
400
+ static func findReminder(_ title: String, _ listName: String) {
401
+ guard let dbPath = findDb(), let db = openDb(dbPath) else {
402
+ outputJSON(["success": false, "error": "Database not found"])
403
+ return
404
+ }
405
+ defer { sqlite3_close(db) }
406
+
407
+ var stmt: OpaquePointer?
408
+ let sql = """
409
+ SELECT r.Z_PK, hex(r.ZIDENTIFIER) FROM ZREMCDREMINDER r
410
+ JOIN ZREMCDBASELIST l ON r.ZLIST = l.Z_PK
411
+ WHERE r.ZTITLE = ? AND l.ZNAME = ? AND r.ZCOMPLETED = 0 AND r.ZMARKEDFORDELETION = 0
412
+ ORDER BY r.ZCREATIONDATE DESC LIMIT 1
413
+ """
414
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
415
+ outputJSON(["success": false, "error": "Query failed"])
416
+ return
417
+ }
418
+ defer { sqlite3_finalize(stmt) }
419
+
420
+ sqlite3_bind_text(stmt, 1, (title as NSString).utf8String, -1, nil)
421
+ sqlite3_bind_text(stmt, 2, (listName as NSString).utf8String, -1, nil)
422
+
423
+ if sqlite3_step(stmt) == SQLITE_ROW {
424
+ let pk = Int(sqlite3_column_int(stmt, 0))
425
+ let identifier = String(cString: sqlite3_column_text(stmt, 1))
426
+ outputJSON(["success": true, "pk": pk, "identifier": identifier])
427
+ } else {
428
+ outputJSON(["success": false, "error": "Reminder '\(title)' not found in '\(listName)'"])
429
+ }
430
+ }
431
+
432
+ /// Find a section's Z_PK and hex UUID
433
+ static func findSection(_ sectionName: String, _ listName: String) {
434
+ guard let dbPath = findDb(), let db = openDb(dbPath) else {
435
+ outputJSON(["success": false, "error": "Database not found"])
436
+ return
437
+ }
438
+ defer { sqlite3_close(db) }
439
+
440
+ var stmt: OpaquePointer?
441
+ let sql = """
442
+ SELECT s.Z_PK, hex(s.ZIDENTIFIER) FROM ZREMCDBASESECTION s
443
+ JOIN ZREMCDBASELIST l ON s.ZLIST = l.Z_PK
444
+ WHERE s.ZDISPLAYNAME = ? AND l.ZNAME = ? AND s.ZMARKEDFORDELETION = 0
445
+ """
446
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
447
+ outputJSON(["success": false, "error": "Query failed"])
448
+ return
449
+ }
450
+ defer { sqlite3_finalize(stmt) }
451
+
452
+ sqlite3_bind_text(stmt, 1, (sectionName as NSString).utf8String, -1, nil)
453
+ sqlite3_bind_text(stmt, 2, (listName as NSString).utf8String, -1, nil)
454
+
455
+ if sqlite3_step(stmt) == SQLITE_ROW {
456
+ let pk = Int(sqlite3_column_int(stmt, 0))
457
+ let identifier = String(cString: sqlite3_column_text(stmt, 1))
458
+ outputJSON(["success": true, "pk": pk, "identifier": identifier])
459
+ } else {
460
+ outputJSON(["success": false, "error": "Section '\(sectionName)' not found in '\(listName)'"])
461
+ }
462
+ }
463
+
464
+ /// Read membership data JSON for a list
465
+ static func readMemberships(_ listName: String) {
466
+ guard let dbPath = findDb(), let db = openDb(dbPath) else {
467
+ outputJSON(["success": false, "error": "Database not found"])
468
+ return
469
+ }
470
+ defer { sqlite3_close(db) }
471
+
472
+ guard let listPk = findList(listName) else {
473
+ outputJSON(["success": false, "error": "List '\(listName)' not found"])
474
+ return
475
+ }
476
+
477
+ var stmt: OpaquePointer?
478
+ let sql = "SELECT cast(ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA as text) FROM ZREMCDBASELIST WHERE Z_PK = ?"
479
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
480
+ outputJSON(["success": false, "error": "Query failed"])
481
+ return
482
+ }
483
+ defer { sqlite3_finalize(stmt) }
484
+
485
+ sqlite3_bind_int(stmt, 1, Int32(listPk))
486
+
487
+ if sqlite3_step(stmt) == SQLITE_ROW, let text = sqlite3_column_text(stmt, 0) {
488
+ outputJSON(["success": true, "data": String(cString: text)])
489
+ } else {
490
+ outputJSON(["success": true, "data": NSNull()])
491
+ }
492
+ }
493
+
494
+ /// Read token map JSON for a list
495
+ static func readTokenMap(_ listName: String) {
496
+ guard let dbPath = findDb(), let db = openDb(dbPath) else {
497
+ outputJSON(["success": false, "error": "Database not found"])
498
+ return
499
+ }
500
+ defer { sqlite3_close(db) }
501
+
502
+ guard let listPk = findList(listName) else {
503
+ outputJSON(["success": false, "error": "List '\(listName)' not found"])
504
+ return
505
+ }
506
+
507
+ var stmt: OpaquePointer?
508
+ let sql = "SELECT cast(ZRESOLUTIONTOKENMAP_V3_JSONDATA as text) FROM ZREMCDBASELIST WHERE Z_PK = ?"
509
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
510
+ outputJSON(["success": false, "error": "Query failed"])
511
+ return
512
+ }
513
+ defer { sqlite3_finalize(stmt) }
514
+
515
+ sqlite3_bind_int(stmt, 1, Int32(listPk))
516
+
517
+ if sqlite3_step(stmt) == SQLITE_ROW, let text = sqlite3_column_text(stmt, 0) {
518
+ outputJSON(["success": true, "data": String(cString: text)])
519
+ } else {
520
+ outputJSON(["success": true, "data": NSNull()])
521
+ }
522
+ }
523
+
524
+ /// Full atomic membership sync: write data + checksum + token map in one transaction, then trigger sync.
525
+ /// This is the key operation — the three writes MUST be atomic to prevent remindd detecting corruption.
526
+ static func writeMembershipSync(_ listName: String, _ membershipJSON: String, bridge: REMBridge) {
527
+ guard let dbPath = findDb(), let db = openDb(dbPath) else {
528
+ outputJSON(["success": false, "error": "Database not found"])
529
+ return
530
+ }
531
+ // Note: we close db explicitly before sync trigger, not via defer
532
+
533
+ guard let listPk = findList(listName) else {
534
+ outputJSON(["success": false, "error": "List '\(listName)' not found in database"])
535
+ return
536
+ }
537
+
538
+ // Compute SHA-512 checksum
539
+ let data = membershipJSON.data(using: .utf8)!
540
+ var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
541
+ data.withUnsafeBytes { ptr in
542
+ _ = CC_SHA512(ptr.baseAddress, CC_LONG(data.count), &digest)
543
+ }
544
+ let checksumHex = digest.map { String(format: "%02x", $0) }.joined()
545
+
546
+ // Read current token map and increment counter
547
+ let coreDataTimestamp = Date().timeIntervalSinceReferenceDate
548
+ var tokenMap: [String: Any] = [:]
549
+
550
+ var readStmt: OpaquePointer?
551
+ let readSql = "SELECT cast(ZRESOLUTIONTOKENMAP_V3_JSONDATA as text) FROM ZREMCDBASELIST WHERE Z_PK = ?"
552
+ if sqlite3_prepare_v2(db, readSql, -1, &readStmt, nil) == SQLITE_OK {
553
+ sqlite3_bind_int(readStmt, 1, Int32(listPk))
554
+ if sqlite3_step(readStmt) == SQLITE_ROW, let text = sqlite3_column_text(readStmt, 0) {
555
+ let rawStr = String(cString: text)
556
+ if let jsonData = rawStr.data(using: .utf8),
557
+ let parsed = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
558
+ tokenMap = parsed
559
+ }
560
+ }
561
+ sqlite3_finalize(readStmt)
562
+ }
563
+
564
+ // Token map entries live INSIDE the "map" key, and each needs a replicaID
565
+ let fieldKey = "membershipsOfRemindersInSectionsChecksum"
566
+ var mapDict = tokenMap["map"] as? [String: Any] ?? [:]
567
+
568
+ var currentCounter = 0
569
+ var replicaID = UUID().uuidString.uppercased()
570
+ if let existing = mapDict[fieldKey] as? [String: Any] {
571
+ if let counter = existing["counter"] as? Int {
572
+ currentCounter = counter
573
+ }
574
+ // Preserve existing replicaID if present
575
+ if let existingReplicaID = existing["replicaID"] as? String {
576
+ replicaID = existingReplicaID
577
+ }
578
+ }
579
+
580
+ mapDict[fieldKey] = [
581
+ "counter": currentCounter + 1,
582
+ "modificationTime": coreDataTimestamp,
583
+ "replicaID": replicaID
584
+ ]
585
+
586
+ // Also increment reminderIDsMergeableOrdering — remindd appears to use this
587
+ // as the trigger to push the full record including membership data.
588
+ // Without this, membership-only changes don't propagate to other devices.
589
+ let orderingKey = "reminderIDsMergeableOrdering"
590
+ if var orderingEntry = mapDict[orderingKey] as? [String: Any],
591
+ let orderingCounter = orderingEntry["counter"] as? Int {
592
+ orderingEntry["counter"] = orderingCounter + 1
593
+ orderingEntry["modificationTime"] = coreDataTimestamp
594
+ mapDict[orderingKey] = orderingEntry
595
+ }
596
+
597
+ tokenMap["map"] = mapDict
598
+
599
+ guard let tokenMapData = try? JSONSerialization.data(withJSONObject: tokenMap, options: [.sortedKeys]),
600
+ let tokenMapJSON = String(data: tokenMapData, encoding: .utf8) else {
601
+ outputJSON(["success": false, "error": "Failed to serialize token map"])
602
+ return
603
+ }
604
+
605
+ // ATOMIC TRANSACTION: write data + checksum + token map
606
+ guard sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil) == SQLITE_OK else {
607
+ outputJSON(["success": false, "error": "Failed to begin transaction"])
608
+ return
609
+ }
610
+
611
+ var success = true
612
+ let updates: [(String, String)] = [
613
+ ("UPDATE ZREMCDBASELIST SET ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA = ? WHERE Z_PK = ?", membershipJSON),
614
+ ("UPDATE ZREMCDBASELIST SET ZMEMBERSHIPSOFREMINDERSINSECTIONSCHECKSUM = ? WHERE Z_PK = ?", checksumHex),
615
+ ("UPDATE ZREMCDBASELIST SET ZRESOLUTIONTOKENMAP_V3_JSONDATA = ? WHERE Z_PK = ?", tokenMapJSON),
616
+ ]
617
+
618
+ for (sql, value) in updates {
619
+ var updateStmt: OpaquePointer?
620
+ guard sqlite3_prepare_v2(db, sql, -1, &updateStmt, nil) == SQLITE_OK else {
621
+ success = false; break
622
+ }
623
+ sqlite3_bind_text(updateStmt, 1, (value as NSString).utf8String, -1, nil)
624
+ sqlite3_bind_int(updateStmt, 2, Int32(listPk))
625
+ if sqlite3_step(updateStmt) != SQLITE_DONE { success = false }
626
+ sqlite3_finalize(updateStmt)
627
+ if !success { break }
628
+ }
629
+
630
+ if success {
631
+ sqlite3_exec(db, "COMMIT", nil, nil, nil)
632
+ } else {
633
+ sqlite3_exec(db, "ROLLBACK", nil, nil, nil)
634
+ outputJSON(["success": false, "error": "Failed to write membership data"])
635
+ return
636
+ }
637
+
638
+ // CRITICAL: Close the database BEFORE triggering sync.
639
+ // remindd needs to read our WAL changes, which requires the connection to be closed
640
+ // so the WAL is checkpointed and visible to other processes.
641
+ sqlite3_close(db)
642
+
643
+ // Small delay to ensure WAL checkpoint completes
644
+ Thread.sleep(forTimeInterval: 0.5)
645
+
646
+ // Trigger sync via EventKit (don't output from triggerSync — we handle output here)
647
+ let eventStore = EKEventStore()
648
+ let syncSem = DispatchSemaphore(value: 0)
649
+ var syncGranted = false
650
+ eventStore.requestFullAccessToReminders { granted, _ in
651
+ syncGranted = granted
652
+ syncSem.signal()
653
+ }
654
+ syncSem.wait()
655
+
656
+ var syncWarning: String? = nil
657
+ if syncGranted {
658
+ let calendars = eventStore.calendars(for: .reminder)
659
+ if let calendar = calendars.first(where: { $0.title == listName }) {
660
+ // Trigger a LIST-level sync by creating and immediately deleting a
661
+ // temporary reminder. This forces remindd to update the list's
662
+ // reminderIDsMergeableOrdering, which triggers a full list record push
663
+ // including our membership data.
664
+ //
665
+ // Why not just edit a reminder's notes? That only triggers a reminder-level
666
+ // push, not a list-level push. remindd doesn't check the list's token map
667
+ // when only a reminder changed.
668
+ let tempReminder = EKReminder(eventStore: eventStore)
669
+ tempReminder.title = "_remi_sync_trigger_\(UUID().uuidString.prefix(8))"
670
+ tempReminder.calendar = calendar
671
+ do {
672
+ try eventStore.save(tempReminder, commit: true)
673
+ // Small delay to ensure remindd processes the creation
674
+ Thread.sleep(forTimeInterval: 0.3)
675
+ try eventStore.remove(tempReminder, commit: true)
676
+ } catch {
677
+ syncWarning = "Sync trigger failed: \(error.localizedDescription)"
678
+ }
679
+ } else {
680
+ syncWarning = "List not found via EventKit for sync trigger"
681
+ }
682
+ } else {
683
+ syncWarning = "Reminders access denied for sync trigger"
684
+ }
685
+
686
+ if let warning = syncWarning {
687
+ outputJSON(["success": true, "message": "Memberships written for '\(listName)'", "warning": warning])
688
+ } else {
689
+ outputJSON(["success": true, "message": "Memberships written and sync triggered for '\(listName)'"])
690
+ }
691
+ }
692
+ }
693
+
694
+ // MARK: - Main
695
+
696
+ guard let bridge = REMBridge() else {
697
+ outputJSON(["success": false, "error": "Failed to initialize ReminderKit bridge. Is ReminderKit available on this macOS version?"])
698
+ exit(1)
699
+ }
700
+
701
+ let args = CommandLine.arguments
702
+ guard args.count >= 2 else {
703
+ outputJSON(["success": false, "error": "Usage: section-helper <command> [args...]\nCommands: list-sections, create-section, delete-section, trigger-sync"])
704
+ exit(1)
705
+ }
706
+
707
+ let command = args[1]
708
+
709
+ switch command {
710
+ case "list-sections":
711
+ guard args.count >= 3 else {
712
+ outputJSON(["success": false, "error": "Usage: list-sections <listName>"]); exit(1)
713
+ }
714
+ let sections = bridge.listSections(args[2])
715
+ outputJSON(["success": true, "sections": sections])
716
+
717
+ case "create-section":
718
+ guard args.count >= 4 else {
719
+ outputJSON(["success": false, "error": "Usage: create-section <listName> <sectionName>"]); exit(1)
720
+ }
721
+ bridge.createSection(args[2], args[3])
722
+
723
+ case "delete-section":
724
+ guard args.count >= 4 else {
725
+ outputJSON(["success": false, "error": "Usage: delete-section <listName> <sectionName>"]); exit(1)
726
+ }
727
+ // deleteSection outputs its own error JSON internally
728
+ if bridge.deleteSection(args[2], args[3]) {
729
+ outputJSON(["success": true, "message": "Deleted section '\(args[3])' from '\(args[2])'"])
730
+ }
731
+
732
+ case "trigger-sync":
733
+ guard args.count >= 3 else {
734
+ outputJSON(["success": false, "error": "Usage: trigger-sync <listName>"]); exit(1)
735
+ }
736
+ if bridge.triggerSync(args[2]) {
737
+ outputJSON(["success": true, "message": "Sync triggered for '\(args[2])'"])
738
+ }
739
+
740
+ // -- Database commands --
741
+
742
+ case "db-find-db":
743
+ if let path = DBHelper.findDb() {
744
+ outputJSON(["success": true, "dbPath": path])
745
+ } else {
746
+ outputJSON(["success": false, "error": "Reminders database not found"])
747
+ }
748
+
749
+ case "db-stats":
750
+ DBHelper.stats()
751
+
752
+ case "db-find-list":
753
+ guard args.count >= 3 else {
754
+ outputJSON(["success": false, "error": "Usage: db-find-list <listName>"]); exit(1)
755
+ }
756
+ if let pk = DBHelper.findList(args[2]) {
757
+ outputJSON(["success": true, "pk": pk])
758
+ } else {
759
+ outputJSON(["success": false, "error": "List '\(args[2])' not found"])
760
+ }
761
+
762
+ case "db-find-reminder":
763
+ guard args.count >= 4 else {
764
+ outputJSON(["success": false, "error": "Usage: db-find-reminder <title> <listName>"]); exit(1)
765
+ }
766
+ DBHelper.findReminder(args[2], args[3])
767
+
768
+ case "db-find-section":
769
+ guard args.count >= 4 else {
770
+ outputJSON(["success": false, "error": "Usage: db-find-section <sectionName> <listName>"]); exit(1)
771
+ }
772
+ DBHelper.findSection(args[2], args[3])
773
+
774
+ case "db-read-memberships":
775
+ guard args.count >= 3 else {
776
+ outputJSON(["success": false, "error": "Usage: db-read-memberships <listName>"]); exit(1)
777
+ }
778
+ DBHelper.readMemberships(args[2])
779
+
780
+ case "db-read-tokenmap":
781
+ guard args.count >= 3 else {
782
+ outputJSON(["success": false, "error": "Usage: db-read-tokenmap <listName>"]); exit(1)
783
+ }
784
+ DBHelper.readTokenMap(args[2])
785
+
786
+ case "db-write-membership-sync":
787
+ guard args.count >= 4 else {
788
+ outputJSON(["success": false, "error": "Usage: db-write-membership-sync <listName> <membershipJSON>"]); exit(1)
789
+ }
790
+ DBHelper.writeMembershipSync(args[2], args[3], bridge: bridge)
791
+
792
+ default:
793
+ outputJSON(["success": false, "error": "Unknown command: \(command)"])
794
+ }