Release 1.6.1
- Sync reminders: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms into mirrored placeholders. CLI flag --sync-reminders. - CLI polish: --help, --list-calendars, --status (all support --json). Scheduled/headless runs record last-run time/result/summary. Failure paths now exit with distinct nonzero codes (2 = no calendar access, 3 = no saved routes) instead of always exiting 0. - Fix settings silently wiped on upgrade: SettingsPayload's auto-synthesized Codable threw on any settings blob missing a field added since (e.g. the new syncReminders key), failing the whole decode and letting the next autosave persist an empty state over real routes/filters. Custom decode now falls back per-field like Route already did, and loadSettingsFromDefaults recovers routes from the legacy routes.v1 key if settings.v2 comes back empty, repairing installs already hit by this. - Roadmap: event-driven background sync (SMAppService + EKEventStoreChanged + wake notification) and an external MCP wrapper around the CLI queued next; decided against direct Google/CalDAV API integration since EventKit already covers it via System Settings accounts. All 47 unit tests pass (45 existing + 2 new SettingsPayloadTests). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,24 @@ import EventKit
|
||||
|
||||
private let SAME_TIME_TOL_MIN: Double = 5
|
||||
|
||||
private func alarmOffsets(for event: EKEvent) -> [TimeInterval]? {
|
||||
guard let alarms = event.alarms, !alarms.isEmpty else { return nil }
|
||||
let offsets = alarms.compactMap { alarm -> TimeInterval? in
|
||||
if alarm.relativeOffset != 0 {
|
||||
return alarm.relativeOffset
|
||||
}
|
||||
if let absoluteDate = alarm.absoluteDate, let start = event.startDate {
|
||||
return absoluteDate.timeIntervalSince(start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return offsets.isEmpty ? nil : offsets
|
||||
}
|
||||
|
||||
private func alarmsFromOffsets(_ offsets: [TimeInterval]) -> [EKAlarm] {
|
||||
offsets.map { EKAlarm(relativeOffset: $0) }
|
||||
}
|
||||
|
||||
struct MirrorRecord: Hashable, Codable {
|
||||
var targetCalendarID: String
|
||||
var sourceCalendarID: String
|
||||
@@ -160,7 +178,7 @@ final class MirrorEngine {
|
||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||
let srcID = stableSourceIdentifier(for: ev)
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
|
||||
}
|
||||
if skippedMirrors > 0 {
|
||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||
@@ -324,6 +342,17 @@ final class MirrorEngine {
|
||||
upsertMirrorRecord(for: blk, event: event)
|
||||
}
|
||||
|
||||
func desiredAlarms(for blk: Block) -> [EKAlarm] {
|
||||
guard config.syncReminders, let offsets = blk.alarmOffsets else { return [] }
|
||||
return alarmsFromOffsets(offsets)
|
||||
}
|
||||
|
||||
func alarmsEqual(_ a: [EKAlarm], _ b: [EKAlarm]) -> Bool {
|
||||
let offsetsA = a.compactMap { $0.relativeOffset }.sorted()
|
||||
let offsetsB = b.compactMap { $0.relativeOffset }.sorted()
|
||||
return offsetsA == offsetsB
|
||||
}
|
||||
|
||||
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
|
||||
let curS = existing.startDate ?? blk.start
|
||||
let curE = existing.endDate ?? blk.end
|
||||
@@ -333,6 +362,9 @@ final class MirrorEngine {
|
||||
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
|
||||
if existing.isAllDay { return true }
|
||||
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
|
||||
let newAlarms = desiredAlarms(for: blk)
|
||||
let existingAlarms = existing.alarms ?? []
|
||||
if !alarmsEqual(newAlarms, existingAlarms) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -382,6 +414,7 @@ final class MirrorEngine {
|
||||
existing.isAllDay = false
|
||||
existing.notes = notes
|
||||
existing.url = desiredURL
|
||||
existing.alarms = desiredAlarms(for: blk)
|
||||
do {
|
||||
try store.save(existing, span: .thisEvent, commit: true)
|
||||
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
||||
@@ -433,6 +466,7 @@ final class MirrorEngine {
|
||||
newEv.isAllDay = false
|
||||
newEv.notes = notes
|
||||
newEv.url = desiredURL
|
||||
newEv.alarms = desiredAlarms(for: blk)
|
||||
newEv.availability = .busy
|
||||
do {
|
||||
try store.save(newEv, span: .thisEvent, commit: true)
|
||||
|
||||
Reference in New Issue
Block a user