Fix Sync Now toolbar button showing icon-only, no text label. Confirmed via screenshot that 1.9.1's sidebar/toolbar fixes worked; this was the remaining clarity issue -- forced .labelStyle(.titleAndIcon) so the label always shows. All unit tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1593 lines
70 KiB
Swift
1593 lines
70 KiB
Swift
import SwiftUI
|
||
import EventKit
|
||
import AppKit
|
||
|
||
|
||
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
||
case allow, skipCovered, fillGaps
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
enum ScheduleMode: String, CaseIterable, Identifiable {
|
||
case hourly, daily, weekdays
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .hourly: return "Hourly"
|
||
case .daily: return "Daily"
|
||
case .weekdays: return "Weekdays"
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
|
||
case routes, schedule, log
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .routes: return "Routes"
|
||
case .schedule: return "Schedule"
|
||
case .log: return "Activity Log"
|
||
}
|
||
}
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .routes: return "arrow.triangle.branch"
|
||
case .schedule: return "clock"
|
||
case .log: return "terminal"
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
struct Route: Identifiable, Hashable, Codable {
|
||
let id = UUID()
|
||
var sourceID: String
|
||
var targetIDs: Set<String>
|
||
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, syncReminders, mergeGapHours, overlap, allDay }
|
||
|
||
init(sourceID: String, targetIDs: Set<String>, 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
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.sourceID = try c.decode(String.self, forKey: .sourceID)
|
||
self.targetIDs = try c.decode(Set<String>.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)
|
||
|
||
}
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@EnvironmentObject private var appController: BusyMirrorAppController
|
||
@State private var store = EKEventStore()
|
||
@State private var hasAccess = false
|
||
@State private var calendars: [EKCalendar] = []
|
||
@State private var sourceIndex: Int = 0
|
||
@State private var targetSelections = Set<Int>() // indices in calendars
|
||
// Stable selection storage by persistent identifiers (survives reordering)
|
||
@State private var sourceID: String? = nil
|
||
@State private var targetIDs = Set<String>()
|
||
@State private var routes: [Route] = []
|
||
@State private var selectedSection: SidebarSection? = .routes
|
||
@State private var manualSelectionExpanded = false
|
||
@AppStorage("daysForward") private var daysForward: Int = 7
|
||
@AppStorage("daysBack") private var daysBack: Int = 1
|
||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||
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
|
||
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
|
||
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
|
||
@AppStorage("scheduleMode") private var scheduleModeRaw: String = ScheduleMode.weekdays.rawValue
|
||
@AppStorage("scheduleHour") private var scheduleHour: Int = 8
|
||
@AppStorage("scheduleMinute") private var scheduleMinute: Int = 0
|
||
@AppStorage("scheduleWeekdaysOnly") private var scheduleWeekdaysOnly: Bool = true
|
||
@AppStorage("scheduleIntervalHours") private var scheduleIntervalHours: Int = 1
|
||
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
|
||
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
|
||
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
|
||
var overlapMode: OverlapMode {
|
||
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
|
||
nonmutating set { overlapModeRaw = newValue.rawValue }
|
||
}
|
||
@State private var writeEnabled = false // dry-run unless checked
|
||
@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<Void, Never>? = nil
|
||
@State private var progressText: String? = nil
|
||
/// Token for the EKEventStoreChanged observer; nil until calendar access is granted.
|
||
@State private var storeObserver: NSObjectProtocol? = nil
|
||
// Run-session guard: prevents the same source event from being mirrored
|
||
// into the same target more than once across multiple routes within a
|
||
// single "Mirror Now" click.
|
||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||
|
||
// Mirrors can run either by manual selection (source + at least one target)
|
||
// or using predefined routes. This derived flag controls the Mirror Now button.
|
||
private var canRunMirrorNow: Bool {
|
||
let hasManualTargets = !targetIDs.isEmpty
|
||
let hasRouteTargets = routes.contains { !$0.targetIDs.isEmpty }
|
||
return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets)
|
||
}
|
||
|
||
private static let intFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 0
|
||
f.maximum = 720
|
||
return f
|
||
}()
|
||
|
||
private static let hourFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 0
|
||
f.maximum = 24
|
||
return f
|
||
}()
|
||
|
||
private static let smallIntFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.numberStyle = .none
|
||
f.minimum = 1
|
||
f.maximum = 24
|
||
return f
|
||
}()
|
||
|
||
private var scheduleMode: ScheduleMode {
|
||
get { ScheduleMode(rawValue: scheduleModeRaw) ?? (scheduleWeekdaysOnly ? .weekdays : .daily) }
|
||
nonmutating set { scheduleModeRaw = newValue.rawValue }
|
||
}
|
||
|
||
private var launchAgentLabel: String { "com.cqrenet.BusyMirror.saved-routes" }
|
||
|
||
private var launchAgentURL: URL {
|
||
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
|
||
}
|
||
|
||
private var hasInstalledSchedule: Bool {
|
||
FileManager.default.fileExists(atPath: launchAgentURL.path)
|
||
}
|
||
|
||
private var scheduleSummary: String {
|
||
switch scheduleMode {
|
||
case .hourly:
|
||
return "every \(scheduleIntervalHours) hour" + (scheduleIntervalHours == 1 ? "" : "s")
|
||
case .daily:
|
||
return String(format: "%02d:%02d daily", scheduleHour, scheduleMinute)
|
||
case .weekdays:
|
||
return String(format: "%02d:%02d weekdays", scheduleHour, scheduleMinute)
|
||
}
|
||
}
|
||
|
||
private func clampScheduleTime() {
|
||
let nextHour = min(max(scheduleHour, 0), 23)
|
||
if nextHour != scheduleHour { scheduleHour = nextHour }
|
||
let nextMinute = min(max(scheduleMinute, 0), 59)
|
||
if nextMinute != scheduleMinute { scheduleMinute = nextMinute }
|
||
let nextInterval = min(max(scheduleIntervalHours, 1), 24)
|
||
if nextInterval != scheduleIntervalHours { scheduleIntervalHours = nextInterval }
|
||
}
|
||
|
||
private func launchAgentScheduleProperties() -> [String: Any] {
|
||
switch scheduleMode {
|
||
case .hourly:
|
||
return ["StartInterval": scheduleIntervalHours * 3600]
|
||
case .daily:
|
||
return ["StartCalendarInterval": ["Hour": scheduleHour, "Minute": scheduleMinute]]
|
||
case .weekdays:
|
||
let intervals: [[String: Int]] = (1...5).map { weekday in
|
||
["Hour": scheduleHour, "Minute": scheduleMinute, "Weekday": weekday]
|
||
}
|
||
return ["StartCalendarInterval": intervals]
|
||
}
|
||
}
|
||
|
||
private func launchCtl(_ arguments: [String], allowFailure: Bool = false) throws -> String {
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||
proc.arguments = arguments
|
||
let stdout = Pipe()
|
||
let stderr = Pipe()
|
||
proc.standardOutput = stdout
|
||
proc.standardError = stderr
|
||
try proc.run()
|
||
proc.waitUntilExit()
|
||
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
|
||
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
|
||
let outText = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let errText = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let combined = [outText, errText].filter { !$0.isEmpty }.joined(separator: "\n")
|
||
if proc.terminationStatus != 0 && !allowFailure {
|
||
throw NSError(
|
||
domain: "BusyMirrorLaunchCtl",
|
||
code: Int(proc.terminationStatus),
|
||
userInfo: [NSLocalizedDescriptionKey: combined.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : combined]
|
||
)
|
||
}
|
||
return combined
|
||
}
|
||
|
||
private func installSchedule() {
|
||
guard !routes.isEmpty else {
|
||
log("Cannot install schedule: no saved routes.")
|
||
return
|
||
}
|
||
clampScheduleTime()
|
||
do {
|
||
let fm = FileManager.default
|
||
try fm.createDirectory(at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
try fm.createDirectory(at: AppLogStore.logDirectoryURL, withIntermediateDirectories: true)
|
||
|
||
let executablePath = Bundle.main.executableURL?.path
|
||
?? Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/BusyMirror").path
|
||
let plist: [String: Any] = [
|
||
"Label": launchAgentLabel,
|
||
"ProgramArguments": [executablePath, "--run-saved-routes", "--write", "1", "--exit"],
|
||
"RunAtLoad": false,
|
||
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
|
||
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
|
||
"WorkingDirectory": NSHomeDirectory(),
|
||
"EnvironmentVariables": ["HOME": NSHomeDirectory()]
|
||
].merging(launchAgentScheduleProperties()) { _, new in new }
|
||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||
try data.write(to: launchAgentURL, options: .atomic)
|
||
|
||
let domain = "gui/\(getuid())"
|
||
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||
_ = try launchCtl(["bootstrap", domain, launchAgentURL.path])
|
||
log("Installed schedule at \(scheduleSummary). LaunchAgent: \(launchAgentURL.path)")
|
||
} catch {
|
||
log("Failed to install schedule: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func removeSchedule() {
|
||
do {
|
||
let domain = "gui/\(getuid())"
|
||
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
|
||
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||
try FileManager.default.removeItem(at: launchAgentURL)
|
||
}
|
||
log("Removed schedule: \(launchAgentURL.path)")
|
||
} catch {
|
||
log("Failed to remove schedule: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
// Deterministic ordering to keep indices stable across runs
|
||
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
|
||
return cals.sorted { a, b in
|
||
if a.source.title != b.source.title { return a.source.title < b.source.title }
|
||
if a.title != b.title { return a.title < b.title }
|
||
return a.calendarIdentifier < b.calendarIdentifier
|
||
}
|
||
}
|
||
|
||
private var excludedTitleFilterList: [String] {
|
||
excludedTitleFiltersRaw
|
||
.split { $0 == "\n" || $0 == "," }
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
}
|
||
|
||
private var excludedTitleFilterTerms: [String] {
|
||
excludedTitleFilterList.map { $0.lowercased() }
|
||
}
|
||
|
||
private var excludedOrganizerFilterList: [String] {
|
||
excludedOrganizerFiltersRaw
|
||
.split { $0 == "\n" || $0 == "," }
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
}
|
||
|
||
private var excludedOrganizerFilterTerms: [String] {
|
||
excludedOrganizerFilterList.map { $0.lowercased() }
|
||
}
|
||
|
||
private func rebuildSelectionsFromIDs() {
|
||
// Map IDs -> indices in current calendars
|
||
var idToIndex: [String:Int] = [:]
|
||
for (i, c) in calendars.enumerated() { idToIndex[c.calendarIdentifier] = i }
|
||
if calendars.isEmpty {
|
||
sourceIndex = 0
|
||
sourceID = nil
|
||
targetSelections.removeAll()
|
||
targetIDs.removeAll()
|
||
return
|
||
}
|
||
// Drop selections that no longer exist in EventKit
|
||
targetIDs = Set(targetIDs.filter { idToIndex[$0] != nil })
|
||
// Restore source index from sourceID if possible
|
||
if let sid = sourceID, let idx = idToIndex[sid] { sourceIndex = idx }
|
||
else if !calendars.isEmpty { sourceIndex = min(sourceIndex, calendars.count - 1); sourceID = calendars[sourceIndex].calendarIdentifier }
|
||
// Ensure selected source is never a target
|
||
if let sid = sourceID { targetIDs.remove(sid) }
|
||
// Restore targets from IDs
|
||
let restored = targetIDs.compactMap { idToIndex[$0] }
|
||
targetSelections = Set(restored).filter { $0 != sourceIndex }
|
||
}
|
||
|
||
private func pruneStaleCalendarReferences() -> (removedTargets: Int, droppedRoutes: Int, trimmedRoutes: Int, removedSource: Bool) {
|
||
let validIDs = Set(calendars.map { $0.calendarIdentifier })
|
||
|
||
let originalTargets = targetIDs
|
||
targetIDs = Set(originalTargets.filter { validIDs.contains($0) })
|
||
let removedTargets = originalTargets.count - targetIDs.count
|
||
|
||
var removedSource = false
|
||
if let sid = sourceID, !validIDs.contains(sid) {
|
||
sourceID = nil
|
||
removedSource = true
|
||
}
|
||
|
||
var droppedRoutes = 0
|
||
var trimmedRoutes = 0
|
||
if !routes.isEmpty {
|
||
var cleaned: [Route] = []
|
||
cleaned.reserveCapacity(routes.count)
|
||
for route in routes {
|
||
guard validIDs.contains(route.sourceID) else {
|
||
droppedRoutes += 1
|
||
continue
|
||
}
|
||
|
||
var filteredTargets = Set(route.targetIDs.filter { validIDs.contains($0) })
|
||
filteredTargets.remove(route.sourceID)
|
||
guard !filteredTargets.isEmpty else {
|
||
droppedRoutes += 1
|
||
continue
|
||
}
|
||
|
||
var next = route
|
||
if filteredTargets != route.targetIDs {
|
||
next.targetIDs = filteredTargets
|
||
trimmedRoutes += 1
|
||
}
|
||
cleaned.append(next)
|
||
}
|
||
routes = cleaned
|
||
}
|
||
|
||
return (removedTargets: removedTargets, droppedRoutes: droppedRoutes, trimmedRoutes: trimmedRoutes, removedSource: removedSource)
|
||
}
|
||
|
||
private func indexForCalendar(id: String) -> Int? { calendars.firstIndex(where: { $0.calendarIdentifier == id }) }
|
||
private func labelForCalendar(id: String) -> String { calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id }
|
||
|
||
|
||
// Ensure the currently selected source calendar is not present in targets
|
||
private func enforceNoSourceInTargets() {
|
||
guard sourceIndex < calendars.count else { return }
|
||
let sid = calendars[sourceIndex].calendarIdentifier
|
||
// Remove by index
|
||
if targetSelections.contains(sourceIndex) {
|
||
targetSelections.remove(sourceIndex)
|
||
}
|
||
// Remove by ID (in case indices shifted)
|
||
targetIDs.remove(sid)
|
||
}
|
||
|
||
private func addRouteFromCurrentSelection() {
|
||
guard let sid = sourceID, !targetIDs.isEmpty else { return }
|
||
let r = Route(sourceID: sid,
|
||
targetIDs: targetIDs,
|
||
privacy: hideDetails,
|
||
copyNotes: copyDescription,
|
||
syncReminders: syncReminders,
|
||
mergeGapHours: mergeGapHours,
|
||
overlap: overlapMode,
|
||
allDay: mirrorAllDay)
|
||
routes.append(r)
|
||
}
|
||
|
||
private func runConfiguredRoutes(_ configuredRoutes: [Route], sessionGuard: inout Set<String>) async {
|
||
var ranAnyRoute = false
|
||
var skippedMissingSource = 0
|
||
var skippedNoTargets = 0
|
||
|
||
for (idx, r) in configuredRoutes.enumerated() {
|
||
if Task.isCancelled { break }
|
||
guard let sIdx = indexForCalendar(id: r.sourceID) else {
|
||
skippedMissingSource += 1
|
||
continue
|
||
}
|
||
|
||
let validTargets = Set(r.targetIDs.filter { tid in
|
||
tid != r.sourceID && indexForCalendar(id: tid) != nil
|
||
})
|
||
guard !validTargets.isEmpty else {
|
||
skippedNoTargets += 1
|
||
continue
|
||
}
|
||
|
||
ranAnyRoute = true
|
||
let config = MirrorConfig(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapMin: max(0, r.mergeGapHours * 60),
|
||
hideDetails: r.privacy,
|
||
copyDescription: r.copyNotes,
|
||
mirrorAllDay: r.allDay,
|
||
overlapMode: r.overlap,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
writeEnabled: writeEnabled,
|
||
syncReminders: r.syncReminders
|
||
)
|
||
let srcCal = calendars[sIdx]
|
||
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
|
||
await MainActor.run {
|
||
sourceIndex = sIdx
|
||
sourceID = r.sourceID
|
||
targetIDs = validTargets
|
||
targetIDs.remove(r.sourceID)
|
||
progressText = "Route \(idx + 1) of \(configuredRoutes.count)"
|
||
}
|
||
let engine = makeEngine()
|
||
await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||
}
|
||
|
||
if skippedMissingSource > 0 {
|
||
log("- SKIP routes with missing source calendar: \(skippedMissingSource)")
|
||
}
|
||
if skippedNoTargets > 0 {
|
||
log("- SKIP routes with no valid targets: \(skippedNoTargets)")
|
||
}
|
||
if !ranAnyRoute {
|
||
log("No valid routes to run. Refresh calendars and update your route selections.")
|
||
}
|
||
}
|
||
|
||
private func makeMirrorConfig() -> MirrorConfig {
|
||
MirrorConfig(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapMin: mergeGapMin,
|
||
hideDetails: hideDetails,
|
||
copyDescription: copyDescription,
|
||
mirrorAllDay: mirrorAllDay,
|
||
overlapMode: overlapMode,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
writeEnabled: writeEnabled,
|
||
syncReminders: syncReminders
|
||
)
|
||
}
|
||
|
||
private func startMirrorNow() {
|
||
guard !appController.isSyncing else { return }
|
||
guard mirrorTask == nil else { return }
|
||
appController.setSyncing(true)
|
||
isRunning = true
|
||
progressText = nil
|
||
mirrorTask = Task {
|
||
defer {
|
||
Task { @MainActor in
|
||
appController.setSyncing(false)
|
||
isRunning = false
|
||
mirrorTask = nil
|
||
progressText = nil
|
||
}
|
||
}
|
||
var sessionGuard = Set<String>()
|
||
if routes.isEmpty {
|
||
guard calendars.indices.contains(sourceIndex) else {
|
||
log("Cannot mirror: selected source is invalid.")
|
||
return
|
||
}
|
||
let config = makeMirrorConfig()
|
||
let srcCal = calendars[sourceIndex]
|
||
await MainActor.run {
|
||
sourceID = srcCal.calendarIdentifier
|
||
enforceNoSourceInTargets()
|
||
}
|
||
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
let engine = makeEngine()
|
||
await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||
} else {
|
||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelMirror() {
|
||
mirrorTask?.cancel()
|
||
mirrorTask = nil
|
||
appController.setSyncing(false)
|
||
isRunning = false
|
||
log("Cancelled.")
|
||
}
|
||
|
||
private func handlePendingMenuBarSyncIfNeeded() {
|
||
guard appController.hasPendingSyncRequest else { return }
|
||
guard !isRunning else { return }
|
||
guard hasAccess else { return }
|
||
guard !calendars.isEmpty else { return }
|
||
guard canRunMirrorNow else {
|
||
appController.clearPendingSyncRequest()
|
||
log("Menu bar sync requested, but no valid manual targets or saved routes are available.")
|
||
return
|
||
}
|
||
appController.clearPendingSyncRequest()
|
||
startMirrorNow()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var sidebarView: some View {
|
||
List(selection: $selectedSection) {
|
||
ForEach(SidebarSection.allCases) { section in
|
||
Label {
|
||
HStack {
|
||
Text(section.title)
|
||
if section == .routes && !routes.isEmpty {
|
||
Spacer()
|
||
Text("\(routes.count)")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} icon: {
|
||
Image(systemName: section.icon)
|
||
}
|
||
.tag(section)
|
||
}
|
||
}
|
||
.listStyle(.sidebar)
|
||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var accessNeededView: some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "lock.fill")
|
||
.font(.system(size: 40))
|
||
.foregroundStyle(.secondary)
|
||
Text("Calendar Permission Needed")
|
||
.font(.title3.weight(.semibold))
|
||
Text("BusyMirror needs access to read and mirror your events.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
Button("Request Calendar Access") {
|
||
requestAccess()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.navigationTitle("BusyMirror")
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var routesDetailView: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
DisclosureGroup("Manual Selection", isExpanded: $manualSelectionExpanded) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
CalendarsSectionView(
|
||
calendars: calendars,
|
||
sourceIndex: $sourceIndex,
|
||
targetSelections: $targetSelections,
|
||
targetIDs: $targetIDs,
|
||
isRunning: isRunning
|
||
)
|
||
HStack {
|
||
Spacer()
|
||
Button("Add Route from Selection", action: addRouteFromCurrentSelection)
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isRunning || sourceID == nil || targetIDs.isEmpty)
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
.font(.headline)
|
||
|
||
Divider()
|
||
|
||
RoutesSectionView(
|
||
routes: $routes,
|
||
calendars: calendars,
|
||
isRunning: isRunning,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
canAddRoute: sourceID != nil && !targetIDs.isEmpty,
|
||
onAddRoute: addRouteFromCurrentSelection
|
||
)
|
||
}
|
||
.padding(20)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var scheduleDetailView: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Automatic Sync")
|
||
.font(.title2.weight(.bold))
|
||
Text("Runs on its own when your calendars change — no schedule to manage.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: appController.autoSyncArmed ? "checkmark.circle.fill" : "circle.dashed")
|
||
.foregroundStyle(appController.autoSyncArmed ? .green : .secondary)
|
||
.font(.title3)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(appController.autoSyncArmed ? "Auto-sync active" : "Auto-sync not active")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(appController.autoSyncArmed ? "Watching for calendar changes, wake, and a 30-min fallback" : "Add a saved route below to enable")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(14)
|
||
Divider()
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "clock")
|
||
.foregroundStyle(.secondary)
|
||
.font(.title3)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Last sync")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(appController.lastRunStatusText)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(14)
|
||
}
|
||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(nsColor: .controlBackgroundColor)))
|
||
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.primary.opacity(0.12)))
|
||
|
||
Divider()
|
||
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Manual Schedule (optional)")
|
||
.font(.subheadline.weight(.semibold))
|
||
Text("Add a fixed-time schedule on top of auto-sync — a guaranteed full resync at a specific hour regardless of what changed.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
ScheduleSectionView(
|
||
scheduleMode: Binding(
|
||
get: { scheduleMode },
|
||
set: { newValue in
|
||
scheduleMode = newValue
|
||
scheduleWeekdaysOnly = (newValue == .weekdays)
|
||
}
|
||
),
|
||
scheduleIntervalHours: $scheduleIntervalHours,
|
||
scheduleHour: $scheduleHour,
|
||
scheduleMinute: $scheduleMinute,
|
||
isRunning: isRunning,
|
||
routesEmpty: routes.isEmpty,
|
||
hasInstalledSchedule: hasInstalledSchedule,
|
||
scheduleSummary: scheduleSummary,
|
||
onInstall: installSchedule,
|
||
onRemove: removeSchedule,
|
||
onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) },
|
||
onScheduleTimeChanged: clampScheduleTime
|
||
)
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
}
|
||
|
||
private var statusSubtitle: String {
|
||
if let progressText { return progressText }
|
||
if isRunning { return "Running…" }
|
||
return hasAccess ? "\(calendars.count) calendars" : "No calendar access"
|
||
}
|
||
|
||
@ToolbarContentBuilder
|
||
private var toolbarContent: some ToolbarContent {
|
||
ToolbarItemGroup {
|
||
Picker("Mode", selection: $writeEnabled) {
|
||
Text("Dry Run").tag(false)
|
||
Text("Write").tag(true)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(width: 150)
|
||
.disabled(isRunning)
|
||
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
|
||
|
||
if isRunning {
|
||
Button("Cancel") { cancelMirror() }
|
||
} else {
|
||
Button {
|
||
startMirrorNow()
|
||
} label: {
|
||
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||
.labelStyle(.titleAndIcon)
|
||
}
|
||
.disabled(!canRunMirrorNow)
|
||
}
|
||
|
||
Menu {
|
||
Button("Export Settings…") { exportSettings() }
|
||
Button("Import Settings…") { importSettings() }
|
||
Divider()
|
||
Button("Reveal Log File") {
|
||
NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL])
|
||
}
|
||
Divider()
|
||
Button("Cleanup Placeholders") {
|
||
if writeEnabled {
|
||
confirmCleanup = true
|
||
} else {
|
||
Task {
|
||
if routes.isEmpty {
|
||
await runCleanupForCurrentSelection()
|
||
} else {
|
||
for r in routes {
|
||
await runCleanupForRoute(r)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.disabled(isRunning)
|
||
Button("Refresh Calendars") {
|
||
reloadCalendars(forceResetStore: true)
|
||
}
|
||
.disabled(isRunning)
|
||
Divider()
|
||
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
||
requestAccess()
|
||
}
|
||
.disabled(isRunning)
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
}
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
sidebarView
|
||
} detail: {
|
||
Group {
|
||
if !hasAccess {
|
||
accessNeededView
|
||
} else {
|
||
switch selectedSection ?? .routes {
|
||
case .routes: routesDetailView
|
||
case .schedule: scheduleDetailView
|
||
case .log: LogSectionView(logText: $logText)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle((selectedSection ?? .routes).title)
|
||
.navigationSubtitle(statusSubtitle)
|
||
.toolbar { toolbarContent }
|
||
}
|
||
.confirmationDialog(
|
||
"Delete mirrored placeholders?",
|
||
isPresented: $confirmCleanup,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Delete now", role: .destructive) {
|
||
Task {
|
||
if routes.isEmpty {
|
||
await runCleanupForCurrentSelection()
|
||
} else {
|
||
for r in routes {
|
||
await runCleanupForRoute(r)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
let prefixNote = titlePrefix.isEmpty ? "" : " (title prefix ‘\(titlePrefix)’)"
|
||
Text("This will remove events identified as mirrored by URL prefix\(prefixNote) within the current window (Days back/forward) from the selected target calendars.")
|
||
}
|
||
.onAppear {
|
||
appController.setMainWindowVisible(true)
|
||
AppLogStore.append("=== BusyMirror launch ===")
|
||
log("Log file: \(AppLogStore.logFileURL.path)")
|
||
requestAccess()
|
||
loadSettingsFromDefaults()
|
||
tryRunCLIIfPresent()
|
||
enforceNoSourceInTargets()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
if !isCLIRun {
|
||
appController.bootstrapBackgroundSync()
|
||
}
|
||
}
|
||
.onDisappear {
|
||
appController.setMainWindowVisible(false)
|
||
unregisterStoreObserver()
|
||
}
|
||
// Persist key settings whenever they change, to ensure restore between runs
|
||
.onChange(of: appController.syncRequestToken) { _ in
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
.onChange(of: daysBack) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: daysForward) { _ in saveSettingsToDefaults() }
|
||
.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() }
|
||
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: workHoursStart) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: workHoursEnd) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() }
|
||
.onChange(of: sourceIndex) { newValue in
|
||
// Track selected source by persistent ID and ensure it is not a target
|
||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
}
|
||
.onChange(of: targetSelections) { _ in
|
||
// If the new source is accidentally included, drop it
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
}
|
||
.onChange(of: targetIDs) { _ in
|
||
// If IDs contain the source’s ID, drop it
|
||
enforceNoSourceInTargets()
|
||
saveSettingsToDefaults()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
.onChange(of: routes) { _ in
|
||
saveSettingsToDefaults()
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
appController.armAutoSyncIfPossible()
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
let v = args[i+1].lowercased()
|
||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||
}
|
||
return def
|
||
}
|
||
func intArg(_ name: String, default def: Int) -> Int {
|
||
if let i = args.firstIndex(of: name), i+1 < args.count, let n = Int(args[i+1]) { return n }
|
||
return def
|
||
}
|
||
func strArg(_ name: String) -> String? {
|
||
if let i = args.firstIndex(of: name), i+1 < args.count { return args[i+1] }
|
||
return nil
|
||
}
|
||
|
||
// 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)
|
||
daysBack = intArg("--days-back", default: daysBack)
|
||
mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours)
|
||
if let modeStr = strArg("--mode")?.lowercased() {
|
||
switch modeStr {
|
||
case "allow": overlapModeRaw = OverlapMode.allow.rawValue
|
||
case "skipcovered", "skip": overlapModeRaw = OverlapMode.skipCovered.rawValue
|
||
case "fillgaps", "gaps": overlapModeRaw = OverlapMode.fillGaps.rawValue
|
||
default: break
|
||
}
|
||
}
|
||
// Optional filters via CLI
|
||
if let tFilters = strArg("--exclude-titles") { excludedTitleFiltersRaw = tFilters }
|
||
if let oFilters = strArg("--exclude-organizers") { excludedOrganizerFiltersRaw = oFilters }
|
||
|
||
let routesSpec = {
|
||
guard let routesIdx else { return "" }
|
||
return (routesIdx + 1 < args.count) ? args[routesIdx + 1] : ""
|
||
}()
|
||
let routeParts = routesSpec.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }
|
||
if runSavedRoutes {
|
||
log("CLI: run saved routes")
|
||
} else {
|
||
log("CLI: routes=\(routesSpec)")
|
||
}
|
||
Task {
|
||
// If permission already granted, force a sync calendar reload so
|
||
// the CLI doesn't race the async permission callback.
|
||
if hasAccess {
|
||
await MainActor.run { reloadCalendars() }
|
||
}
|
||
// Wait up to ~10s for calendars to load
|
||
for _ in 0..<50 {
|
||
if hasAccess && !calendars.isEmpty { break }
|
||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||
}
|
||
guard hasAccess, !calendars.isEmpty else {
|
||
log("CLI: no calendar access; aborting")
|
||
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<String>()
|
||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routes.count) saved route(s)")
|
||
}
|
||
} else {
|
||
for part in routeParts where !part.isEmpty {
|
||
let lr = part.split(separator: "->", maxSplits: 1).map { String($0) }
|
||
guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
||
let srcIdx0 = max(0, s1 - 1)
|
||
let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 }
|
||
if srcIdx0 >= calendars.count { continue }
|
||
let srcCal = calendars[srcIdx0]
|
||
let targetSet = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
await MainActor.run {
|
||
sourceIndex = srcIdx0
|
||
sourceID = srcCal.calendarIdentifier
|
||
targetSelections = Set(tgtIdxs0)
|
||
targetIDs = targetSet
|
||
}
|
||
|
||
if boolArg("--cleanup-only", default: false) {
|
||
log("CLI: cleanup route \(part)")
|
||
let engine = makeEngine()
|
||
await engine.runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
} else {
|
||
log("CLI: mirror route \(part)")
|
||
let engine = makeEngine()
|
||
var sessionGuard = Set<String>()
|
||
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
|
||
// --routes to pre-populate a run without auto-quitting.
|
||
if CommandLine.arguments.contains("--exit") {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Permissions & Calendars
|
||
@MainActor
|
||
func requestAccess() {
|
||
log("Requesting calendar access…")
|
||
if #available(macOS 14.0, *) {
|
||
store.requestFullAccessToEvents { granted, _ in
|
||
DispatchQueue.main.async {
|
||
hasAccess = granted
|
||
if granted {
|
||
// Reinitialize the store after permission changes to ensure sources load
|
||
store = EKEventStore()
|
||
reloadCalendars(pruneRoutes: false)
|
||
} else {
|
||
appController.clearPendingSyncRequest()
|
||
}
|
||
log(granted ? "Access granted." : "Access denied.")
|
||
}
|
||
}
|
||
} else {
|
||
store.requestAccess(to: .event) { granted, _ in
|
||
DispatchQueue.main.async {
|
||
hasAccess = granted
|
||
if granted {
|
||
// Reinitialize the store after permission changes to ensure sources load
|
||
store = EKEventStore()
|
||
reloadCalendars(pruneRoutes: false)
|
||
} else {
|
||
appController.clearPendingSyncRequest()
|
||
}
|
||
log(granted ? "Access granted." : "Access denied.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func reloadCalendars(forceResetStore: Bool = false, pruneRoutes: Bool = true) {
|
||
if forceResetStore {
|
||
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
||
// Unregister the existing EKEventStoreChanged observer first — it targets the
|
||
// old store object and would never fire again after the store is replaced.
|
||
unregisterStoreObserver()
|
||
store = EKEventStore()
|
||
}
|
||
let fetched = store.calendars(for: .event)
|
||
calendars = sortedCalendars(fetched)
|
||
// A freshly-created EKEventStore (right after a permission grant) can report an
|
||
// incomplete calendar list for a moment before remote sources (Exchange, CalDAV)
|
||
// finish hydrating — pruning against that snapshot wrongly concludes a route's
|
||
// calendar is gone and deletes it. Confirmed happening on a real install: routes
|
||
// dropped on the very next launch, only saved by the legacy routes.v1 fallback.
|
||
// requestAccess() passes pruneRoutes: false for exactly that reload; the
|
||
// EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click
|
||
// (a warm, already-stable store) still prune as before.
|
||
if pruneRoutes {
|
||
let pruned = pruneStaleCalendarReferences()
|
||
if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource {
|
||
log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).")
|
||
saveSettingsToDefaults()
|
||
}
|
||
}
|
||
// Initialize IDs on first load
|
||
if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier }
|
||
// Rebuild index-based selections from stored IDs
|
||
rebuildSelectionsFromIDs()
|
||
log("Loaded \(calendars.count) calendars.")
|
||
// Register for live calendar-store changes the first time we have access,
|
||
// so the calendar list stays up-to-date without pressing "Refresh".
|
||
if storeObserver == nil {
|
||
storeObserver = NotificationCenter.default.addObserver(
|
||
forName: .EKEventStoreChanged,
|
||
object: store,
|
||
queue: .main
|
||
) { [self] _ in
|
||
// Skip silent background refreshes while a sync is running to
|
||
// avoid interfering with an in-progress mirror operation.
|
||
guard !isRunning else { return }
|
||
reloadCalendars()
|
||
}
|
||
}
|
||
handlePendingMenuBarSyncIfNeeded()
|
||
}
|
||
|
||
@MainActor
|
||
private func unregisterStoreObserver() {
|
||
if let token = storeObserver {
|
||
NotificationCenter.default.removeObserver(token)
|
||
storeObserver = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Export / Import Settings
|
||
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
|
||
var workHoursEnd: Int = 17
|
||
var excludedTitleFilters: [String] = []
|
||
var excludedOrganizerFilters: [String] = []
|
||
var mirrorAcceptedOnly: Bool = false
|
||
var overlapMode: String
|
||
var titlePrefix: String
|
||
var placeholderTitle: String
|
||
var autoDeleteMissing: Bool
|
||
var routes: [Route]
|
||
// UI selections (optional for backward compatibility)
|
||
var selectedSourceID: String? = nil
|
||
var selectedTargetIDs: [String]? = nil
|
||
// 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 {
|
||
SettingsPayload(
|
||
daysBack: daysBack,
|
||
daysForward: daysForward,
|
||
mergeGapHours: mergeGapHours,
|
||
hideDetails: hideDetails,
|
||
copyDescription: copyDescription,
|
||
mirrorAllDay: mirrorAllDay,
|
||
filterByWorkHours: filterByWorkHours,
|
||
workHoursStart: workHoursStart,
|
||
workHoursEnd: workHoursEnd,
|
||
excludedTitleFilters: excludedTitleFilterList,
|
||
excludedOrganizerFilters: excludedOrganizerFilterList,
|
||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||
overlapMode: overlapMode.rawValue,
|
||
titlePrefix: titlePrefix,
|
||
placeholderTitle: placeholderTitle,
|
||
autoDeleteMissing: autoDeleteMissing,
|
||
routes: routes,
|
||
selectedSourceID: sourceID,
|
||
selectedTargetIDs: Array(targetIDs).sorted(),
|
||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||
exportedAt: Date()
|
||
)
|
||
}
|
||
|
||
private func applySnapshot(_ s: SettingsPayload) {
|
||
daysBack = s.daysBack
|
||
daysForward = s.daysForward
|
||
mergeGapHours = s.mergeGapHours
|
||
hideDetails = s.hideDetails
|
||
copyDescription = s.copyDescription
|
||
syncReminders = s.syncReminders
|
||
mirrorAllDay = s.mirrorAllDay
|
||
filterByWorkHours = s.filterByWorkHours
|
||
workHoursStart = s.workHoursStart
|
||
workHoursEnd = s.workHoursEnd
|
||
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
|
||
excludedOrganizerFiltersRaw = s.excludedOrganizerFilters.joined(separator: "\n")
|
||
mirrorAcceptedOnly = s.mirrorAcceptedOnly
|
||
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
|
||
titlePrefix = s.titlePrefix
|
||
placeholderTitle = s.placeholderTitle
|
||
autoDeleteMissing = s.autoDeleteMissing
|
||
routes = s.routes
|
||
// Restore UI selections if provided
|
||
if let selSrc = s.selectedSourceID { sourceID = selSrc }
|
||
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
|
||
clampWorkHours()
|
||
// Rebuild indices from IDs after restoring selections
|
||
rebuildSelectionsFromIDs()
|
||
}
|
||
|
||
/// Used only at launch (`loadSettingsFromDefaults`) — restores just the
|
||
/// state that has no other persistence (`routes` and the manual
|
||
/// source/target selection are plain `@State`, not `@AppStorage`).
|
||
/// Deliberately does NOT touch the @AppStorage-backed fields even though
|
||
/// they're also present in the decoded snapshot: those already restore
|
||
/// themselves from their own UserDefaults keys before this ever runs, and
|
||
/// since Preferences is now a separate window, a value changed there
|
||
/// wouldn't necessarily have re-triggered `saveSettingsToDefaults()` —
|
||
/// applying the (possibly stale) snapshot copy on top would silently
|
||
/// revert a preference the user just changed. `applySnapshot` (the full
|
||
/// version) stays reserved for Import, where overwriting everything from
|
||
/// the imported file is exactly the point.
|
||
private func restoreLaunchState(from s: SettingsPayload) {
|
||
routes = s.routes
|
||
if let selSrc = s.selectedSourceID { sourceID = selSrc }
|
||
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
|
||
rebuildSelectionsFromIDs()
|
||
}
|
||
|
||
private func exportSettings() {
|
||
let panel = NSSavePanel()
|
||
panel.allowedFileTypes = ["json"]
|
||
panel.nameFieldStringValue = "BusyMirror-Settings.json"
|
||
panel.canCreateDirectories = true
|
||
panel.isExtensionHidden = false
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
do {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||
let data = try encoder.encode(makeSnapshot())
|
||
try data.write(to: url, options: Data.WritingOptions.atomic)
|
||
log("✓ Exported settings to \(url.path)")
|
||
} catch {
|
||
log("✗ Export failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func importSettings() {
|
||
let panel = NSOpenPanel()
|
||
panel.allowedFileTypes = ["json"]
|
||
panel.allowsMultipleSelection = false
|
||
panel.canChooseDirectories = false
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
do {
|
||
let data = try Data(contentsOf: url)
|
||
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
||
applySnapshot(snap)
|
||
saveSettingsToDefaults()
|
||
log("✓ Imported settings from \(url.lastPathComponent)")
|
||
} catch {
|
||
log("✗ Import failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Settings persistence (UserDefaults)
|
||
private func makeEngine() -> MirrorEngine {
|
||
MirrorEngine(log: log)
|
||
}
|
||
|
||
private func runCleanupForCurrentSelection() async {
|
||
guard hasAccess, !calendars.isEmpty else { return }
|
||
guard calendars.indices.contains(sourceIndex) else {
|
||
log("Cannot cleanup: selected source is invalid.")
|
||
return
|
||
}
|
||
let srcCal = calendars[sourceIndex]
|
||
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
}
|
||
|
||
private func runCleanupForRoute(_ route: Route) async {
|
||
guard let sIdx = indexForCalendar(id: route.sourceID) else { return }
|
||
let srcCal = calendars[sIdx]
|
||
let targetSet = route.targetIDs.subtracting([srcCal.calendarIdentifier])
|
||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||
// Do NOT mutate sourceIndex / sourceID / targetIDs here: cleanup does
|
||
// not need to reflect route selections in the UI and doing so causes
|
||
// jarring picker jumps when iterating over multiple routes.
|
||
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||
}
|
||
|
||
private let settingsDefaultsKey = "settings.v2"
|
||
private let legacyRoutesDefaultsKey = "routes.v1"
|
||
|
||
private func saveSettingsToDefaults() {
|
||
do {
|
||
let data = try JSONEncoder().encode(makeSnapshot())
|
||
UserDefaults.standard.set(data, forKey: settingsDefaultsKey)
|
||
} catch {
|
||
log("✗ Failed to save settings: \(error.localizedDescription)")
|
||
}
|
||
// Keep the legacy routes-only backup current (not just a frozen
|
||
// historical snapshot) so it's a real safety net: if settings.v2 ever
|
||
// comes back with an empty `routes` array again (the exact bug fixed
|
||
// by reloadCalendars(pruneRoutes:) above), recovery restores the
|
||
// actual current routes, not whatever they were the first time this
|
||
// key was ever written.
|
||
if !routes.isEmpty, let routesData = try? JSONEncoder().encode(routes) {
|
||
UserDefaults.standard.set(routesData, forKey: legacyRoutesDefaultsKey)
|
||
}
|
||
}
|
||
|
||
private func loadSettingsFromDefaults() {
|
||
let defaults = UserDefaults.standard
|
||
if let data = defaults.data(forKey: settingsDefaultsKey) {
|
||
do {
|
||
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
||
restoreLaunchState(from: 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)")
|
||
}
|
||
return
|
||
}
|
||
|
||
// Legacy fallback: routes-only payload
|
||
guard let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey) else { return }
|
||
do {
|
||
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
|
||
routes = decodedRoutes
|
||
clampWorkHours()
|
||
saveSettingsToDefaults() // upgrade stored format
|
||
} catch {
|
||
log("✗ Failed to load routes: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Filters
|
||
|
||
private func clampWorkHours() {
|
||
let clampedStart = min(max(workHoursStart, 0), 23)
|
||
if clampedStart != workHoursStart { workHoursStart = clampedStart }
|
||
let clampedEnd = min(max(workHoursEnd, 1), 24)
|
||
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
|
||
if workHoursEnd <= workHoursStart {
|
||
let adjustedEnd = min(workHoursStart + 1, 24)
|
||
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
|
||
}
|
||
}
|
||
|
||
// 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
|
||
let lines = logText.split(separator: "\n", omittingEmptySubsequences: false)
|
||
if lines.count > maxLines {
|
||
logText = lines.suffix(maxLines).joined(separator: "\n")
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|