diff --git a/AGENTS.md b/AGENTS.md index 5f46918..94a9b9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,9 @@ BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit ``` -Relevant flags: `--privacy`, `--copy-notes`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`. +Relevant flags: `--privacy`, `--copy-notes`, `--sync-reminders`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`. + +Diagnostic/query flags (no calendar write, exit immediately): `--help`/`-h`, `--list-calendars [--json]`, `--status [--json]`. `--status` reads `lastRunAtISO`/`lastRunOK`/`lastRunSummary` in `UserDefaults`, written by `recordRunResult(ok:summary:)` at the end of every `--routes`/`--run-saved-routes` invocation. Exit codes: `2` = no calendar access, `3` = `--run-saved-routes` with no saved routes. Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click. diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 35e583e..dcdec29 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 22; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 22; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/BlockMath.swift b/BusyMirror/BlockMath.swift index 2366f41..a87da91 100644 --- a/BusyMirror/BlockMath.swift +++ b/BusyMirror/BlockMath.swift @@ -7,10 +7,31 @@ struct Block: Hashable { let label: String? // source title (for dry-run / non-private) let notes: String? // source notes (for optional copy) let occurrence: Date? // occurrenceDate for recurring instances + let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event /// Convenience factory for time-only blocks (used internally for occupancy tracking). static func span(start: Date, end: Date) -> Block { - Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) + Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: nil) + } + + // Alarms are carried along for mirroring but do not affect time-based + // deduplication, merging, or overlap calculations. + static func == (lhs: Block, rhs: Block) -> Bool { + lhs.start == rhs.start && + lhs.end == rhs.end && + lhs.srcStableID == rhs.srcStableID && + lhs.label == rhs.label && + lhs.notes == rhs.notes && + lhs.occurrence == rhs.occurrence + } + + func hash(into hasher: inout Hasher) { + hasher.combine(start) + hasher.combine(end) + hasher.combine(srcStableID) + hasher.combine(label) + hasher.combine(notes) + hasher.combine(occurrence) } } @@ -36,16 +57,18 @@ func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] { let sorted = blocks.sorted { $0.start < $1.start } var out: [Block] = [] var cur = Block.span(start: sorted[0].start, end: sorted[0].end) + var curAlarms = sorted[0].alarmOffsets for b in sorted.dropFirst() { let gap = b.start.timeIntervalSince(cur.end) / 60.0 if gap <= Double(gapMinutes) { if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) } } else { - out.append(cur) + out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms)) cur = Block.span(start: b.start, end: b.end) + curAlarms = b.alarmOffsets } } - out.append(cur) + out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms)) return out } diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 0abf5f0..511196d 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -44,16 +44,18 @@ struct Route: Identifiable, Hashable, Codable { var targetIDs: Set var privacy: Bool // true = hide details for this source var copyNotes: Bool // copy description when privacy is OFF + var syncReminders: Bool // copy source event alarms into placeholder var mergeGapHours: Int // per-route merge gap (hours) var overlap: OverlapMode // per-route overlap behavior var allDay: Bool // per-route mirror all-day - enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay } + enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, syncReminders, mergeGapHours, overlap, allDay } - init(sourceID: String, targetIDs: Set, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) { + init(sourceID: String, targetIDs: Set, privacy: Bool, copyNotes: Bool, syncReminders: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) { self.sourceID = sourceID self.targetIDs = targetIDs self.privacy = privacy self.copyNotes = copyNotes + self.syncReminders = syncReminders self.mergeGapHours = mergeGapHours self.overlap = overlap self.allDay = allDay @@ -65,6 +67,7 @@ struct Route: Identifiable, Hashable, Codable { self.targetIDs = try c.decode(Set.self, forKey: .targetIDs) self.privacy = try c.decode(Bool.self, forKey: .privacy) self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes) + self.syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours) self.overlap = try c.decode(OverlapMode.self, forKey: .overlap) self.allDay = try c.decode(Bool.self, forKey: .allDay) @@ -89,6 +92,7 @@ struct ContentView: View { private var mergeGapMin: Int { max(0, mergeGapHours * 60) } @AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy" @AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false + @AppStorage("syncReminders") private var syncReminders: Bool = false // Copy source alarms into mirrored placeholders @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @@ -110,6 +114,10 @@ struct ContentView: View { @State private var logText = "Ready." @State private var isRunning = false @State private var isCLIRun = false + @State private var cliRunErrorCount = 0 + @AppStorage("lastRunAtISO") private var lastRunAtISO: String = "" + @AppStorage("lastRunOK") private var lastRunOK: Bool = true + @AppStorage("lastRunSummary") private var lastRunSummary: String = "" @State private var confirmCleanup = false @State private var mirrorTask: Task? = nil @State private var progressText: String? = nil @@ -542,6 +550,7 @@ struct ContentView: View { targetIDs: targetIDs, privacy: hideDetails, copyNotes: copyDescription, + syncReminders: syncReminders, mergeGapHours: mergeGapHours, overlap: overlapMode, allDay: mirrorAllDay) @@ -587,6 +596,9 @@ struct ContentView: View { Toggle("Copy description", isOn: routeBinding.copyNotes) .disabled(isRunning || route.privacy) .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") + Toggle("Sync reminders", isOn: routeBinding.syncReminders) + .disabled(isRunning) + .help("If ON, copy the source event’s reminders/alarms into the placeholder.") Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay) .disabled(isRunning) .help("Mirror all-day events for this source.") @@ -732,7 +744,8 @@ struct ContentView: View { excludedOrganizerFilterTerms: excludedOrganizerFilterTerms, mirrorAcceptedOnly: mirrorAcceptedOnly, autoDeleteMissing: autoDeleteMissing, - writeEnabled: writeEnabled + writeEnabled: writeEnabled, + syncReminders: r.syncReminders ) let srcCal = calendars[sIdx] let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier } @@ -776,7 +789,8 @@ struct ContentView: View { excludedOrganizerFilterTerms: excludedOrganizerFilterTerms, mirrorAcceptedOnly: mirrorAcceptedOnly, autoDeleteMissing: autoDeleteMissing, - writeEnabled: writeEnabled + writeEnabled: writeEnabled, + syncReminders: syncReminders ) } @@ -904,6 +918,8 @@ struct ContentView: View { .disabled(isRunning) Toggle("Copy description when mirroring", isOn: $copyDescription) .disabled(isRunning || hideDetails) + Toggle("Sync reminders when mirroring", isOn: $syncReminders) + .disabled(isRunning) Toggle("Mirror all-day events", isOn: $mirrorAllDay) .disabled(isRunning) Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly) @@ -1309,6 +1325,7 @@ struct ContentView: View { .onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() } .onChange(of: hideDetails) { _ in saveSettingsToDefaults() } .onChange(of: copyDescription) { _ in saveSettingsToDefaults() } + .onChange(of: syncReminders) { _ in saveSettingsToDefaults() } .onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() } .onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() } .onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() } @@ -1339,12 +1356,166 @@ struct ContentView: View { } // MARK: - CLI support + private static let cliHelpText = """ + BusyMirror — mirror calendar events between EventKit calendars. + + Usage: + BusyMirror --run-saved-routes [--write 1] [--exit] + BusyMirror --routes "1->2,3; 4->5" [--write 1] [--exit] + BusyMirror --list-calendars [--json] + BusyMirror --status [--json] + BusyMirror --help + + Run modes: + --run-saved-routes Run the routes configured in the app's saved settings. + --routes SPEC Run ad-hoc routes by 1-based calendar index, e.g. "1->2,3". + --list-calendars Print available calendars (index, id, title, source) and exit. + --status Print last-run and schedule diagnostics and exit. + --help, -h Print this help and exit. + + Options: + --json Machine-readable JSON output for --list-calendars / --status. + --write 1 Actually create/update/delete events (default: dry-run). + --exit Quit the app after the run completes. + --cleanup-only Only delete stale mirrored placeholders; don't mirror. + --privacy 1|0 Hide event details behind a placeholder title. + --copy-notes 1|0 Copy the source event's notes into the mirror. + --sync-reminders 1|0 Copy source event alarms into the mirror. + --all-day 1|0 Mirror all-day events. + --mode allow|skipCovered|fillGaps + --days-back N / --days-forward N + --merge-gap-hours N + --exclude-titles "token1, token2" + --exclude-organizers "alice@example.com, Example Org" + """ + + private func recordRunResult(ok: Bool, summary: String) { + lastRunAtISO = ISO8601DateFormatter().string(from: Date()) + lastRunOK = ok + lastRunSummary = summary + } + + private struct CLICalendarInfo: Codable { + let index: Int + let id: String + let title: String + let source: String + let sourceType: String + let allowsModify: Bool + } + + private struct CLIStatusInfo: Codable { + let lastRunAt: String? + let lastRunOK: Bool? + let lastRunSummary: String? + let scheduleInstalled: Bool + let scheduleSummary: String? + let routeCount: Int + let logFilePath: String + } + + private func sourceTypeLabel(_ type: EKSourceType) -> String { + switch type { + case .local: return "local" + case .exchange: return "exchange" + case .calDAV: return "calDAV" + case .mobileMe: return "iCloud" + case .subscribed: return "subscribed" + case .birthdays: return "birthdays" + @unknown default: return "unknown" + } + } + + private func printCalendars(json: Bool) { + let infos = calendars.enumerated().map { idx, cal in + CLICalendarInfo( + index: idx + 1, + id: cal.calendarIdentifier, + title: cal.title, + source: cal.source.title, + sourceType: sourceTypeLabel(cal.source.sourceType), + allowsModify: cal.allowsContentModifications + ) + } + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(infos), let s = String(data: data, encoding: .utf8) { + print(s) + } + } else { + for info in infos { + print("\(info.index): \(info.title) [\(info.source), \(info.sourceType)]\(info.allowsModify ? "" : " (read-only)") id=\(info.id)") + } + } + } + + private func printStatus(json: Bool) { + let info = CLIStatusInfo( + lastRunAt: lastRunAtISO.isEmpty ? nil : lastRunAtISO, + lastRunOK: lastRunAtISO.isEmpty ? nil : lastRunOK, + lastRunSummary: lastRunSummary.isEmpty ? nil : lastRunSummary, + scheduleInstalled: hasInstalledSchedule, + scheduleSummary: hasInstalledSchedule ? scheduleSummary : nil, + routeCount: routes.count, + logFilePath: AppLogStore.logFileURL.path + ) + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(info), let s = String(data: data, encoding: .utf8) { + print(s) + } + } else { + print("Last run: \(info.lastRunAt ?? "never")\(info.lastRunAt != nil ? (info.lastRunOK == true ? " (ok)" : " (error)") : "")") + if let summary = info.lastRunSummary { print(" \(summary)") } + print("Schedule: \(info.scheduleInstalled ? (info.scheduleSummary ?? "installed") : "not installed")") + print("Saved routes: \(info.routeCount)") + print("Log file: \(info.logFilePath)") + } + } + func tryRunCLIIfPresent() { let args = CommandLine.arguments + let jsonOutput = args.contains("--json") + + if args.contains("--help") || args.contains("-h") { + isCLIRun = true + print(Self.cliHelpText) + NSApp.terminate(nil) + return + } + + if args.contains("--status") { + isCLIRun = true + printStatus(json: jsonOutput) + NSApp.terminate(nil) + return + } + + if args.contains("--list-calendars") { + isCLIRun = true + Task { + if hasAccess { await MainActor.run { reloadCalendars() } } + for _ in 0..<50 { + if hasAccess && !calendars.isEmpty { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + guard hasAccess else { + FileHandle.standardError.write("No calendar access.\n".data(using: .utf8)!) + exit(2) + } + await MainActor.run { printCalendars(json: jsonOutput) } + NSApp.terminate(nil) + } + return + } + let routesIdx = args.firstIndex(of: "--routes") let runSavedRoutes = args.contains("--run-saved-routes") guard routesIdx != nil || runSavedRoutes else { return } isCLIRun = true + cliRunErrorCount = 0 func boolArg(_ name: String, default def: Bool) -> Bool { if let i = args.firstIndex(of: name), i+1 < args.count { @@ -1365,6 +1536,7 @@ struct ContentView: View { // Configure options from CLI flags hideDetails = boolArg("--privacy", default: hideDetails) copyDescription = boolArg("--copy-notes", default: copyDescription) + syncReminders = boolArg("--sync-reminders", default: syncReminders) writeEnabled = boolArg("--write", default: writeEnabled) mirrorAllDay = boolArg("--all-day", default: mirrorAllDay) daysForward = intArg("--days-forward", default: daysForward) @@ -1405,22 +1577,26 @@ struct ContentView: View { } guard hasAccess, !calendars.isEmpty else { log("CLI: no calendar access; aborting") - NSApp.terminate(nil) - return + recordRunResult(ok: false, summary: "no calendar access") + exit(2) } let cliConfig = makeMirrorConfig() if runSavedRoutes { if routes.isEmpty { log("CLI: no saved routes; aborting") + recordRunResult(ok: false, summary: "no saved routes") + exit(3) } else if boolArg("--cleanup-only", default: false) { for r in routes { log("CLI: cleanup saved route \(r.sourceID)") await runCleanupForRoute(r) } + recordRunResult(ok: cliRunErrorCount == 0, summary: "cleaned up \(routes.count) saved route(s)") } else { var sessionGuard = Set() await runConfiguredRoutes(routes, sessionGuard: &sessionGuard) + recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routes.count) saved route(s)") } } else { for part in routeParts where !part.isEmpty { @@ -1450,6 +1626,7 @@ struct ContentView: View { await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false) } } + recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routeParts.count) route(s)") } // Exit only when --exit is explicitly passed. isCLIRun alone does // not force termination so that advanced users can open the UI with @@ -1542,12 +1719,13 @@ struct ContentView: View { } // MARK: - Export / Import Settings - private struct SettingsPayload: Codable { + struct SettingsPayload: Codable { var daysBack: Int var daysForward: Int var mergeGapHours: Int var hideDetails: Bool var copyDescription: Bool + var syncReminders: Bool = false var mirrorAllDay: Bool var filterByWorkHours: Bool = false var workHoursStart: Int = 9 @@ -1566,6 +1744,67 @@ struct ContentView: View { // optional metadata var appVersion: String? var exportedAt: Date = Date() + + init(daysBack: Int, daysForward: Int, mergeGapHours: Int, hideDetails: Bool, copyDescription: Bool, + syncReminders: Bool = false, mirrorAllDay: Bool, filterByWorkHours: Bool, workHoursStart: Int, + workHoursEnd: Int, excludedTitleFilters: [String], excludedOrganizerFilters: [String], + mirrorAcceptedOnly: Bool, overlapMode: String, titlePrefix: String, placeholderTitle: String, + autoDeleteMissing: Bool, routes: [Route], selectedSourceID: String? = nil, + selectedTargetIDs: [String]? = nil, appVersion: String? = nil, exportedAt: Date = Date()) { + self.daysBack = daysBack + self.daysForward = daysForward + self.mergeGapHours = mergeGapHours + self.hideDetails = hideDetails + self.copyDescription = copyDescription + self.syncReminders = syncReminders + self.mirrorAllDay = mirrorAllDay + self.filterByWorkHours = filterByWorkHours + self.workHoursStart = workHoursStart + self.workHoursEnd = workHoursEnd + self.excludedTitleFilters = excludedTitleFilters + self.excludedOrganizerFilters = excludedOrganizerFilters + self.mirrorAcceptedOnly = mirrorAcceptedOnly + self.overlapMode = overlapMode + self.titlePrefix = titlePrefix + self.placeholderTitle = placeholderTitle + self.autoDeleteMissing = autoDeleteMissing + self.routes = routes + self.selectedSourceID = selectedSourceID + self.selectedTargetIDs = selectedTargetIDs + self.appVersion = appVersion + self.exportedAt = exportedAt + } + + // Custom decode: every field added after the very first release must be + // read with decodeIfPresent so that a settings blob written by an older + // build (missing that key) doesn't fail the whole decode and silently + // wipe all saved routes/settings (see: settings.v2 losing data across + // the 1.5.1 -> 1.6.0 upgrade when `syncReminders` was added). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + daysBack = try c.decodeIfPresent(Int.self, forKey: .daysBack) ?? 1 + daysForward = try c.decodeIfPresent(Int.self, forKey: .daysForward) ?? 7 + mergeGapHours = try c.decodeIfPresent(Int.self, forKey: .mergeGapHours) ?? 0 + hideDetails = try c.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? true + copyDescription = try c.decodeIfPresent(Bool.self, forKey: .copyDescription) ?? false + syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false + mirrorAllDay = try c.decodeIfPresent(Bool.self, forKey: .mirrorAllDay) ?? false + filterByWorkHours = try c.decodeIfPresent(Bool.self, forKey: .filterByWorkHours) ?? false + workHoursStart = try c.decodeIfPresent(Int.self, forKey: .workHoursStart) ?? 9 + workHoursEnd = try c.decodeIfPresent(Int.self, forKey: .workHoursEnd) ?? 17 + excludedTitleFilters = try c.decodeIfPresent([String].self, forKey: .excludedTitleFilters) ?? [] + excludedOrganizerFilters = try c.decodeIfPresent([String].self, forKey: .excludedOrganizerFilters) ?? [] + mirrorAcceptedOnly = try c.decodeIfPresent(Bool.self, forKey: .mirrorAcceptedOnly) ?? false + overlapMode = try c.decodeIfPresent(String.self, forKey: .overlapMode) ?? OverlapMode.allow.rawValue + titlePrefix = try c.decodeIfPresent(String.self, forKey: .titlePrefix) ?? "🪞 " + placeholderTitle = try c.decodeIfPresent(String.self, forKey: .placeholderTitle) ?? "Busy" + autoDeleteMissing = try c.decodeIfPresent(Bool.self, forKey: .autoDeleteMissing) ?? true + routes = try c.decodeIfPresent([Route].self, forKey: .routes) ?? [] + selectedSourceID = try c.decodeIfPresent(String.self, forKey: .selectedSourceID) + selectedTargetIDs = try c.decodeIfPresent([String].self, forKey: .selectedTargetIDs) + appVersion = try c.decodeIfPresent(String.self, forKey: .appVersion) + exportedAt = try c.decodeIfPresent(Date.self, forKey: .exportedAt) ?? Date() + } } private func makeSnapshot() -> SettingsPayload { @@ -1600,6 +1839,7 @@ struct ContentView: View { mergeGapHours = s.mergeGapHours hideDetails = s.hideDetails copyDescription = s.copyDescription + syncReminders = s.syncReminders mirrorAllDay = s.mirrorAllDay filterByWorkHours = s.filterByWorkHours workHoursStart = s.workHoursStart @@ -1703,6 +1943,18 @@ struct ContentView: View { do { let snap = try JSONDecoder().decode(SettingsPayload.self, from: data) applySnapshot(snap) + // A build affected by an earlier settings.v2 decode failure (fixed + // in 1.6.0 — a newly added field with no decode fallback threw and + // wiped routes in memory, which a later autosave then persisted + // back as empty) can still be running with `routes: []` in + // settings.v2 while the untouched legacy `routes.v1` key still + // holds the real routes. Recover them if so. + if routes.isEmpty, let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey), + let recovered = try? JSONDecoder().decode([Route].self, from: legacyData), !recovered.isEmpty { + routes = recovered + log("Recovered \(recovered.count) route(s) from legacy backup (routes.v1).") + saveSettingsToDefaults() + } } catch { log("✗ Failed to load settings: \(error.localizedDescription)") } @@ -1737,6 +1989,12 @@ struct ContentView: View { // MARK: - Logging func log(_ s: String) { AppLogStore.append(s) + if isCLIRun { + let lower = s.lowercased() + if lower.contains("error") || lower.contains("fail") { + cliRunErrorCount += 1 + } + } DispatchQueue.main.async { logText.append("\n" + s) let maxLines = 2000 diff --git a/BusyMirror/MirrorConfig.swift b/BusyMirror/MirrorConfig.swift index 6fd7bf2..ce7765d 100644 --- a/BusyMirror/MirrorConfig.swift +++ b/BusyMirror/MirrorConfig.swift @@ -19,4 +19,5 @@ struct MirrorConfig { let mirrorAcceptedOnly: Bool let autoDeleteMissing: Bool let writeEnabled: Bool + let syncReminders: Bool } diff --git a/BusyMirror/MirrorEngine.swift b/BusyMirror/MirrorEngine.swift index 2dc2a87..994bf92 100644 --- a/BusyMirror/MirrorEngine.swift +++ b/BusyMirror/MirrorEngine.swift @@ -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) diff --git a/BusyMirrorTests/BlockMathTests.swift b/BusyMirrorTests/BlockMathTests.swift index 58af717..a5ea06e 100644 --- a/BusyMirrorTests/BlockMathTests.swift +++ b/BusyMirrorTests/BlockMathTests.swift @@ -5,14 +5,15 @@ final class BlockMathTests: XCTestCase { private let d = Date(timeIntervalSince1970: 0) - private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block { + private func block(_ startMin: Int, _ endMin: Int, id: String? = nil, alarmOffsets: [TimeInterval]? = nil) -> Block { Block( start: d.addingTimeInterval(TimeInterval(startMin * 60)), end: d.addingTimeInterval(TimeInterval(endMin * 60)), srcStableID: id, label: nil, notes: nil, - occurrence: nil + occurrence: nil, + alarmOffsets: alarmOffsets ) } @@ -44,6 +45,21 @@ final class BlockMathTests: XCTestCase { XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty) } + func testMergeBlocksPreservesFirstAlarms() { + let b1 = block(0, 10, alarmOffsets: [-900, -3600]) + let b2 = block(10, 20, alarmOffsets: [-600]) + let merged = mergeBlocks([b1, b2], gapMinutes: 0) + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(merged[0].alarmOffsets, [-900, -3600]) + } + + func testBlockEqualityIgnoresAlarms() { + let b1 = block(0, 10, alarmOffsets: [-900]) + let b2 = block(0, 10, alarmOffsets: [-1800]) + XCTAssertEqual(b1, b2) + XCTAssertEqual(Set([b1, b2]).count, 1) + } + func testMergeBlocksUnsortedInput() { let blocks = [block(30, 40), block(0, 10), block(10, 20)] let merged = mergeBlocks(blocks, gapMinutes: 0) @@ -150,8 +166,8 @@ final class BlockMathTests: XCTestCase { } func testUniqueBlocksByIDDifferentOccurrence() { - let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d) - let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600)) + let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil) + let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil) let result = uniqueBlocks([b1, b2], trackByID: true) XCTAssertEqual(result.count, 2) } diff --git a/BusyMirrorTests/SettingsPayloadTests.swift b/BusyMirrorTests/SettingsPayloadTests.swift new file mode 100644 index 0000000..aa8b211 --- /dev/null +++ b/BusyMirrorTests/SettingsPayloadTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import BusyMirror + +final class SettingsPayloadTests: XCTestCase { + + // Only the fields present in the very first schema. Every key added since + // (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters, + // excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs, + // appVersion, exportedAt) is deliberately missing here, simulating a + // settings.v2 blob written by an older build. + private let oldSchemaJSON = """ + { + "daysBack": 3, + "daysForward": 10, + "mergeGapHours": 1, + "hideDetails": false, + "copyDescription": true, + "mirrorAllDay": true, + "overlapMode": "skipCovered", + "titlePrefix": "🪞 ", + "placeholderTitle": "Busy", + "autoDeleteMissing": true, + "routes": [] + } + """ + + func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws { + let data = oldSchemaJSON.data(using: .utf8)! + let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) + + // Fields present in the old blob are preserved. + XCTAssertEqual(payload.daysBack, 3) + XCTAssertEqual(payload.daysForward, 10) + XCTAssertEqual(payload.overlapMode, "skipCovered") + + // Fields missing from the old blob fall back to their defaults instead + // of failing the whole decode. + XCTAssertEqual(payload.syncReminders, false) + XCTAssertEqual(payload.filterByWorkHours, false) + XCTAssertEqual(payload.workHoursStart, 9) + XCTAssertEqual(payload.workHoursEnd, 17) + XCTAssertEqual(payload.excludedTitleFilters, []) + XCTAssertEqual(payload.mirrorAcceptedOnly, false) + XCTAssertNil(payload.selectedSourceID) + XCTAssertNil(payload.selectedTargetIDs) + } + + func testEncodeDecodeRoundTrip() throws { + let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false) + let original = ContentView.SettingsPayload( + daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false, + mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18, + excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true, + overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true, + routes: [route] + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) + XCTAssertEqual(decoded.daysBack, original.daysBack) + XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters) + XCTAssertEqual(decoded.routes.count, 1) + XCTAssertEqual(decoded.routes[0].sourceID, "a") + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e610e9..9db8379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to BusyMirror will be documented in this file. +## [1.6.1] - 2026-08-26 + +### Added +- **Sync reminders**: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms/relative offsets into mirrored placeholders. This lets calendars that are synced to a phone ring for mirrored events. When merging is enabled, only the first event's alarms are preserved for a merged block. ([ContentView.swift](BusyMirror/ContentView.swift), [MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift)) +- CLI flag `--sync-reminders` to enable reminder syncing from scripted/headless runs. +- CLI polish: `--help`/`-h`, `--list-calendars`, and `--status` (all support `--json` for machine-readable output). Scheduled/headless runs now record last-run time, success/failure, and a summary so `--status` can report real diagnostics instead of just log-file grepping. Failure paths (`no calendar access`, `no saved routes`) now exit with distinct nonzero codes instead of always exiting 0. ([ContentView.swift](BusyMirror/ContentView.swift)) + +### Fixed +- **Settings silently wiped on upgrade**: `SettingsPayload` used fully auto-synthesized `Codable`, so decoding a settings blob written by an older build (missing a field added since, e.g. `syncReminders`) threw `keyNotFound` and failed the *entire* decode — not just that one field. The app then ran with in-code defaults (empty routes, filters, etc.), and the next autosave persisted that empty state back over the real data. `SettingsPayload` now has a custom `init(from:)` that reads every field added after the first release with `decodeIfPresent` + its existing default, matching the pattern `Route` already used. `loadSettingsFromDefaults()` also now recovers routes from the legacy `routes.v1` backup key if `settings.v2` decodes successfully but with an empty `routes` array — repairing installs that already hit this bug before upgrading. ([ContentView.swift](BusyMirror/ContentView.swift), [SettingsPayloadTests.swift](BusyMirrorTests/SettingsPayloadTests.swift)) + ## [1.5.1] - 2026-05-27 ### Fixed diff --git a/README.md b/README.md index cc0139d..8036a2a 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,15 @@ See `CHANGELOG.md` for notable changes. - `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit` - Run the routes already saved in the app settings: - `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit` +- `--help` prints full flag documentation. +- `--list-calendars [--json]` prints available calendars (index, id, title, source/type) and exits. +- `--status [--json]` prints last-run time/result, schedule state, and saved-route count and exits. - Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters. - Filters: - `--exclude-titles "token1, token2"` - `--exclude-organizers "alice@example.com, Example Org"` - Tokens are comma or newline separated; matching is case-insensitive. +- Exit codes: `0` success, `2` no calendar access, `3` no saved routes (with `--run-saved-routes`). ## Logs - BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`. diff --git a/ROADMAP.md b/ROADMAP.md index a32228f..4a039e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,19 +12,30 @@ - 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes - 1.3.6: generated macOS app icon set and packaged release assets - 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig` +- Calendar list already auto-refreshes on `EKEventStoreChanged` (does not yet trigger an auto-sync — see Next) +- CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary) -## Next -- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less) -- Better scheduled-run diagnostics in the UI (last run / last error / next run) -- Better server-side privacy mapping (per-provider heuristics) +## Next — reliability (V2 groundwork) +1. **Event-driven background sync.** The existing hourly `launchd StartInterval` only fires while the Mac happens to be awake at that instant and gets throttled/coalesced by macOS, so it's not reliable. Replace polling with reacting: + - Keep the app running as a login item via `SMAppService.agent` instead of relying on launchd to relaunch it. + - Extend the existing `EKEventStoreChanged` observer (today it only reloads the calendar list) to also trigger a debounced (~2-5s) auto-sync of saved routes. + - Add an `NSWorkspace.didWakeNotification` handler to resync after sleep. + - Keep one coarse fallback timer (e.g. every 30 min) purely as a safety net for a missed notification — not the primary mechanism. + - `launchd`'s remaining job shrinks to "make sure the app is running," which `SMAppService` likely covers, so the plist-generation code may become unnecessary. +2. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently. -## Then -- Signed/notarized binaries and release pipeline -- CLI quality: friendlier `--routes` parsing and help flag -- “Dry-run by default” preference +## Then — V2 UI polish +- Split `ContentView.swift` (~1800 lines doing UI + settings + CLI + scheduling) into per-section view models (Routes, Schedule, Privacy, Log) so views are small and native-feeling. +- Real `Settings { }` scene instead of the main window doubling as preferences. +- Menu bar icon reflects state (idle / syncing / error) instead of a static icon. +- Surface last-sync-time / last-error / next-check in the menu bar UI — the data now exists (`--status`'s `lastRunAtISO`/`lastRunOK`/`lastRunSummary`), this is just wiring it into the menu. +- Better server-side privacy mapping (per-provider heuristics). ## Later -- Background monitoring (macOS) -- Smarter cleanup & conflict resolution -- iOS/iPadOS helper (Shortcuts integration) -- Profiles & MDM/Managed Config support +- Signed/notarized binaries and release pipeline. +- Smarter cleanup & conflict resolution. +- iOS/iPadOS helper (Shortcuts integration). +- Profiles & MDM/Managed Config support. + +## Decided against +- **Direct Google/CalDAV/Exchange API integration** (OAuth flows, token storage, per-provider clients so BusyMirror can mirror a non-local calendar without it being added to macOS). EventKit already surfaces any calendar added via System Settings → Internet Accounts — macOS does the sync, auth, and refresh. Building a parallel integration would duplicate the OS for the narrow case of an account that can't or won't be added system-wide (e.g. MDM-restricted work accounts). Not worth the OAuth/Keychain/per-provider-quirk surface for that.