@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.
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/add.js +36 -0
- package/dist/cli/commands/add.js.map +1 -0
- package/dist/cli/commands/authorize.d.ts +1 -0
- package/dist/cli/commands/authorize.js +75 -0
- package/dist/cli/commands/authorize.js.map +1 -0
- package/dist/cli/commands/complete.d.ts +3 -0
- package/dist/cli/commands/complete.js +9 -0
- package/dist/cli/commands/complete.js.map +1 -0
- package/dist/cli/commands/create-list.d.ts +1 -0
- package/dist/cli/commands/create-list.js +7 -0
- package/dist/cli/commands/create-list.js.map +1 -0
- package/dist/cli/commands/create-section.d.ts +1 -0
- package/dist/cli/commands/create-section.js +7 -0
- package/dist/cli/commands/create-section.js.map +1 -0
- package/dist/cli/commands/delete-list.d.ts +3 -0
- package/dist/cli/commands/delete-list.js +11 -0
- package/dist/cli/commands/delete-list.js.map +1 -0
- package/dist/cli/commands/delete-section.d.ts +1 -0
- package/dist/cli/commands/delete-section.js +7 -0
- package/dist/cli/commands/delete-section.js.map +1 -0
- package/dist/cli/commands/delete.d.ts +4 -0
- package/dist/cli/commands/delete.js +14 -0
- package/dist/cli/commands/delete.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.js +180 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/list.d.ts +4 -0
- package/dist/cli/commands/list.js +11 -0
- package/dist/cli/commands/list.js.map +1 -0
- package/dist/cli/commands/lists.d.ts +1 -0
- package/dist/cli/commands/lists.js +7 -0
- package/dist/cli/commands/lists.js.map +1 -0
- package/dist/cli/commands/move.d.ts +3 -0
- package/dist/cli/commands/move.js +11 -0
- package/dist/cli/commands/move.js.map +1 -0
- package/dist/cli/commands/overdue.d.ts +1 -0
- package/dist/cli/commands/overdue.js +11 -0
- package/dist/cli/commands/overdue.js.map +1 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/commands/search.js +11 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/commands/sections.d.ts +1 -0
- package/dist/cli/commands/sections.js +7 -0
- package/dist/cli/commands/sections.js.map +1 -0
- package/dist/cli/commands/today.d.ts +1 -0
- package/dist/cli/commands/today.js +11 -0
- package/dist/cli/commands/today.js.map +1 -0
- package/dist/cli/commands/upcoming.d.ts +3 -0
- package/dist/cli/commands/upcoming.js +12 -0
- package/dist/cli/commands/upcoming.js.map +1 -0
- package/dist/cli/commands/update.d.ts +7 -0
- package/dist/cli/commands/update.js +17 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +232 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.d.ts +26 -0
- package/dist/cli/output.js +234 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/core/checksum.d.ts +9 -0
- package/dist/core/checksum.js +13 -0
- package/dist/core/checksum.js.map +1 -0
- package/dist/core/dateparse.d.ts +7 -0
- package/dist/core/dateparse.js +27 -0
- package/dist/core/dateparse.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/eventkit.d.ts +38 -0
- package/dist/core/eventkit.js +127 -0
- package/dist/core/eventkit.js.map +1 -0
- package/dist/core/lookup.d.ts +10 -0
- package/dist/core/lookup.js +32 -0
- package/dist/core/lookup.js.map +1 -0
- package/dist/core/membership.d.ts +30 -0
- package/dist/core/membership.js +92 -0
- package/dist/core/membership.js.map +1 -0
- package/dist/core/recurrence.d.ts +14 -0
- package/dist/core/recurrence.js +90 -0
- package/dist/core/recurrence.js.map +1 -0
- package/dist/core/reminderkit.d.ts +33 -0
- package/dist/core/reminderkit.js +154 -0
- package/dist/core/reminderkit.js.map +1 -0
- package/dist/core/sqlite.d.ts +65 -0
- package/dist/core/sqlite.js +175 -0
- package/dist/core/sqlite.js.map +1 -0
- package/dist/core/tokenmap.d.ts +29 -0
- package/dist/core/tokenmap.js +52 -0
- package/dist/core/tokenmap.js.map +1 -0
- package/dist/reminders-helper +0 -0
- package/dist/section-helper +0 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/swift/Info.plist +16 -0
- package/src/swift/build.sh +47 -0
- package/src/swift/reminders-helper.swift +697 -0
- 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
|
+
}
|