@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,697 @@
1
+ #!/usr/bin/env swift
2
+ /**
3
+ * remi - Apple Reminders EventKit helper
4
+ *
5
+ * Usage: swift reminders-helper.swift <command> [json-args]
6
+ *
7
+ * Commands:
8
+ * list-lists List all reminder lists
9
+ * get-reminders <json> Get reminders with filtering
10
+ * search <json> Search reminders across all lists
11
+ * create <json> Create a reminder
12
+ * edit <json> Edit an existing reminder
13
+ * complete <json> Mark reminder complete
14
+ * delete <json> Delete a reminder
15
+ * create-list <json> Create a new reminder list
16
+ * delete-list <json> Delete a reminder list
17
+ *
18
+ * All output is JSON.
19
+ */
20
+
21
+ import EventKit
22
+ import Foundation
23
+
24
+ // MARK: - Types
25
+
26
+ struct ReminderList: Encodable {
27
+ let id: String
28
+ let title: String
29
+ let reminderCount: Int
30
+ let overdueCount: Int
31
+ }
32
+
33
+ struct ReminderItem: Encodable {
34
+ let id: String
35
+ let title: String
36
+ let isCompleted: Bool
37
+ let listID: String
38
+ let listName: String
39
+ let priority: String
40
+ let dueDate: String?
41
+ let completionDate: String?
42
+ let notes: String?
43
+ let isRecurring: Bool
44
+ let recurrence: String? // Human-readable recurrence description, e.g. "every 2 weeks"
45
+ }
46
+
47
+ struct ResultResponse: Encodable {
48
+ let success: Bool
49
+ let data: AnyCodable?
50
+ let error: String?
51
+ }
52
+
53
+ struct AnyCodable: Encodable {
54
+ let value: Any
55
+
56
+ func encode(to encoder: Encoder) throws {
57
+ var container = encoder.singleValueContainer()
58
+ if let arr = value as? [Encodable] {
59
+ try container.encode(arr.map { AnyEncodableWrapper($0) })
60
+ } else if let str = value as? String {
61
+ try container.encode(str)
62
+ } else if let bool = value as? Bool {
63
+ try container.encode(bool)
64
+ } else if let int = value as? Int {
65
+ try container.encode(int)
66
+ } else {
67
+ try container.encodeNil()
68
+ }
69
+ }
70
+ }
71
+
72
+ struct AnyEncodableWrapper: Encodable {
73
+ let wrapped: Encodable
74
+ init(_ wrapped: Encodable) { self.wrapped = wrapped }
75
+ func encode(to encoder: Encoder) throws { try wrapped.encode(to: encoder) }
76
+ }
77
+
78
+ // MARK: - Helpers
79
+
80
+ let dateFormatter: DateFormatter = {
81
+ let f = DateFormatter()
82
+ f.dateFormat = "yyyy-MM-dd"
83
+ f.timeZone = TimeZone.current
84
+ return f
85
+ }()
86
+
87
+ let isoFormatter: ISO8601DateFormatter = {
88
+ let f = ISO8601DateFormatter()
89
+ f.formatOptions = [.withInternetDateTime]
90
+ return f
91
+ }()
92
+
93
+ func priorityString(_ p: Int) -> String {
94
+ switch p {
95
+ case 1...4: return "high"
96
+ case 5: return "medium"
97
+ case 6...9: return "low"
98
+ default: return "none"
99
+ }
100
+ }
101
+
102
+ func priorityInt(_ s: String) -> Int {
103
+ switch s {
104
+ case "high": return 1
105
+ case "medium": return 5
106
+ case "low": return 9
107
+ default: return 0
108
+ }
109
+ }
110
+
111
+ func output(_ result: ResultResponse) {
112
+ let encoder = JSONEncoder()
113
+ encoder.outputFormatting = .sortedKeys
114
+ if let data = try? encoder.encode(result), let json = String(data: data, encoding: .utf8) {
115
+ print(json)
116
+ } else {
117
+ print("{\"success\":false,\"error\":\"Failed to encode result\"}")
118
+ }
119
+ }
120
+
121
+ func outputList(_ lists: [ReminderList]) {
122
+ let encoder = JSONEncoder()
123
+ encoder.outputFormatting = .sortedKeys
124
+ if let data = try? encoder.encode(lists), let json = String(data: data, encoding: .utf8) {
125
+ print(json)
126
+ }
127
+ }
128
+
129
+ func outputReminders(_ items: [ReminderItem]) {
130
+ let encoder = JSONEncoder()
131
+ encoder.outputFormatting = .sortedKeys
132
+ if let data = try? encoder.encode(items), let json = String(data: data, encoding: .utf8) {
133
+ print(json)
134
+ }
135
+ }
136
+
137
+ func reminderToItem(_ r: EKReminder) -> ReminderItem {
138
+ var dueDateStr: String? = nil
139
+ if let due = r.dueDateComponents, let year = due.year, let month = due.month, let day = due.day {
140
+ dueDateStr = String(format: "%04d-%02d-%02d", year, month, day)
141
+ }
142
+ var completionStr: String? = nil
143
+ if let cd = r.completionDate {
144
+ completionStr = isoFormatter.string(from: cd)
145
+ }
146
+
147
+ // Build human-readable recurrence description
148
+ var recurrenceStr: String? = nil
149
+ if let rules = r.recurrenceRules, let rule = rules.first {
150
+ let freqName: String
151
+ switch rule.frequency {
152
+ case .daily: freqName = "day"
153
+ case .weekly: freqName = "week"
154
+ case .monthly: freqName = "month"
155
+ case .yearly: freqName = "year"
156
+ @unknown default: freqName = "?"
157
+ }
158
+ if rule.interval == 1 {
159
+ // "daily", "weekly", "monthly", "yearly"
160
+ recurrenceStr = freqName == "day" ? "daily" : freqName + "ly"
161
+ } else {
162
+ recurrenceStr = "every \(rule.interval) \(freqName)s"
163
+ }
164
+ // Add day-of-week info for weekly rules
165
+ if rule.frequency == .weekly, let days = rule.daysOfTheWeek, !days.isEmpty {
166
+ let dayNames = days.map { d -> String in
167
+ switch d.dayOfTheWeek {
168
+ case .sunday: return "Sun"
169
+ case .monday: return "Mon"
170
+ case .tuesday: return "Tue"
171
+ case .wednesday: return "Wed"
172
+ case .thursday: return "Thu"
173
+ case .friday: return "Fri"
174
+ case .saturday: return "Sat"
175
+ @unknown default: return "?"
176
+ }
177
+ }
178
+ recurrenceStr! += " on " + dayNames.joined(separator: ", ")
179
+ }
180
+ }
181
+
182
+ return ReminderItem(
183
+ id: r.calendarItemIdentifier,
184
+ title: r.title ?? "",
185
+ isCompleted: r.isCompleted,
186
+ listID: r.calendar.calendarIdentifier,
187
+ listName: r.calendar.title,
188
+ priority: priorityString(Int(r.priority)),
189
+ dueDate: dueDateStr,
190
+ completionDate: completionStr,
191
+ notes: r.notes,
192
+ isRecurring: (r.recurrenceRules ?? []).count > 0,
193
+ recurrence: recurrenceStr
194
+ )
195
+ }
196
+
197
+ // MARK: - Commands
198
+
199
+ func listLists(_ store: EKEventStore) {
200
+ let calendars = store.calendars(for: .reminder)
201
+ var results: [ReminderList] = []
202
+ let group = DispatchGroup()
203
+
204
+ for cal in calendars {
205
+ group.enter()
206
+ let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: [cal])
207
+ store.fetchReminders(matching: pred) { reminders in
208
+ let items = reminders ?? []
209
+ let now = Date()
210
+ let overdue = items.filter { r in
211
+ guard let due = r.dueDateComponents,
212
+ let year = due.year, let month = due.month, let day = due.day else { return false }
213
+ var comps = DateComponents()
214
+ comps.year = year; comps.month = month; comps.day = day
215
+ guard let dueDate = Calendar.current.date(from: comps) else { return false }
216
+ return dueDate < now
217
+ }.count
218
+
219
+ results.append(ReminderList(
220
+ id: cal.calendarIdentifier,
221
+ title: cal.title,
222
+ reminderCount: items.count,
223
+ overdueCount: overdue
224
+ ))
225
+ group.leave()
226
+ }
227
+ }
228
+
229
+ group.wait()
230
+ outputList(results.sorted { $0.title < $1.title })
231
+ }
232
+
233
+ struct GetRemindersArgs: Decodable {
234
+ let filter: String?
235
+ let list: String?
236
+ let days: Int?
237
+ }
238
+
239
+ func getReminders(_ store: EKEventStore, _ args: GetRemindersArgs) {
240
+ let filter = args.filter ?? "upcoming"
241
+ let calendars: [EKCalendar]
242
+
243
+ if let listName = args.list {
244
+ calendars = store.calendars(for: .reminder).filter { $0.title == listName }
245
+ if calendars.isEmpty {
246
+ output(ResultResponse(success: false, data: nil, error: "List '\(listName)' not found"))
247
+ return
248
+ }
249
+ } else {
250
+ calendars = store.calendars(for: .reminder)
251
+ }
252
+
253
+ let sem = DispatchSemaphore(value: 0)
254
+ let pred: NSPredicate
255
+
256
+ switch filter {
257
+ case "completed":
258
+ pred = store.predicateForCompletedReminders(withCompletionDateStarting: nil, ending: nil, calendars: calendars)
259
+ case "all":
260
+ let pred1 = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: calendars)
261
+ let pred2 = store.predicateForCompletedReminders(withCompletionDateStarting: nil, ending: nil, calendars: calendars)
262
+ var allItems: [ReminderItem] = []
263
+ let group = DispatchGroup()
264
+
265
+ group.enter()
266
+ store.fetchReminders(matching: pred1) { reminders in
267
+ allItems.append(contentsOf: (reminders ?? []).map(reminderToItem))
268
+ group.leave()
269
+ }
270
+ group.enter()
271
+ store.fetchReminders(matching: pred2) { reminders in
272
+ allItems.append(contentsOf: (reminders ?? []).map(reminderToItem))
273
+ group.leave()
274
+ }
275
+ group.wait()
276
+ outputReminders(allItems)
277
+ return
278
+ default:
279
+ pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: calendars)
280
+ }
281
+
282
+ store.fetchReminders(matching: pred) { reminders in
283
+ var items = (reminders ?? []).map(reminderToItem)
284
+
285
+ let cal = Calendar.current
286
+ let now = Date()
287
+
288
+ switch filter {
289
+ case "today":
290
+ items = items.filter { item in
291
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
292
+ return cal.isDateInToday(d)
293
+ }
294
+ case "tomorrow":
295
+ items = items.filter { item in
296
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
297
+ return cal.isDateInTomorrow(d)
298
+ }
299
+ case "week":
300
+ let weekEnd = cal.date(byAdding: .day, value: 7, to: now)!
301
+ items = items.filter { item in
302
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
303
+ return d <= weekEnd
304
+ }
305
+ case "upcoming":
306
+ let days = args.days ?? 7
307
+ let upcomingEnd = cal.date(byAdding: .day, value: days, to: now)!
308
+ items = items.filter { item in
309
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
310
+ return d <= upcomingEnd
311
+ }
312
+ case "overdue":
313
+ let startOfToday = cal.startOfDay(for: now)
314
+ items = items.filter { item in
315
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
316
+ return d < startOfToday
317
+ }
318
+ default:
319
+ // Try parsing as date
320
+ if let targetDate = dateFormatter.date(from: filter) {
321
+ items = items.filter { item in
322
+ guard let ds = item.dueDate, let d = dateFormatter.date(from: ds) else { return false }
323
+ return cal.isDate(d, inSameDayAs: targetDate)
324
+ }
325
+ }
326
+ }
327
+
328
+ outputReminders(items)
329
+ sem.signal()
330
+ }
331
+ sem.wait()
332
+ }
333
+
334
+ struct SearchArgs: Decodable {
335
+ let query: String
336
+ }
337
+
338
+ func searchReminders(_ store: EKEventStore, _ args: SearchArgs) {
339
+ let query = args.query.lowercased()
340
+ let calendars = store.calendars(for: .reminder)
341
+
342
+ let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: calendars)
343
+ let sem = DispatchSemaphore(value: 0)
344
+
345
+ store.fetchReminders(matching: pred) { reminders in
346
+ let items = (reminders ?? [])
347
+ .map(reminderToItem)
348
+ .filter { item in
349
+ item.title.lowercased().contains(query) ||
350
+ (item.notes?.lowercased().contains(query) ?? false)
351
+ }
352
+ outputReminders(items)
353
+ sem.signal()
354
+ }
355
+ sem.wait()
356
+ }
357
+
358
+ struct CreateArgs: Decodable {
359
+ let title: String
360
+ let listName: String
361
+ let due: String?
362
+ let notes: String?
363
+ let priority: String?
364
+ // Recurrence (optional)
365
+ let rruleFreq: String? // DAILY, WEEKLY, MONTHLY, YEARLY
366
+ let rruleInterval: Int? // defaults to 1
367
+ let rruleDays: [Int]? // Days of week: 1=Sun, 2=Mon, 3=Tue, 4=Wed, 5=Thu, 6=Fri, 7=Sat
368
+ let rruleEnd: String? // End date for recurrence (YYYY-MM-DD)
369
+ }
370
+
371
+ func createReminder(_ store: EKEventStore, _ args: CreateArgs) {
372
+ let calendars = store.calendars(for: .reminder).filter { $0.title == args.listName }
373
+ guard let cal = calendars.first else {
374
+ output(ResultResponse(success: false, data: nil, error: "List '\(args.listName)' not found"))
375
+ return
376
+ }
377
+
378
+ let reminder = EKReminder(eventStore: store)
379
+ reminder.title = args.title
380
+ reminder.calendar = cal
381
+
382
+ if let notes = args.notes { reminder.notes = notes }
383
+ if let p = args.priority { reminder.priority = Int(priorityInt(p)) }
384
+
385
+ if let dueStr = args.due, let dueDate = dateFormatter.date(from: dueStr) {
386
+ let comps = Calendar.current.dateComponents([.year, .month, .day], from: dueDate)
387
+ reminder.dueDateComponents = comps
388
+ }
389
+
390
+ // Set recurrence if specified
391
+ if let freqStr = args.rruleFreq {
392
+ let freq: EKRecurrenceFrequency
393
+ switch freqStr.uppercased() {
394
+ case "DAILY": freq = .daily
395
+ case "WEEKLY": freq = .weekly
396
+ case "MONTHLY": freq = .monthly
397
+ case "YEARLY": freq = .yearly
398
+ default:
399
+ output(ResultResponse(success: false, data: nil, error: "Invalid frequency: \(freqStr). Use: daily, weekly, monthly, yearly"))
400
+ return
401
+ }
402
+ let interval = args.rruleInterval ?? 1
403
+
404
+ var daysOfWeek: [EKRecurrenceDayOfWeek]? = nil
405
+ if let days = args.rruleDays, !days.isEmpty {
406
+ daysOfWeek = days.compactMap { dayNum -> EKRecurrenceDayOfWeek? in
407
+ guard let weekday = EKWeekday(rawValue: dayNum) else { return nil }
408
+ return EKRecurrenceDayOfWeek(weekday)
409
+ }
410
+ }
411
+
412
+ var recurrenceEnd: EKRecurrenceEnd? = nil
413
+ if let endStr = args.rruleEnd, let endDate = dateFormatter.date(from: endStr) {
414
+ recurrenceEnd = EKRecurrenceEnd(end: endDate)
415
+ }
416
+
417
+ let rule = EKRecurrenceRule(
418
+ recurrenceWith: freq,
419
+ interval: interval,
420
+ daysOfTheWeek: daysOfWeek,
421
+ daysOfTheMonth: nil,
422
+ monthsOfTheYear: nil,
423
+ weeksOfTheYear: nil,
424
+ daysOfTheYear: nil,
425
+ setPositions: nil,
426
+ end: recurrenceEnd
427
+ )
428
+ reminder.addRecurrenceRule(rule)
429
+ }
430
+
431
+ do {
432
+ try store.save(reminder, commit: true)
433
+ output(ResultResponse(success: true, data: AnyCodable(value: reminder.calendarItemIdentifier), error: nil))
434
+ } catch {
435
+ output(ResultResponse(success: false, data: nil, error: "Save failed: \(error.localizedDescription)"))
436
+ }
437
+ }
438
+
439
+ struct EditArgs: Decodable {
440
+ let id: String
441
+ let title: String?
442
+ let listName: String?
443
+ let due: String?
444
+ let clearDue: Bool?
445
+ let notes: String?
446
+ let priority: String?
447
+ }
448
+
449
+ func editReminder(_ store: EKEventStore, _ args: EditArgs) {
450
+ let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: nil)
451
+ let sem = DispatchSemaphore(value: 0)
452
+
453
+ store.fetchReminders(matching: pred) { reminders in
454
+ guard let reminder = (reminders ?? []).first(where: {
455
+ $0.calendarItemIdentifier.uppercased().hasPrefix(args.id.uppercased())
456
+ }) else {
457
+ output(ResultResponse(success: false, data: nil, error: "Reminder '\(args.id)' not found"))
458
+ sem.signal()
459
+ return
460
+ }
461
+
462
+ if let title = args.title { reminder.title = title }
463
+ if let notes = args.notes { reminder.notes = notes }
464
+ if let p = args.priority { reminder.priority = Int(priorityInt(p)) }
465
+
466
+ if args.clearDue == true {
467
+ reminder.dueDateComponents = nil
468
+ } else if let dueStr = args.due, let dueDate = dateFormatter.date(from: dueStr) {
469
+ let comps = Calendar.current.dateComponents([.year, .month, .day], from: dueDate)
470
+ reminder.dueDateComponents = comps
471
+ }
472
+
473
+ if let listName = args.listName {
474
+ let cals = store.calendars(for: .reminder).filter { $0.title == listName }
475
+ if let cal = cals.first {
476
+ reminder.calendar = cal
477
+ }
478
+ }
479
+
480
+ do {
481
+ try store.save(reminder, commit: true)
482
+ output(ResultResponse(success: true, data: AnyCodable(value: reminder.title ?? ""), error: nil))
483
+ } catch {
484
+ output(ResultResponse(success: false, data: nil, error: "Save failed: \(error.localizedDescription)"))
485
+ }
486
+ sem.signal()
487
+ }
488
+ sem.wait()
489
+ }
490
+
491
+ struct IdArgs: Decodable {
492
+ let id: String
493
+ }
494
+
495
+ func completeReminder(_ store: EKEventStore, _ args: IdArgs) {
496
+ let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: nil)
497
+ let sem = DispatchSemaphore(value: 0)
498
+
499
+ store.fetchReminders(matching: pred) { reminders in
500
+ guard let reminder = (reminders ?? []).first(where: {
501
+ $0.calendarItemIdentifier.uppercased().hasPrefix(args.id.uppercased())
502
+ }) else {
503
+ output(ResultResponse(success: false, data: nil, error: "Reminder '\(args.id)' not found"))
504
+ sem.signal()
505
+ return
506
+ }
507
+
508
+ reminder.isCompleted = true
509
+ reminder.completionDate = Date()
510
+
511
+ do {
512
+ try store.save(reminder, commit: true)
513
+ output(ResultResponse(success: true, data: AnyCodable(value: reminder.title ?? ""), error: nil))
514
+ } catch {
515
+ output(ResultResponse(success: false, data: nil, error: "Complete failed: \(error.localizedDescription)"))
516
+ }
517
+ sem.signal()
518
+ }
519
+ sem.wait()
520
+ }
521
+
522
+ func deleteReminder(_ store: EKEventStore, _ args: IdArgs) {
523
+ let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: nil)
524
+ let pred2 = store.predicateForCompletedReminders(withCompletionDateStarting: nil, ending: nil, calendars: nil)
525
+ let group = DispatchGroup()
526
+ var allReminders: [EKReminder] = []
527
+
528
+ group.enter()
529
+ store.fetchReminders(matching: pred) { r in allReminders.append(contentsOf: r ?? []); group.leave() }
530
+ group.enter()
531
+ store.fetchReminders(matching: pred2) { r in allReminders.append(contentsOf: r ?? []); group.leave() }
532
+ group.wait()
533
+
534
+ guard let reminder = allReminders.first(where: {
535
+ $0.calendarItemIdentifier.uppercased().hasPrefix(args.id.uppercased())
536
+ }) else {
537
+ output(ResultResponse(success: false, data: nil, error: "Reminder '\(args.id)' not found"))
538
+ return
539
+ }
540
+
541
+ let title = reminder.title ?? ""
542
+ do {
543
+ try store.remove(reminder, commit: true)
544
+ output(ResultResponse(success: true, data: AnyCodable(value: title), error: nil))
545
+ } catch {
546
+ output(ResultResponse(success: false, data: nil, error: "Delete failed: \(error.localizedDescription)"))
547
+ }
548
+ }
549
+
550
+ struct CreateListArgs: Decodable {
551
+ let name: String
552
+ }
553
+
554
+ func createList(_ store: EKEventStore, _ args: CreateListArgs) {
555
+ // Check if list already exists (idempotent)
556
+ let existing = store.calendars(for: .reminder).filter { $0.title == args.name }
557
+ if let cal = existing.first {
558
+ output(ResultResponse(success: true, data: AnyCodable(value: cal.calendarIdentifier), error: nil))
559
+ return
560
+ }
561
+
562
+ let cal = EKCalendar(for: .reminder, eventStore: store)
563
+ cal.title = args.name
564
+
565
+ // Use the default reminder source (iCloud if available)
566
+ if let defaultCal = store.defaultCalendarForNewReminders() {
567
+ cal.source = defaultCal.source
568
+ } else {
569
+ output(ResultResponse(success: false, data: nil, error: "No reminder source available"))
570
+ return
571
+ }
572
+
573
+ do {
574
+ try store.saveCalendar(cal, commit: true)
575
+ output(ResultResponse(success: true, data: AnyCodable(value: cal.calendarIdentifier), error: nil))
576
+ } catch {
577
+ output(ResultResponse(success: false, data: nil, error: "Create list failed: \(error.localizedDescription)"))
578
+ }
579
+ }
580
+
581
+ struct DeleteListArgs: Decodable {
582
+ let name: String
583
+ }
584
+
585
+ func deleteList(_ store: EKEventStore, _ args: DeleteListArgs) {
586
+ let calendars = store.calendars(for: .reminder).filter { $0.title == args.name }
587
+ guard let cal = calendars.first else {
588
+ output(ResultResponse(success: false, data: nil, error: "List '\(args.name)' not found"))
589
+ return
590
+ }
591
+
592
+ do {
593
+ try store.removeCalendar(cal, commit: true)
594
+ output(ResultResponse(success: true, data: AnyCodable(value: args.name), error: nil))
595
+ } catch {
596
+ output(ResultResponse(success: false, data: nil, error: "Delete list failed: \(error.localizedDescription)"))
597
+ }
598
+ }
599
+
600
+ // MARK: - Main
601
+
602
+ let store = EKEventStore()
603
+ let sem = DispatchSemaphore(value: 0)
604
+
605
+ store.requestFullAccessToReminders { granted, error in
606
+ guard granted else {
607
+ output(ResultResponse(success: false, data: nil, error: "Reminders access denied. Grant in System Settings > Privacy & Security > Reminders."))
608
+ sem.signal()
609
+ return
610
+ }
611
+
612
+ let args = CommandLine.arguments
613
+ guard args.count >= 2 else {
614
+ output(ResultResponse(success: false, data: nil, error: "Usage: reminders-helper.swift <command> [json-args]"))
615
+ sem.signal()
616
+ return
617
+ }
618
+
619
+ let command = args[1]
620
+ let jsonArg = args.count > 2 ? args[2] : "{}"
621
+
622
+ switch command {
623
+ case "list-lists":
624
+ listLists(store)
625
+
626
+ case "get-reminders":
627
+ guard let data = jsonArg.data(using: .utf8),
628
+ let parsed = try? JSONDecoder().decode(GetRemindersArgs.self, from: data) else {
629
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for get-reminders"))
630
+ break
631
+ }
632
+ getReminders(store, parsed)
633
+
634
+ case "search":
635
+ guard let data = jsonArg.data(using: .utf8),
636
+ let parsed = try? JSONDecoder().decode(SearchArgs.self, from: data) else {
637
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for search"))
638
+ break
639
+ }
640
+ searchReminders(store, parsed)
641
+
642
+ case "create":
643
+ guard let data = jsonArg.data(using: .utf8),
644
+ let parsed = try? JSONDecoder().decode(CreateArgs.self, from: data) else {
645
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for create"))
646
+ break
647
+ }
648
+ createReminder(store, parsed)
649
+
650
+ case "edit":
651
+ guard let data = jsonArg.data(using: .utf8),
652
+ let parsed = try? JSONDecoder().decode(EditArgs.self, from: data) else {
653
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for edit"))
654
+ break
655
+ }
656
+ editReminder(store, parsed)
657
+
658
+ case "complete":
659
+ guard let data = jsonArg.data(using: .utf8),
660
+ let parsed = try? JSONDecoder().decode(IdArgs.self, from: data) else {
661
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for complete"))
662
+ break
663
+ }
664
+ completeReminder(store, parsed)
665
+
666
+ case "delete":
667
+ guard let data = jsonArg.data(using: .utf8),
668
+ let parsed = try? JSONDecoder().decode(IdArgs.self, from: data) else {
669
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for delete"))
670
+ break
671
+ }
672
+ deleteReminder(store, parsed)
673
+
674
+ case "create-list":
675
+ guard let data = jsonArg.data(using: .utf8),
676
+ let parsed = try? JSONDecoder().decode(CreateListArgs.self, from: data) else {
677
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for create-list"))
678
+ break
679
+ }
680
+ createList(store, parsed)
681
+
682
+ case "delete-list":
683
+ guard let data = jsonArg.data(using: .utf8),
684
+ let parsed = try? JSONDecoder().decode(DeleteListArgs.self, from: data) else {
685
+ output(ResultResponse(success: false, data: nil, error: "Invalid JSON for delete-list"))
686
+ break
687
+ }
688
+ deleteList(store, parsed)
689
+
690
+ default:
691
+ output(ResultResponse(success: false, data: nil, error: "Unknown command: \(command). Available: list-lists, get-reminders, search, create, edit, complete, delete, create-list, delete-list"))
692
+ }
693
+
694
+ sem.signal()
695
+ }
696
+
697
+ sem.wait()