From 2c319808c22ae315d17c89ae14e3631ec63d95b5 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 27 May 2026 12:51:22 +0200 Subject: [PATCH] Release 1.5.0 - Remove non-functional markPrivate feature and Objective-C runtime hacks - Extract mirror engine into MirrorEngine.swift - Move calLabel to MirrorUtils.swift - Update AGENTS.md architecture documentation - Bump version to 1.5.0 (build 19) --- AGENTS.md | 33 +- BusyMirror.xcodeproj/project.pbxproj | 8 +- BusyMirror/ContentView.swift | 752 ++------------------------- BusyMirror/MirrorConfig.swift | 1 - BusyMirror/MirrorEngine.swift | 612 ++++++++++++++++++++++ BusyMirror/MirrorUtils.swift | 6 + CHANGELOG.md | 13 + 7 files changed, 700 insertions(+), 725 deletions(-) create mode 100644 BusyMirror/MirrorEngine.swift diff --git a/AGENTS.md b/AGENTS.md index ff301a4..bcd3fcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using Key capabilities: - Manual or route-driven multi-source mirroring -- Privacy modes: hide details (placeholder title) or mark events Private server-side (best-effort) +- Privacy mode: hide details (placeholder title) - DRY-RUN mode to preview changes without writing - Scheduled headless runs via a self-installed `launchd` LaunchAgent - Settings autosave/restore, plus Import/Export JSON @@ -36,7 +36,12 @@ No external Swift Package Manager dependencies are used. The project is self-con ``` BusyMirror/ ├── BusyMirrorApp.swift # App entry point; defines Window + MenuBarExtra -├── ContentView.swift # Main UI, mirror engine, settings, CLI, scheduling (≈2600 lines) +├── ContentView.swift # Main UI, settings, CLI, scheduling (≈1800 lines) +├── MirrorEngine.swift # EventKit mirror engine (read, deduplicate, merge, create/update/delete) +├── MirrorConfig.swift # Configuration struct passed to the engine +├── MirrorUtils.swift # URL builders, mirror detection, calendar labels +├── BlockMath.swift # Block merging, gap calculation, overlap logic +├── EventFilters.swift # Work-hours, title, and organizer filters ├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view ├── Info.plist # LSUIElement, calendar usage descriptions ├── BusyMirror.entitlements # App sandbox + calendar access entitlement @@ -47,15 +52,9 @@ BusyMirrorTests/ # Empty (no tests implemented) BusyMirrorUITests/ # Empty (no tests implemented) ``` -**Architecture note:** nearly all business logic lives inside `ContentView.swift`. This includes: -- SwiftUI view body and subviews -- EventKit mirror engine (read, deduplicate, merge, create/update/delete) -- Settings serialization/deserialization -- CLI argument parsing -- `launchd` LaunchAgent installation/removal -- Logging to file and UI +**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability. -When making changes, be aware that `ContentView.swift` is a large monolithic file. Extracting helpers is fine, but keep the existing data flow ( `@EnvironmentObject`, `@AppStorage`, `@State`) intact. +When making changes, keep the existing data flow (`@EnvironmentObject`, `@AppStorage`, `@State`) intact in `ContentView.swift`. ## Build and Release Commands @@ -96,8 +95,8 @@ Built products: ## Testing -- **There are no implemented tests.** `BusyMirrorTests/` and `BusyMirrorUITests/` exist as Xcode targets but contain no source files. -- When adding logic, prefer extracting pure functions (e.g., block merging, gap calculation, filter logic) so they can be unit-tested later. +- Unit tests exist in `BusyMirrorTests/` for `BlockMath`, `EventFilters`, and `MirrorUtils`. +- When adding logic, prefer extracting pure functions (e.g., block merging, gap calculation, filter logic) so they can be unit-tested. - Manual testing checklist for releases: 1. Grant Calendar permission. 2. Select a source and target, run DRY-RUN, verify log output. @@ -112,7 +111,6 @@ Built products: - **Calendar data:** the app reads and writes the user’s calendars via EventKit. It must handle permission denial gracefully. - **Sandbox:** the app uses the macOS app sandbox (`com.apple.security.app-sandbox`) and the `com.apple.security.personal-information.calendars` entitlement. - **Signing:** releases are ad-hoc signed only (`codesign --sign -`). They are **not notarized**. Gatekeeper may block the app on first launch; users may need to right-click → Open. -- **Private events:** the "Mark Private" feature uses Objective-C runtime tricks (`perform(Selector:)`, KVC `setValue:forKey:`) because EventKit does not expose a public privacy API. This is best-effort and may silently fail on some calendar providers (e.g., some Exchange configurations). - **Loop guard:** a `sessionGuard` set prevents mirroring an event into the same target twice in one run, and prefix-based detection (`titlePrefix`) prevents re-mirroring already-mirrored placeholders. - **Logging:** log files are written to the user’s `~/Library/Logs/BusyMirror/`. No log data is transmitted externally. @@ -136,7 +134,12 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau | File | Purpose | |------|---------| -| `BusyMirror/ContentView.swift` | UI, business logic, mirror engine, settings, CLI, scheduling | +| `BusyMirror/ContentView.swift` | UI, settings, CLI, scheduling | +| `BusyMirror/MirrorEngine.swift` | EventKit mirror engine (runMirror, runCleanup, index persistence) | +| `BusyMirror/MirrorConfig.swift` | Configuration struct for mirror runs | +| `BusyMirror/MirrorUtils.swift` | Mirror URL builders, event detection, calendar labels | +| `BusyMirror/BlockMath.swift` | Block merging, gap calculation, overlap logic | +| `BusyMirror/EventFilters.swift` | Work-hours, title, and organizer filters | | `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra | | `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view | | `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions | @@ -149,6 +152,6 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau - Do **not** add third-party dependencies unless the user explicitly asks. The project intentionally has zero external packages. - If you refactor `ContentView.swift`, preserve `@AppStorage` keys and `UserDefaults` keys exactly; users have existing settings on disk. -- The mirror engine is tightly coupled to SwiftUI state. Extract helpers for testability, but do not break the `@MainActor`/`@State` flow without careful review. +- The mirror engine (`MirrorEngine.swift`) is `@MainActor` and accepts an `EKEventStore` plus a logging closure. It does not directly mutate SwiftUI `@State`; `ContentView` manages all view state. - When modifying build settings, update both Debug and Release configurations in `project.pbxproj`, and update `CHANGELOG.md` if the change is user-visible. - Do not run `git commit`, `git push`, or similar operations unless explicitly asked. diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 280f12d..6f879c6 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 = 18; + CURRENT_PROJECT_VERSION = 19; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; 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 = 18; + CURRENT_PROJECT_VERSION = 19; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 6113fe2..ad17594 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -1,10 +1,7 @@ import SwiftUI import EventKit import AppKit -import ObjectiveC -/// Placeholder title is configurable via state (see `placeholderTitle`) -private let SAME_TIME_TOL_MIN: Double = 5 private let SKIP_ALL_DAY_DEFAULT = true private enum AppLogStore { @@ -73,12 +70,6 @@ enum ScheduleMode: String, CaseIterable, Identifiable { } } -// Calendar label helper to disambiguate identical names -private func calLabel(_ cal: EKCalendar) -> String { - let src = cal.source.title - return src.isEmpty ? cal.title : "\(cal.title) — \(src)" -} - // Calendar color helpers private func calColor(_ cal: EKCalendar) -> Color { #if os(macOS) @@ -96,29 +87,6 @@ private func calChip(_ cal: EKCalendar) -> some View { } } -private struct MirrorRecord: Hashable, Codable { - var targetCalendarID: String - var sourceCalendarID: String - var sourceStableID: String - var occurrenceTimestamp: TimeInterval? - var targetEventIdentifier: String? - var lastKnownStartTimestamp: TimeInterval - var lastKnownEndTimestamp: TimeInterval - var updatedAt: Date = Date() - - var sourceKey: String { - sourceOccurrenceKey( - sourceCalID: sourceCalendarID, - sourceStableID: sourceStableID, - occurrence: occurrenceTimestamp.map { Date(timeIntervalSince1970: $0) } - ) - } - - var timeKey: String { - "\(lastKnownStartTimestamp)|\(lastKnownEndTimestamp)" - } -} - struct Route: Identifiable, Hashable, Codable { let id = UUID() var sourceID: String @@ -128,11 +96,9 @@ struct Route: Identifiable, Hashable, Codable { var mergeGapHours: Int // per-route merge gap (hours) var overlap: OverlapMode // per-route overlap behavior var allDay: Bool // per-route mirror all-day - var markPrivate: Bool = false // mark mirrored events as Private (if supported by account) + enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay } - enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay, markPrivate } - - init(sourceID: String, targetIDs: Set, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool, markPrivate: Bool = false) { + init(sourceID: String, targetIDs: Set, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) { self.sourceID = sourceID self.targetIDs = targetIDs self.privacy = privacy @@ -140,7 +106,6 @@ struct Route: Identifiable, Hashable, Codable { self.mergeGapHours = mergeGapHours self.overlap = overlap self.allDay = allDay - self.markPrivate = markPrivate } init(from decoder: Decoder) throws { @@ -152,7 +117,7 @@ struct Route: Identifiable, Hashable, Codable { 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) - self.markPrivate = (try? c.decode(Bool.self, forKey: .markPrivate)) ?? false + } } @@ -173,7 +138,6 @@ 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("markPrivate") private var markPrivate: Bool = false // If ON, set event Private (server-side) when mirroring @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @@ -204,7 +168,6 @@ struct ContentView: View { @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 - private let mirrorIndexDefaultsKey = "mirror-index.v1" // Mirrors can run either by manual selection (source + at least one target) // or using predefined routes. This derived flag controls the Mirror Now button. @@ -214,25 +177,6 @@ struct ContentView: View { return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets) } - private func loadMirrorIndex() -> [String: MirrorRecord] { - guard let data = UserDefaults.standard.data(forKey: mirrorIndexDefaultsKey) else { return [:] } - do { - return try JSONDecoder().decode([String: MirrorRecord].self, from: data) - } catch { - log("✗ Failed to load mirror index: \(error.localizedDescription)") - return [:] - } - } - - private func saveMirrorIndex(_ index: [String: MirrorRecord]) { - do { - let data = try JSONEncoder().encode(index) - UserDefaults.standard.set(data, forKey: mirrorIndexDefaultsKey) - } catch { - log("✗ Failed to save mirror index: \(error.localizedDescription)") - } - } - private static let intFormatter: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .none @@ -647,8 +591,7 @@ struct ContentView: View { copyNotes: copyDescription, mergeGapHours: mergeGapHours, overlap: overlapMode, - allDay: mirrorAllDay, - markPrivate: markPrivate) + allDay: mirrorAllDay) routes.append(r) } .disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) @@ -691,9 +634,6 @@ 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("Mark mirrored events as Private", isOn: routeBinding.markPrivate) - .disabled(isRunning) - .help("If ON, attempt to mark mirrored events as Private on the server (e.g., Exchange). Titles still use your prefix and source title.") Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay) .disabled(isRunning) .help("Mirror all-day events for this source.") @@ -828,7 +768,6 @@ struct ContentView: View { mergeGapMin: max(0, r.mergeGapHours * 60), hideDetails: r.privacy, copyDescription: r.copyNotes, - markPrivate: r.markPrivate, mirrorAllDay: r.allDay, overlapMode: r.overlap, titlePrefix: titlePrefix, @@ -851,7 +790,8 @@ struct ContentView: View { targetIDs.remove(r.sourceID) progressText = "Route \(idx + 1) of \(configuredRoutes.count)" } - await runMirror(config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true) + let engine = makeEngine() + await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true) } if skippedMissingSource > 0 { @@ -872,7 +812,6 @@ struct ContentView: View { mergeGapMin: mergeGapMin, hideDetails: hideDetails, copyDescription: copyDescription, - markPrivate: markPrivate, mirrorAllDay: mirrorAllDay, overlapMode: overlapMode, titlePrefix: titlePrefix, @@ -917,7 +856,8 @@ struct ContentView: View { } let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier]) let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) } - await runMirror(config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false) + let engine = makeEngine() + await engine.runMirror(store: store, config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false) } else { await runConfiguredRoutes(routes, sessionGuard: &sessionGuard) } @@ -1011,8 +951,6 @@ struct ContentView: View { .disabled(isRunning) Toggle("Copy description when mirroring", isOn: $copyDescription) .disabled(isRunning || hideDetails) - Toggle("Mark mirrored events as Private (if supported)", isOn: $markPrivate) - .disabled(isRunning) Toggle("Mirror all-day events", isOn: $mirrorAllDay) .disabled(isRunning) Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly) @@ -1218,17 +1156,10 @@ struct ContentView: View { // Dry-run: run without confirmation Task { if routes.isEmpty { - await runCleanup() + await runCleanupForCurrentSelection() } else { for r in routes { - if let sIdx = indexForCalendar(id: r.sourceID) { - await MainActor.run { - sourceIndex = sIdx - sourceID = r.sourceID - targetIDs = r.targetIDs - } - await runCleanup() - } + await runCleanupForRoute(r) } } } @@ -1389,17 +1320,10 @@ struct ContentView: View { Button("Delete now", role: .destructive) { Task { if routes.isEmpty { - await runCleanup() + await runCleanupForCurrentSelection() } else { for r in routes { - if let sIdx = indexForCalendar(id: r.sourceID) { - await MainActor.run { - sourceIndex = sIdx - sourceID = r.sourceID - targetIDs = r.targetIDs - } - await runCleanup() - } + await runCleanupForRoute(r) } } } @@ -1437,7 +1361,6 @@ struct ContentView: View { .onChange(of: titlePrefix) { _ in saveSettingsToDefaults() } .onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() } .onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() } - .onChange(of: markPrivate) { _ 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 } @@ -1538,15 +1461,8 @@ struct ContentView: View { log("CLI: no saved routes; aborting") } else if boolArg("--cleanup-only", default: false) { for r in routes { - if let sIdx = indexForCalendar(id: r.sourceID) { - await MainActor.run { - sourceIndex = sIdx - sourceID = r.sourceID - targetIDs = r.targetIDs - } - log("CLI: cleanup saved route \(r.sourceID)") - await runCleanup() - } + log("CLI: cleanup saved route \(r.sourceID)") + await runCleanupForRoute(r) } } else { var sessionGuard = Set() @@ -1571,11 +1487,13 @@ struct ContentView: View { if boolArg("--cleanup-only", default: false) { log("CLI: cleanup route \(part)") - await runCleanup() + 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() - await runMirror(config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false) + await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false) } } } @@ -1641,535 +1559,6 @@ struct ContentView: View { handlePendingMenuBarSyncIfNeeded() } - // MARK: - Mirror engine (EventKit) - func runMirror(config: MirrorConfig, sourceCalendar: EKCalendar, targetCalendars: [EKCalendar], sessionGuard: inout Set, isMultiRouteRun: Bool) async { - guard hasAccess else { - log("Cannot mirror: calendar access is not granted.") - return - } - guard !calendars.isEmpty else { - log("Cannot mirror: no calendars loaded. Try Refresh Calendars.") - return - } - let srcCal = sourceCalendar - let srcName = calLabel(srcCal) - let targets = targetCalendars.filter { $0.calendarIdentifier != srcCal.calendarIdentifier } - if targets.isEmpty { - log("No target calendars selected. Choose at least one target or add a route with valid targets.") - return - } - - let cal = Calendar.current - let todayStart = cal.startOfDay(for: Date()) - let windowStart = cal.date(byAdding: .day, value: -config.daysBack, to: todayStart)! - let windowEnd = cal.date(byAdding: .day, value: config.daysForward, to: todayStart)! - log("=== BusyMirror ===") - log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))") - log("Window: \(windowStart) -> \(windowEnd)") - log("WRITE: \(config.writeEnabled) \(config.writeEnabled ? "" : "(DRY-RUN)") mode: \(config.overlapMode.rawValue) mergeGapMin: \(config.mergeGapMin) allDay: \(config.mirrorAllDay)") - log("Route: \(srcName) → {\(targets.map { calLabel($0) }.joined(separator: ", "))}") - - // Source events (recurrences expanded by EventKit) - let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal]) - var srcEvents = store.events(matching: srcPred) - let srcFetched = srcEvents.count - srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier } - let srcKept = srcEvents.count - if srcKept != srcFetched { - log("- WARN: filtered \(srcFetched - srcKept) stray source event(s) not in \(srcName)") - } - srcEvents.sort { ($0.startDate ?? .distantPast) < ($1.startDate ?? .distantPast) } - - var srcBlocks: [Block] = [] - var skippedMirrors = 0 - let titleFilters = config.excludedTitleFilterTerms - let organizerFilters = config.excludedOrganizerFilterTerms - let enforceWorkHours = config.filterByWorkHours && config.workHoursEnd > config.workHoursStart - let allowedStartMinutes = config.workHoursStart * 60 - let allowedEndMinutes = config.workHoursEnd * 60 - var skippedWorkHours = 0 - var skippedTitles = 0 - var skippedOrganizers = 0 - var skippedStatus = 0 - for ev in srcEvents { - if Task.isCancelled { break } - if config.mirrorAcceptedOnly, ev.hasAttendees { - let attendees = ev.attendees ?? [] - if let me = attendees.first(where: { $0.isCurrentUser }) { - if me.participantStatus != .accepted { - skippedStatus += 1 - continue - } - } else { - skippedStatus += 1 - continue - } - } - if enforceWorkHours, !ev.isAllDay, let start = ev.startDate, - isOutsideWorkHours(start, calendar: cal, startMinutes: allowedStartMinutes, endMinutes: allowedEndMinutes) { - skippedWorkHours += 1 - continue - } - if shouldSkip(title: ev.title, filters: titleFilters, titlePrefix: config.titlePrefix) { - skippedTitles += 1 - continue - } - if shouldSkipOrganizer(organizerValues: organizerStrings(for: ev), filters: organizerFilters) { - skippedOrganizers += 1 - continue - } - if !config.mirrorAllDay && ev.isAllDay { continue } - if isMirrorEvent(ev, prefix: config.titlePrefix, placeholder: config.placeholderTitle) { - skippedMirrors += 1 - continue - } - 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)) - } - if skippedMirrors > 0 { - log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") - } - if skippedWorkHours > 0 { - log("- SKIP outside work hours: \(skippedWorkHours) event(s)") - } - if skippedTitles > 0 { - log("- SKIP title filter: \(skippedTitles) event(s)") - } - if skippedOrganizers > 0 { - log("- SKIP organizer filter: \(skippedOrganizers) event(s)") - } - if skippedStatus > 0 { - log("- SKIP non-accepted status: \(skippedStatus) event(s)") - } - srcBlocks = uniqueBlocks(srcBlocks, trackByID: config.mergeGapMin == 0) - - let baseBlocks = (config.mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: config.mergeGapMin) : srcBlocks - let trackByID = (config.mergeGapMin == 0) - var mirrorIndex = loadMirrorIndex() - var mirrorIndexChanged = false - - func sourceKey(for blk: Block) -> String? { - guard trackByID, let sid = blk.srcStableID else { return nil } - return sourceOccurrenceKey(sourceCalID: srcCal.calendarIdentifier, sourceStableID: sid, occurrence: blk.occurrence) - } - - // Cache target events across routes when possible - var targetEventCache: [String: [EKEvent]] = [:] - for tgt in targets { - if Task.isCancelled { break } - let tgtName = calLabel(tgt) - log(">>> Target: \(tgtName)") - if tgt.calendarIdentifier == srcCal.calendarIdentifier { - log("- SKIP target is same as source: \(tgtName)") - continue - } - let tgtEvents: [EKEvent] - if let cached = targetEventCache[tgt.calendarIdentifier] { - tgtEvents = cached - } else { - let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) - var evs = store.events(matching: tgtPred) - let tgtFetched = evs.count - evs = evs.filter { $0.calendar.calendarIdentifier == tgt.calendarIdentifier } - if tgtFetched != evs.count { - log("- WARN: filtered \(tgtFetched - evs.count) stray target event(s) not in \(tgtName)") - } - targetEventCache[tgt.calendarIdentifier] = evs - tgtEvents = evs - } - - var placeholderSet = Set() - var occupied: [Block] = [] - var placeholdersBySourceKey: [String: EKEvent] = [:] - var placeholdersByTime: [String: EKEvent] = [:] - var targetEventsByIdentifier: [String: EKEvent] = [:] - for tv in tgtEvents { - guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } - if let eid = tv.eventIdentifier { - targetEventsByIdentifier[eid] = tv - } - if let ts = tv.startDate, let te = tv.endDate { - let timeKey = mirrorTimeKey(start: ts, end: te) - if isMirrorEvent(tv, prefix: config.titlePrefix, placeholder: config.placeholderTitle) { - placeholderSet.insert(timeKey) - placeholdersByTime[timeKey] = tv - let parsed = parseMirrorURL(tv.url) - if let sourceCalID = parsed.sourceCalID, - let sourceStableID = parsed.sourceStableID, - !sourceCalID.isEmpty, - !sourceStableID.isEmpty { - let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ) - placeholdersBySourceKey[key] = tv - let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) - let record = MirrorRecord( - targetCalendarID: tgt.calendarIdentifier, - sourceCalendarID: sourceCalID, - sourceStableID: sourceStableID, - occurrenceTimestamp: parsed.occ?.timeIntervalSince1970, - targetEventIdentifier: tv.eventIdentifier, - lastKnownStartTimestamp: ts.timeIntervalSince1970, - lastKnownEndTimestamp: te.timeIntervalSince1970 - ) - if mirrorIndex[recordKey] != record { - mirrorIndex[recordKey] = record - mirrorIndexChanged = true - } - } - } - occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) - } - } - occupied = coalesce(occupied) - - var created = 0 - var skipped = 0 - var updated = 0 - var warnedPrivateUnsupported = false - - func guardKey(for blk: Block, targetID: String) -> String { - if let key = sourceKey(for: blk) { - return "\(key)|\(targetID)" - } - return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)" - } - - func desiredNotes(for blk: Block) -> String? { - (!config.hideDetails && config.copyDescription) ? blk.notes : nil - } - - func upsertMirrorRecord(for blk: Block, event: EKEvent) { - guard let sid = blk.srcStableID, - let key = sourceKey(for: blk), - let startDate = event.startDate, - let endDate = event.endDate else { return } - let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) - let record = MirrorRecord( - targetCalendarID: tgt.calendarIdentifier, - sourceCalendarID: srcCal.calendarIdentifier, - sourceStableID: sid, - occurrenceTimestamp: blk.occurrence?.timeIntervalSince1970, - targetEventIdentifier: event.eventIdentifier, - lastKnownStartTimestamp: startDate.timeIntervalSince1970, - lastKnownEndTimestamp: endDate.timeIntervalSince1970 - ) - if mirrorIndex[recordKey] != record { - mirrorIndex[recordKey] = record - mirrorIndexChanged = true - } - } - - func removeMirrorRecord(for key: String) { - let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) - if mirrorIndex.removeValue(forKey: recordKey) != nil { - mirrorIndexChanged = true - } - } - - func resolveMappedEvent(for record: MirrorRecord) -> EKEvent? { - if let eid = record.targetEventIdentifier, - let event = targetEventsByIdentifier[eid], - event.calendar.calendarIdentifier == tgt.calendarIdentifier { - return event - } - return placeholdersByTime[record.timeKey] - } - - func rememberMirrorEvent(_ event: EKEvent, for blk: Block) { - if let startDate = event.startDate, let endDate = event.endDate { - let timeKey = mirrorTimeKey(start: startDate, end: endDate) - placeholderSet.insert(timeKey) - placeholdersByTime[timeKey] = event - } - if let key = sourceKey(for: blk) { - placeholdersBySourceKey[key] = event - } - if let eid = event.eventIdentifier { - targetEventsByIdentifier[eid] = event - } - upsertMirrorRecord(for: blk, event: event) - } - - 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 - if abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN * 60 { return true } - if abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN * 60 { return true } - if (existing.title ?? "") != displayTitle { return true } - if (existing.notes ?? "") != (desiredNotes ?? "") { return true } - if existing.isAllDay { return true } - if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true } - return false - } - - func createOrUpdateIfNeeded(_ blk: Block) async { - let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier) - if sessionGuard.contains(gKey) { - skipped += 1 - log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") - return - } - - let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix) - let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle) - let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)") - let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle - let notes = desiredNotes(for: blk) - let desiredURL = buildMirrorURL( - targetCalID: tgt.calendarIdentifier, - sourceCalID: srcCal.calendarIdentifier, - sourceStableID: blk.srcStableID, - occurrence: blk.occurrence, - start: blk.start, - end: blk.end - ) - let exactTimeKey = mirrorTimeKey(start: blk.start, end: blk.end) - let blkSourceKey = sourceKey(for: blk) - - func updateExisting(_ existing: EKEvent, byTime: Bool) async { - let curS = existing.startDate ?? blk.start - let curE = existing.endDate ?? blk.end - rememberMirrorEvent(existing, for: blk) - if !needsUpdate(existing: existing, blk: blk, displayTitle: displayTitle, desiredNotes: notes, desiredURL: desiredURL) { - sessionGuard.insert(gKey) - skipped += 1 - return - } - let byTimeSuffix = byTime ? " (by time)" : "" - if !config.writeEnabled { - sessionGuard.insert(gKey) - log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") - updated += 1 - return - } - existing.title = displayTitle - existing.startDate = blk.start - existing.endDate = blk.end - existing.isAllDay = false - existing.notes = notes - existing.url = desiredURL - let ok = setEventPrivateIfSupported(existing, config.markPrivate) - if config.markPrivate && !ok && !warnedPrivateUnsupported { - log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]") - warnedPrivateUnsupported = true - } - do { - try await MainActor.run { - try store.save(existing, span: .thisEvent, commit: true) - } - log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)") - rememberMirrorEvent(existing, for: blk) - occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)]) - sessionGuard.insert(gKey) - updated += 1 - } catch { - log("Update failed: \(error.localizedDescription)") - } - } - - if let blkSourceKey, - let record = mirrorIndex[mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: blkSourceKey)], - let existing = resolveMappedEvent(for: record) { - await updateExisting(existing, byTime: false) - return - } - - if let blkSourceKey, let existing = placeholdersBySourceKey[blkSourceKey] { - await updateExisting(existing, byTime: false) - return - } - - if let existingByTime = placeholdersByTime[exactTimeKey] { - await updateExisting(existingByTime, byTime: true) - return - } - - if placeholderSet.contains(exactTimeKey) { - skipped += 1 - return - } - if !config.writeEnabled { - sessionGuard.insert(gKey) - log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") - return - } - guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { - skipped += 1 - log("- SKIP invariant: target is source [\(srcName)]") - return - } - let newEv = EKEvent(eventStore: store) - newEv.calendar = tgt - newEv.title = displayTitle - newEv.startDate = blk.start - newEv.endDate = blk.end - newEv.isAllDay = false - newEv.notes = notes - newEv.url = desiredURL - newEv.availability = .busy - let okNew = setEventPrivateIfSupported(newEv, config.markPrivate) - if config.markPrivate && !okNew && !warnedPrivateUnsupported { - log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]") - warnedPrivateUnsupported = true - } - do { - try await MainActor.run { - try store.save(newEv, span: .thisEvent, commit: true) - } - created += 1 - log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") - rememberMirrorEvent(newEv, for: blk) - occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)]) - sessionGuard.insert(gKey) - } catch { - log("Save failed: \(error.localizedDescription)") - } - } - - for b in baseBlocks { - if Task.isCancelled { break } - switch config.overlapMode { - case .allow: - await createOrUpdateIfNeeded(b) - case .skipCovered: - if fullyCovered(occupied, block: b, tolMin: SAME_TIME_TOL_MIN) { - log("- SKIP covered [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") - skipped += 1 - } else { - await createOrUpdateIfNeeded(b) - } - case .fillGaps: - let gaps = gapsWithin(occupied, in: b) - if gaps.isEmpty { - log("- SKIP no gaps [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") - skipped += 1 - } else { - for g in gaps { await createOrUpdateIfNeeded(g) } - } - } - } - log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)") - if config.autoDeleteMissing { - let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) }) - let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) }) - - var byID: [String: EKEvent] = [:] - for tv in placeholdersByTime.values { - guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } - if let eid = tv.eventIdentifier { byID[eid] = tv } - } - - var removed = 0 - var skippedOtherSource = 0 - var skippedLegacyNoURL = 0 - var handledEventIDs = Set() - - let staleMirrorRecords = mirrorIndex.filter { - $0.value.targetCalendarID == tgt.calendarIdentifier && - $0.value.sourceCalendarID == srcCal.calendarIdentifier && - !activeSourceKeys.contains($0.value.sourceKey) - } - - for (recordKey, record) in staleMirrorRecords { - if Task.isCancelled { break } - let candidate = resolveMappedEvent(for: record) - if let candidate { - if !config.writeEnabled { - log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)") - } else { - do { - try await MainActor.run { - try store.remove(candidate, span: .thisEvent, commit: true) - } - removed += 1 - } catch { - log("Delete failed: \(error.localizedDescription)") - } - } - if let eid = candidate.eventIdentifier { - handledEventIDs.insert(eid) - } - } - if config.writeEnabled || candidate == nil { - if mirrorIndex.removeValue(forKey: recordKey) != nil { - mirrorIndexChanged = true - } - } - } - - for ev in byID.values { - if Task.isCancelled { break } - if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) { - continue - } - let parsed = parseMirrorURL(ev.url) - var shouldDelete = false - var parsedSourceKey: String? = nil - if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty { - if sourceCalID != srcCal.calendarIdentifier { - skippedOtherSource += 1 - continue - } - if let sourceStableID = parsed.sourceStableID, !sourceStableID.isEmpty { - let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ) - parsedSourceKey = key - if !activeSourceKeys.contains(key) { shouldDelete = true } - } else if trackByID, - let s = ev.startDate, - let e = ev.endDate, - !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) { - shouldDelete = true - } - } else if trackByID && !isMultiRouteRun { - if let s = ev.startDate, - let e = ev.endDate, - !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) { - shouldDelete = true - } - } else if trackByID && isMultiRouteRun { - let hasMapping = mirrorIndex.values.contains { - $0.targetCalendarID == tgt.calendarIdentifier && - $0.sourceCalendarID == srcCal.calendarIdentifier && - $0.targetEventIdentifier == ev.eventIdentifier - } - if !hasMapping { - skippedLegacyNoURL += 1 - } - continue - } - if shouldDelete { - if !config.writeEnabled { - log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") - } else { - do { - try await MainActor.run { - try store.remove(ev, span: .thisEvent, commit: true) - } - removed += 1 - } catch { - log("Delete failed: \(error.localizedDescription)") - } - } - if let key = parsedSourceKey { - removeMirrorRecord(for: key) - } - } - } - if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") } - if skippedOtherSource > 0 { - log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)") - } - if skippedLegacyNoURL > 0 { - log("- INFO cleanup skipped \(skippedLegacyNoURL) unmanaged legacy placeholders without source metadata on \(tgtName)") - } - } - } - if mirrorIndexChanged { - saveMirrorIndex(mirrorIndex) - } - } - // MARK: - Export / Import Settings private struct SettingsPayload: Codable { var daysBack: Int @@ -2177,7 +1566,6 @@ private struct SettingsPayload: Codable { var mergeGapHours: Int var hideDetails: Bool var copyDescription: Bool - var markPrivate: Bool var mirrorAllDay: Bool var filterByWorkHours: Bool = false var workHoursStart: Int = 9 @@ -2205,7 +1593,6 @@ private struct SettingsPayload: Codable { mergeGapHours: mergeGapHours, hideDetails: hideDetails, copyDescription: copyDescription, - markPrivate: markPrivate, mirrorAllDay: mirrorAllDay, filterByWorkHours: filterByWorkHours, workHoursStart: workHoursStart, @@ -2232,7 +1619,6 @@ private struct SettingsPayload: Codable { hideDetails = s.hideDetails copyDescription = s.copyDescription mirrorAllDay = s.mirrorAllDay - markPrivate = s.markPrivate filterByWorkHours = s.filterByWorkHours workHoursStart = s.workHoursStart workHoursEnd = s.workHoursEnd @@ -2290,6 +1676,35 @@ private struct SettingsPayload: Codable { } // 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) } + await MainActor.run { + sourceIndex = sIdx + sourceID = route.sourceID + targetIDs = route.targetIDs + } + 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" @@ -2328,38 +1743,6 @@ private struct SettingsPayload: Codable { // MARK: - Filters - // Best-effort: mark an event as Private if the account/server supports it. - // Uses ObjC selector lookup to avoid crashes on unsupported keys. - private func setEventPrivateIfSupported(_ ev: EKEvent, _ flag: Bool) -> Bool { - guard flag else { return false } - let sel = Selector(("setPrivate:")) - if ev.responds(to: sel) { - _ = ev.perform(sel, with: NSNumber(value: true)) - return true - } - // Some backends may expose privacy level via a numeric setter - let sel2 = Selector(("setPrivacy:")) - if ev.responds(to: sel2) { - // 1 may represent private on some providers; this is best-effort. - _ = ev.perform(sel2, with: NSNumber(value: 1)) - return true - } - // Try common KVC keys seen in some providers (best-effort, may be no-ops) - let kvPairs: [(String, Any)] = [ - ("private", true), - ("isPrivate", true), - ("privacy", 1), - ("sensitivity", 1), // Exchange often: 1=personal, 2=private; varies - ("classification", 1) // iCalendar CLASS: 1 might map to PRIVATE - ] - for (key, val) in kvPairs { - ev.setValue(val, forKey: key) - return true - } - // Not supported; no-op - return false - } - private func clampWorkHours() { let clampedStart = min(max(workHoursStart, 0), 23) if clampedStart != workHoursStart { workHoursStart = clampedStart } @@ -2384,45 +1767,4 @@ private struct SettingsPayload: Codable { } } -// MARK: - Cleanup: delete Busy placeholders in the active window on selected targets - func runCleanup() async { - guard hasAccess, !calendars.isEmpty else { return } - guard calendars.indices.contains(sourceIndex) else { - log("Cannot cleanup: selected source is invalid.") - return - } - isRunning = true - defer { isRunning = false } - - let cal = Calendar.current - let todayStart = cal.startOfDay(for: Date()) - let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! - let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! - let targetSet = Set(targetIDs) - let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) && $0.calendarIdentifier != calendars[sourceIndex].calendarIdentifier } - log("=== Cleanup Busy placeholders in window ===") - log("(Cleanup is SAFE: mirrored events detected by url prefix or title prefix ‘\(titlePrefix)’)") - log("Window: \(windowStart) -> \(windowEnd)") - - for tgt in targets { - let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) - let tgtEvents = store.events(matching: tgtPred) - var delCount = 0 - for ev in tgtEvents { - guard isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) else { continue } - if !writeEnabled { - log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)") - } else { - do { - try await MainActor.run { - try store.remove(ev, span: .thisEvent, commit: true) - } - delCount += 1 - } - catch { log("Delete failed: \(error.localizedDescription)") } - } - } - log("[Cleanup \(tgt.title)] deleted=\(delCount)") - } -} } diff --git a/BusyMirror/MirrorConfig.swift b/BusyMirror/MirrorConfig.swift index f568707..6fd7bf2 100644 --- a/BusyMirror/MirrorConfig.swift +++ b/BusyMirror/MirrorConfig.swift @@ -7,7 +7,6 @@ struct MirrorConfig { let mergeGapMin: Int let hideDetails: Bool let copyDescription: Bool - let markPrivate: Bool let mirrorAllDay: Bool let overlapMode: OverlapMode let titlePrefix: String diff --git a/BusyMirror/MirrorEngine.swift b/BusyMirror/MirrorEngine.swift new file mode 100644 index 0000000..bb425b4 --- /dev/null +++ b/BusyMirror/MirrorEngine.swift @@ -0,0 +1,612 @@ +import Foundation +import EventKit + +private let SAME_TIME_TOL_MIN: Double = 5 + +struct MirrorRecord: Hashable, Codable { + var targetCalendarID: String + var sourceCalendarID: String + var sourceStableID: String + var occurrenceTimestamp: TimeInterval? + var targetEventIdentifier: String? + var lastKnownStartTimestamp: TimeInterval + var lastKnownEndTimestamp: TimeInterval + var updatedAt: Date = Date() + + var sourceKey: String { + sourceOccurrenceKey( + sourceCalID: sourceCalendarID, + sourceStableID: sourceStableID, + occurrence: occurrenceTimestamp.map { Date(timeIntervalSince1970: $0) } + ) + } + + var timeKey: String { + "\(lastKnownStartTimestamp)|\(lastKnownEndTimestamp)" + } +} + +@MainActor +final class MirrorEngine { + private let log: (String) -> Void + private let mirrorIndexDefaultsKey = "mirror-index.v1" + + init(log: @escaping (String) -> Void) { + self.log = log + } + + private func loadMirrorIndex() -> [String: MirrorRecord] { + guard let data = UserDefaults.standard.data(forKey: mirrorIndexDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: MirrorRecord].self, from: data) + } catch { + log("✗ Failed to load mirror index: \(error.localizedDescription)") + return [:] + } + } + + private func saveMirrorIndex(_ index: [String: MirrorRecord]) { + do { + let data = try JSONEncoder().encode(index) + UserDefaults.standard.set(data, forKey: mirrorIndexDefaultsKey) + } catch { + log("✗ Failed to save mirror index: \(error.localizedDescription)") + } + } + + func runMirror( + store: EKEventStore, + config: MirrorConfig, + sourceCalendar: EKCalendar, + targetCalendars: [EKCalendar], + sessionGuard: inout Set, + isMultiRouteRun: Bool + ) async { + let srcCal = sourceCalendar + let srcName = calLabel(srcCal) + let targets = targetCalendars.filter { $0.calendarIdentifier != srcCal.calendarIdentifier } + if targets.isEmpty { + log("No target calendars selected. Choose at least one target or add a route with valid targets.") + return + } + + let cal = Calendar.current + let todayStart = cal.startOfDay(for: Date()) + let windowStart = cal.date(byAdding: .day, value: -config.daysBack, to: todayStart)! + let windowEnd = cal.date(byAdding: .day, value: config.daysForward, to: todayStart)! + log("=== BusyMirror ===") + log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))") + log("Window: \(windowStart) -> \(windowEnd)") + log("WRITE: \(config.writeEnabled) \(config.writeEnabled ? "" : "(DRY-RUN)") mode: \(config.overlapMode.rawValue) mergeGapMin: \(config.mergeGapMin) allDay: \(config.mirrorAllDay)") + log("Route: \(srcName) → {\(targets.map { calLabel($0) }.joined(separator: ", "))}") + + // Source events (recurrences expanded by EventKit) + let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal]) + var srcEvents = store.events(matching: srcPred) + let srcFetched = srcEvents.count + srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier } + let srcKept = srcEvents.count + if srcKept != srcFetched { + log("- WARN: filtered \(srcFetched - srcKept) stray source event(s) not in \(srcName)") + } + srcEvents.sort { ($0.startDate ?? .distantPast) < ($1.startDate ?? .distantPast) } + + var srcBlocks: [Block] = [] + var skippedMirrors = 0 + let titleFilters = config.excludedTitleFilterTerms + let organizerFilters = config.excludedOrganizerFilterTerms + let enforceWorkHours = config.filterByWorkHours && config.workHoursEnd > config.workHoursStart + let allowedStartMinutes = config.workHoursStart * 60 + let allowedEndMinutes = config.workHoursEnd * 60 + var skippedWorkHours = 0 + var skippedTitles = 0 + var skippedOrganizers = 0 + var skippedStatus = 0 + for ev in srcEvents { + if Task.isCancelled { break } + if config.mirrorAcceptedOnly, ev.hasAttendees { + let attendees = ev.attendees ?? [] + if let me = attendees.first(where: { $0.isCurrentUser }) { + if me.participantStatus != .accepted { + skippedStatus += 1 + continue + } + } else { + skippedStatus += 1 + continue + } + } + if enforceWorkHours, !ev.isAllDay, let start = ev.startDate, + isOutsideWorkHours(start, calendar: cal, startMinutes: allowedStartMinutes, endMinutes: allowedEndMinutes) { + skippedWorkHours += 1 + continue + } + if shouldSkip(title: ev.title, filters: titleFilters, titlePrefix: config.titlePrefix) { + skippedTitles += 1 + continue + } + if shouldSkipOrganizer(organizerValues: organizerStrings(for: ev), filters: organizerFilters) { + skippedOrganizers += 1 + continue + } + if !config.mirrorAllDay && ev.isAllDay { continue } + if isMirrorEvent(ev, prefix: config.titlePrefix, placeholder: config.placeholderTitle) { + skippedMirrors += 1 + continue + } + 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)) + } + if skippedMirrors > 0 { + log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") + } + if skippedWorkHours > 0 { + log("- SKIP outside work hours: \(skippedWorkHours) event(s)") + } + if skippedTitles > 0 { + log("- SKIP title filter: \(skippedTitles) event(s)") + } + if skippedOrganizers > 0 { + log("- SKIP organizer filter: \(skippedOrganizers) event(s)") + } + if skippedStatus > 0 { + log("- SKIP non-accepted status: \(skippedStatus) event(s)") + } + srcBlocks = uniqueBlocks(srcBlocks, trackByID: config.mergeGapMin == 0) + + let baseBlocks = (config.mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: config.mergeGapMin) : srcBlocks + let trackByID = (config.mergeGapMin == 0) + var mirrorIndex = loadMirrorIndex() + var mirrorIndexChanged = false + + func sourceKey(for blk: Block) -> String? { + guard trackByID, let sid = blk.srcStableID else { return nil } + return sourceOccurrenceKey(sourceCalID: srcCal.calendarIdentifier, sourceStableID: sid, occurrence: blk.occurrence) + } + + // Cache target events across routes when possible + var targetEventCache: [String: [EKEvent]] = [:] + for tgt in targets { + if Task.isCancelled { break } + let tgtName = calLabel(tgt) + log(">>> Target: \(tgtName)") + if tgt.calendarIdentifier == srcCal.calendarIdentifier { + log("- SKIP target is same as source: \(tgtName)") + continue + } + let tgtEvents: [EKEvent] + if let cached = targetEventCache[tgt.calendarIdentifier] { + tgtEvents = cached + } else { + let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) + var evs = store.events(matching: tgtPred) + let tgtFetched = evs.count + evs = evs.filter { $0.calendar.calendarIdentifier == tgt.calendarIdentifier } + if tgtFetched != evs.count { + log("- WARN: filtered \(tgtFetched - evs.count) stray target event(s) not in \(tgtName)") + } + targetEventCache[tgt.calendarIdentifier] = evs + tgtEvents = evs + } + + var placeholderSet = Set() + var occupied: [Block] = [] + var placeholdersBySourceKey: [String: EKEvent] = [:] + var placeholdersByTime: [String: EKEvent] = [:] + var targetEventsByIdentifier: [String: EKEvent] = [:] + for tv in tgtEvents { + guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } + if let eid = tv.eventIdentifier { + targetEventsByIdentifier[eid] = tv + } + if let ts = tv.startDate, let te = tv.endDate { + let timeKey = mirrorTimeKey(start: ts, end: te) + if isMirrorEvent(tv, prefix: config.titlePrefix, placeholder: config.placeholderTitle) { + placeholderSet.insert(timeKey) + placeholdersByTime[timeKey] = tv + let parsed = parseMirrorURL(tv.url) + if let sourceCalID = parsed.sourceCalID, + let sourceStableID = parsed.sourceStableID, + !sourceCalID.isEmpty, + !sourceStableID.isEmpty { + let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ) + placeholdersBySourceKey[key] = tv + let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) + let record = MirrorRecord( + targetCalendarID: tgt.calendarIdentifier, + sourceCalendarID: sourceCalID, + sourceStableID: sourceStableID, + occurrenceTimestamp: parsed.occ?.timeIntervalSince1970, + targetEventIdentifier: tv.eventIdentifier, + lastKnownStartTimestamp: ts.timeIntervalSince1970, + lastKnownEndTimestamp: te.timeIntervalSince1970 + ) + if mirrorIndex[recordKey] != record { + mirrorIndex[recordKey] = record + mirrorIndexChanged = true + } + } + } + occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) + } + } + occupied = coalesce(occupied) + + var created = 0 + var skipped = 0 + var updated = 0 + + func guardKey(for blk: Block, targetID: String) -> String { + if let key = sourceKey(for: blk) { + return "\(key)|\(targetID)" + } + return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)" + } + + func desiredNotes(for blk: Block) -> String? { + (!config.hideDetails && config.copyDescription) ? blk.notes : nil + } + + func upsertMirrorRecord(for blk: Block, event: EKEvent) { + guard let sid = blk.srcStableID, + let key = sourceKey(for: blk), + let startDate = event.startDate, + let endDate = event.endDate else { return } + let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) + let record = MirrorRecord( + targetCalendarID: tgt.calendarIdentifier, + sourceCalendarID: srcCal.calendarIdentifier, + sourceStableID: sid, + occurrenceTimestamp: blk.occurrence?.timeIntervalSince1970, + targetEventIdentifier: event.eventIdentifier, + lastKnownStartTimestamp: startDate.timeIntervalSince1970, + lastKnownEndTimestamp: endDate.timeIntervalSince1970 + ) + if mirrorIndex[recordKey] != record { + mirrorIndex[recordKey] = record + mirrorIndexChanged = true + } + } + + func removeMirrorRecord(for key: String) { + let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key) + if mirrorIndex.removeValue(forKey: recordKey) != nil { + mirrorIndexChanged = true + } + } + + func resolveMappedEvent(for record: MirrorRecord) -> EKEvent? { + if let eid = record.targetEventIdentifier, + let event = targetEventsByIdentifier[eid], + event.calendar.calendarIdentifier == tgt.calendarIdentifier { + return event + } + return placeholdersByTime[record.timeKey] + } + + func rememberMirrorEvent(_ event: EKEvent, for blk: Block) { + if let startDate = event.startDate, let endDate = event.endDate { + let timeKey = mirrorTimeKey(start: startDate, end: endDate) + placeholderSet.insert(timeKey) + placeholdersByTime[timeKey] = event + } + if let key = sourceKey(for: blk) { + placeholdersBySourceKey[key] = event + } + if let eid = event.eventIdentifier { + targetEventsByIdentifier[eid] = event + } + upsertMirrorRecord(for: blk, event: event) + } + + 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 + if abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN * 60 { return true } + if abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN * 60 { return true } + if (existing.title ?? "") != displayTitle { return true } + if (existing.notes ?? "") != (desiredNotes ?? "") { return true } + if existing.isAllDay { return true } + if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true } + return false + } + + func createOrUpdateIfNeeded(_ blk: Block) async { + let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier) + if sessionGuard.contains(gKey) { + skipped += 1 + log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") + return + } + + let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix) + let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle) + let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)") + let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle + let notes = desiredNotes(for: blk) + let desiredURL = buildMirrorURL( + targetCalID: tgt.calendarIdentifier, + sourceCalID: srcCal.calendarIdentifier, + sourceStableID: blk.srcStableID, + occurrence: blk.occurrence, + start: blk.start, + end: blk.end + ) + let exactTimeKey = mirrorTimeKey(start: blk.start, end: blk.end) + let blkSourceKey = sourceKey(for: blk) + + func updateExisting(_ existing: EKEvent, byTime: Bool) async { + let curS = existing.startDate ?? blk.start + let curE = existing.endDate ?? blk.end + rememberMirrorEvent(existing, for: blk) + if !needsUpdate(existing: existing, blk: blk, displayTitle: displayTitle, desiredNotes: notes, desiredURL: desiredURL) { + sessionGuard.insert(gKey) + skipped += 1 + return + } + let byTimeSuffix = byTime ? " (by time)" : "" + if !config.writeEnabled { + sessionGuard.insert(gKey) + log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") + updated += 1 + return + } + existing.title = displayTitle + existing.startDate = blk.start + existing.endDate = blk.end + existing.isAllDay = false + existing.notes = notes + existing.url = desiredURL + do { + try await MainActor.run { + try store.save(existing, span: .thisEvent, commit: true) + } + log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)") + rememberMirrorEvent(existing, for: blk) + occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)]) + sessionGuard.insert(gKey) + updated += 1 + } catch { + log("Update failed: \(error.localizedDescription)") + } + } + + if let blkSourceKey, + let record = mirrorIndex[mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: blkSourceKey)], + let existing = resolveMappedEvent(for: record) { + await updateExisting(existing, byTime: false) + return + } + + if let blkSourceKey, let existing = placeholdersBySourceKey[blkSourceKey] { + await updateExisting(existing, byTime: false) + return + } + + if let existingByTime = placeholdersByTime[exactTimeKey] { + await updateExisting(existingByTime, byTime: true) + return + } + + if placeholderSet.contains(exactTimeKey) { + skipped += 1 + return + } + if !config.writeEnabled { + sessionGuard.insert(gKey) + log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") + return + } + guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { + skipped += 1 + log("- SKIP invariant: target is source [\(srcName)]") + return + } + let newEv = EKEvent(eventStore: store) + newEv.calendar = tgt + newEv.title = displayTitle + newEv.startDate = blk.start + newEv.endDate = blk.end + newEv.isAllDay = false + newEv.notes = notes + newEv.url = desiredURL + newEv.availability = .busy + do { + try await MainActor.run { + try store.save(newEv, span: .thisEvent, commit: true) + } + created += 1 + log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") + rememberMirrorEvent(newEv, for: blk) + occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)]) + sessionGuard.insert(gKey) + } catch { + log("Save failed: \(error.localizedDescription)") + } + } + + for b in baseBlocks { + if Task.isCancelled { break } + switch config.overlapMode { + case .allow: + await createOrUpdateIfNeeded(b) + case .skipCovered: + if fullyCovered(occupied, block: b, tolMin: SAME_TIME_TOL_MIN) { + log("- SKIP covered [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") + skipped += 1 + } else { + await createOrUpdateIfNeeded(b) + } + case .fillGaps: + let gaps = gapsWithin(occupied, in: b) + if gaps.isEmpty { + log("- SKIP no gaps [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") + skipped += 1 + } else { + for g in gaps { await createOrUpdateIfNeeded(g) } + } + } + } + log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)") + if config.autoDeleteMissing { + let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) }) + let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) }) + + var byID: [String: EKEvent] = [:] + for tv in placeholdersByTime.values { + guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } + if let eid = tv.eventIdentifier { byID[eid] = tv } + } + + var removed = 0 + var skippedOtherSource = 0 + var skippedLegacyNoURL = 0 + var handledEventIDs = Set() + + let staleMirrorRecords = mirrorIndex.filter { + $0.value.targetCalendarID == tgt.calendarIdentifier && + $0.value.sourceCalendarID == srcCal.calendarIdentifier && + !activeSourceKeys.contains($0.value.sourceKey) + } + + for (recordKey, record) in staleMirrorRecords { + if Task.isCancelled { break } + let candidate = resolveMappedEvent(for: record) + if let candidate { + if !config.writeEnabled { + log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)") + } else { + do { + try await MainActor.run { + try store.remove(candidate, span: .thisEvent, commit: true) + } + removed += 1 + } catch { + log("Delete failed: \(error.localizedDescription)") + } + } + if let eid = candidate.eventIdentifier { + handledEventIDs.insert(eid) + } + } + if config.writeEnabled || candidate == nil { + if mirrorIndex.removeValue(forKey: recordKey) != nil { + mirrorIndexChanged = true + } + } + } + + for ev in byID.values { + if Task.isCancelled { break } + if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) { + continue + } + let parsed = parseMirrorURL(ev.url) + var shouldDelete = false + var parsedSourceKey: String? = nil + if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty { + if sourceCalID != srcCal.calendarIdentifier { + skippedOtherSource += 1 + continue + } + if let sourceStableID = parsed.sourceStableID, !sourceStableID.isEmpty { + let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ) + parsedSourceKey = key + if !activeSourceKeys.contains(key) { shouldDelete = true } + } else if trackByID, + let s = ev.startDate, + let e = ev.endDate, + !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) { + shouldDelete = true + } + } else if trackByID && !isMultiRouteRun { + if let s = ev.startDate, + let e = ev.endDate, + !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) { + shouldDelete = true + } + } else if trackByID && isMultiRouteRun { + let hasMapping = mirrorIndex.values.contains { + $0.targetCalendarID == tgt.calendarIdentifier && + $0.sourceCalendarID == srcCal.calendarIdentifier && + $0.targetEventIdentifier == ev.eventIdentifier + } + if !hasMapping { + skippedLegacyNoURL += 1 + } + continue + } + if shouldDelete { + if !config.writeEnabled { + log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") + } else { + do { + try await MainActor.run { + try store.remove(ev, span: .thisEvent, commit: true) + } + removed += 1 + } catch { + log("Delete failed: \(error.localizedDescription)") + } + } + if let key = parsedSourceKey { + removeMirrorRecord(for: key) + } + } + } + if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") } + if skippedOtherSource > 0 { + log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)") + } + if skippedLegacyNoURL > 0 { + log("- INFO cleanup skipped \(skippedLegacyNoURL) unmanaged legacy placeholders without source metadata on \(tgtName)") + } + } + } + if mirrorIndexChanged { + saveMirrorIndex(mirrorIndex) + } + } + + func runCleanup( + store: EKEventStore, + daysBack: Int, + daysForward: Int, + sourceCalendar: EKCalendar, + targetCalendars: [EKCalendar], + titlePrefix: String, + placeholderTitle: String, + writeEnabled: Bool + ) async { + let cal = Calendar.current + let todayStart = cal.startOfDay(for: Date()) + let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! + let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! + log("=== Cleanup Busy placeholders in window ===") + log("(Cleanup is SAFE: mirrored events detected by url prefix or title prefix ‘\(titlePrefix)’)") + log("Window: \(windowStart) -> \(windowEnd)") + + for tgt in targetCalendars { + let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) + let tgtEvents = store.events(matching: tgtPred) + var delCount = 0 + for ev in tgtEvents { + guard isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) else { continue } + if !writeEnabled { + log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)") + } else { + do { + try await MainActor.run { + try store.remove(ev, span: .thisEvent, commit: true) + } + delCount += 1 + } + catch { log("Delete failed: \(error.localizedDescription)") } + } + } + log("[Cleanup \(tgt.title)] deleted=\(delCount)") + } + } +} diff --git a/BusyMirror/MirrorUtils.swift b/BusyMirror/MirrorUtils.swift index f523eb0..b85bf7f 100644 --- a/BusyMirror/MirrorUtils.swift +++ b/BusyMirror/MirrorUtils.swift @@ -1,6 +1,12 @@ import Foundation import EventKit +// Calendar label helper to disambiguate identical names +func calLabel(_ cal: EKCalendar) -> String { + let src = cal.source.title + return src.isEmpty ? cal.title : "\(cal.title) — \(src)" +} + // Remove our prefix when building titles so it never doubles up func stripPrefix(_ title: String?, prefix: String) -> String { guard let t = title else { return "" } diff --git a/CHANGELOG.md b/CHANGELOG.md index da62eef..c283845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to BusyMirror will be documented in this file. +## [1.5.0] - 2026-05-27 + +### Removed +- **Mark Private feature**: removed the non-functional server-side "Private" flagging for mirrored events. The Objective-C runtime hack (`setPrivate:`, KVC on `sensitivity`/`classification`) never worked reliably and would have blocked App Store review. This simplifies the UI and removes a private-API liability. + +### Changed +- **Extracted mirror engine**: the ~500-line `runMirror` and `runCleanup` logic has been moved from `ContentView.swift` into a new `MirrorEngine.swift` class. `ContentView` now delegates to the engine via `makeEngine()`. +- `MirrorRecord`, mirror index persistence, and `SAME_TIME_TOL_MIN` now live in the engine module. +- `calLabel` moved to `MirrorUtils.swift` so it can be shared between UI and engine. + +### Build +- Bump version to **1.5.0** (build **19**). + ## [1.4.0] - 2026-05-27 ### Fixed