diff --git a/AGENTS.md b/AGENTS.md index bcd3fcf..5f46918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,16 +40,17 @@ BusyMirror/ ├── 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 +├── BlockMath.swift # Block merging, gap calculation, overlap logic (Block.span factory) ├── EventFilters.swift # Work-hours, title, and organizer filters ├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view +├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum) ├── Info.plist # LSUIElement, calendar usage descriptions ├── BusyMirror.entitlements # App sandbox + calendar access entitlement └── Assets.xcassets/ # AppIcon set and accent color -BusyMirror.xcodeproj/ # Xcode project -BusyMirrorTests/ # Empty (no tests implemented) -BusyMirrorUITests/ # Empty (no tests implemented) +BusyMirror.xcodeproj/ # Xcode project (PBXFileSystemSynchronizedRootGroup — new .swift files are auto-included) +BusyMirrorTests/ # Unit tests: BlockMathTests, EventFiltersTests, MirrorUtilsTests (45 tests) +BusyMirrorUITests/ # UI tests (empty) ``` **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. @@ -142,6 +143,7 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau | `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/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) | | `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions | | `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement | | `Makefile` | Reproducible build, sign, and package targets | diff --git a/BusyMirror-1.5.1-macOS.zip b/BusyMirror-1.5.1-macOS.zip new file mode 100644 index 0000000..7f8c2aa Binary files /dev/null and b/BusyMirror-1.5.1-macOS.zip differ diff --git a/BusyMirror-1.5.1-macOS.zip.sha256 b/BusyMirror-1.5.1-macOS.zip.sha256 new file mode 100644 index 0000000..d277b86 --- /dev/null +++ b/BusyMirror-1.5.1-macOS.zip.sha256 @@ -0,0 +1 @@ +9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15 diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 6f879c6..35e583e 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 = 19; + CURRENT_PROJECT_VERSION = 20; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/AppLogStore.swift b/BusyMirror/AppLogStore.swift new file mode 100644 index 0000000..15c41a6 --- /dev/null +++ b/BusyMirror/AppLogStore.swift @@ -0,0 +1,51 @@ +import Foundation + +enum AppLogStore { + private static let queue = DispatchQueue(label: "BusyMirror.log.store") + private static let maxLogSizeBytes: UInt64 = 1_000_000 + + static let logDirectoryURL: URL = { + let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + return base.appendingPathComponent("Logs/BusyMirror", isDirectory: true) + }() + + static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false) + private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false) + static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false) + static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false) + + private static let timestampFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static func append(_ message: String) { + let line = "[\(timestampFormatter.string(from: Date()))] \(message)\n" + queue.async { + let fm = FileManager.default + do { + try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true) + if let attrs = try? fm.attributesOfItem(atPath: logFileURL.path), + let size = attrs[.size] as? NSNumber, + size.uint64Value >= maxLogSizeBytes { + try? fm.removeItem(at: archivedLogFileURL) + try? fm.moveItem(at: logFileURL, to: archivedLogFileURL) + } + if !fm.fileExists(atPath: logFileURL.path) { + fm.createFile(atPath: logFileURL.path, contents: nil) + } + guard let data = line.data(using: .utf8) else { return } + // Use the throwing initialiser so we don't silently swallow + // an inaccessible file — the outer catch handles it. + let handle = try FileHandle(forWritingTo: logFileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + // Logging must never break the app's main behavior. + } + } + } +} diff --git a/BusyMirror/BlockMath.swift b/BusyMirror/BlockMath.swift index f013b45..2366f41 100644 --- a/BusyMirror/BlockMath.swift +++ b/BusyMirror/BlockMath.swift @@ -7,6 +7,11 @@ struct Block: Hashable { let label: String? // source title (for dry-run / non-private) let notes: String? // source notes (for optional copy) let occurrence: Date? // occurrenceDate for recurring instances + + /// Convenience factory for time-only blocks (used internally for occupancy tracking). + static func span(start: Date, end: Date) -> Block { + Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) + } } // De-dup blocks by occurrence (preferred) or by time range @@ -30,14 +35,14 @@ func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] { guard !blocks.isEmpty else { return [] } let sorted = blocks.sorted { $0.start < $1.start } var out: [Block] = [] - var cur = Block(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) + var cur = Block.span(start: sorted[0].start, end: sorted[0].end) for b in sorted.dropFirst() { let gap = b.start.timeIntervalSince(cur.end) / 60.0 if gap <= Double(gapMinutes) { - if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) } + if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) } } else { out.append(cur) - cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) + cur = Block.span(start: b.start, end: b.end) } } out.append(cur) @@ -55,21 +60,21 @@ func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool { } func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] { - if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] } + if mergedSegs.isEmpty { return [Block.span(start: block.start, end: block.end)] } var segs: [Block] = [] for s in mergedSegs where s.end > block.start && s.start < block.end { let ss = max(s.start, block.start) let ee = min(s.end, block.end) - if ee > ss { segs.append(Block(start: ss, end: ee, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) } + if ee > ss { segs.append(Block.span(start: ss, end: ee)) } } - if segs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] } + if segs.isEmpty { return [Block.span(start: block.start, end: block.end)] } let merged = coalesce(segs) var gaps: [Block] = [] var prevEnd = block.start for s in merged { - if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) } + if s.start > prevEnd { gaps.append(Block.span(start: prevEnd, end: s.start)) } if s.end > prevEnd { prevEnd = s.end } } - if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) } + if prevEnd < block.end { gaps.append(Block.span(start: prevEnd, end: block.end)) } return gaps } diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index ad17594..0abf5f0 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -2,55 +2,6 @@ import SwiftUI import EventKit import AppKit -private let SKIP_ALL_DAY_DEFAULT = true - -private enum AppLogStore { - private static let queue = DispatchQueue(label: "BusyMirror.log.store") - private static let maxLogSizeBytes: UInt64 = 1_000_000 - - static let logDirectoryURL: URL = { - let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) - return base.appendingPathComponent("Logs/BusyMirror", isDirectory: true) - }() - - static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false) - private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false) - static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false) - static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false) - - private static let timestampFormatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() - - static func append(_ message: String) { - let line = "[\(timestampFormatter.string(from: Date()))] \(message)\n" - queue.async { - let fm = FileManager.default - do { - try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true) - if let attrs = try? fm.attributesOfItem(atPath: logFileURL.path), - let size = attrs[.size] as? NSNumber, - size.uint64Value >= maxLogSizeBytes { - try? fm.removeItem(at: archivedLogFileURL) - try? fm.moveItem(at: logFileURL, to: archivedLogFileURL) - } - if !fm.fileExists(atPath: logFileURL.path) { - fm.createFile(atPath: logFileURL.path, contents: nil) - } - guard let data = line.data(using: .utf8), - let handle = FileHandle(forWritingAtPath: logFileURL.path) else { return } - defer { handle.closeFile() } - handle.seekToEndOfFile() - handle.write(data) - } catch { - // Logging must never break the app's main behavior. - } - } - } -} enum OverlapMode: String, CaseIterable, Identifiable, Codable { case allow, skipCovered, fillGaps @@ -162,6 +113,8 @@ struct ContentView: View { @State private var confirmCleanup = false @State private var mirrorTask: Task? = 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. @@ -1345,6 +1298,7 @@ struct ContentView: View { } .onDisappear { appController.setMainWindowVisible(false) + unregisterStoreObserver() } // Persist key settings whenever they change, to ensure restore between runs .onChange(of: appController.syncRequestToken) { _ in @@ -1497,7 +1451,10 @@ struct ContentView: View { } } } - if CommandLine.arguments.contains("--exit") || isCLIRun { + // 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) } } @@ -1542,6 +1499,9 @@ struct ContentView: View { func reloadCalendars(forceResetStore: Bool = false) { 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) @@ -1556,35 +1516,57 @@ struct ContentView: View { saveSettingsToDefaults() } 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 -private struct SettingsPayload: Codable { - var daysBack: Int - var daysForward: Int - var mergeGapHours: Int - var hideDetails: Bool - var copyDescription: Bool - 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() -} + // MARK: - Export / Import Settings + private struct SettingsPayload: Codable { + var daysBack: Int + var daysForward: Int + var mergeGapHours: Int + var hideDetails: Bool + var copyDescription: Bool + 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() + } private func makeSnapshot() -> SettingsPayload { SettingsPayload( @@ -1697,11 +1679,9 @@ private struct SettingsPayload: Codable { 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 - } + // 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) } diff --git a/BusyMirror/MirrorEngine.swift b/BusyMirror/MirrorEngine.swift index bb425b4..2dc2a87 100644 --- a/BusyMirror/MirrorEngine.swift +++ b/BusyMirror/MirrorEngine.swift @@ -13,6 +13,29 @@ struct MirrorRecord: Hashable, Codable { var lastKnownEndTimestamp: TimeInterval var updatedAt: Date = Date() + // updatedAt is intentionally excluded from equality and hashing: it is a + // bookkeeping timestamp that changes on every write and should not cause + // the mirror index to be marked dirty when the meaningful fields are equal. + static func == (lhs: MirrorRecord, rhs: MirrorRecord) -> Bool { + lhs.targetCalendarID == rhs.targetCalendarID && + lhs.sourceCalendarID == rhs.sourceCalendarID && + lhs.sourceStableID == rhs.sourceStableID && + lhs.occurrenceTimestamp == rhs.occurrenceTimestamp && + lhs.targetEventIdentifier == rhs.targetEventIdentifier && + lhs.lastKnownStartTimestamp == rhs.lastKnownStartTimestamp && + lhs.lastKnownEndTimestamp == rhs.lastKnownEndTimestamp + } + + func hash(into hasher: inout Hasher) { + hasher.combine(targetCalendarID) + hasher.combine(sourceCalendarID) + hasher.combine(sourceStableID) + hasher.combine(occurrenceTimestamp) + hasher.combine(targetEventIdentifier) + hasher.combine(lastKnownStartTimestamp) + hasher.combine(lastKnownEndTimestamp) + } + var sourceKey: String { sourceOccurrenceKey( sourceCalID: sourceCalendarID, @@ -229,7 +252,7 @@ final class MirrorEngine { } } } - occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) + occupied.append(Block.span(start: ts, end: te)) } } occupied = coalesce(occupied) @@ -360,12 +383,10 @@ final class MirrorEngine { existing.notes = notes existing.url = desiredURL do { - try await MainActor.run { - try store.save(existing, span: .thisEvent, commit: true) - } + 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)]) + occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)]) sessionGuard.insert(gKey) updated += 1 } catch { @@ -414,13 +435,11 @@ final class MirrorEngine { newEv.url = desiredURL newEv.availability = .busy do { - try await MainActor.run { - try store.save(newEv, span: .thisEvent, commit: true) - } + 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)]) + occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)]) sessionGuard.insert(gKey) } catch { log("Save failed: \(error.localizedDescription)") @@ -479,9 +498,7 @@ final class MirrorEngine { 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) - } + try store.remove(candidate, span: .thisEvent, commit: true) removed += 1 } catch { log("Delete failed: \(error.localizedDescription)") @@ -543,9 +560,7 @@ final class MirrorEngine { 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) - } + try store.remove(ev, span: .thisEvent, commit: true) removed += 1 } catch { log("Delete failed: \(error.localizedDescription)") @@ -598,12 +613,11 @@ final class MirrorEngine { 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) - } + try store.remove(ev, span: .thisEvent, commit: true) delCount += 1 + } catch { + log("Delete failed: \(error.localizedDescription)") } - catch { log("Delete failed: \(error.localizedDescription)") } } } log("[Cleanup \(tgt.title)] deleted=\(delCount)") diff --git a/BusyMirror/MirrorUtils.swift b/BusyMirror/MirrorUtils.swift index b85bf7f..8be48c7 100644 --- a/BusyMirror/MirrorUtils.swift +++ b/BusyMirror/MirrorUtils.swift @@ -57,10 +57,12 @@ func mirrorTimeKey(start: Date, end: Date) -> String { func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: String?, occurrence: Date?, start: Date, end: Date) -> URL? { let sourceID = sourceStableID ?? "" let occ = occurrence.map { String($0.timeIntervalSince1970) } ?? "-" + // Percent-encode IDs so that any embedded ";" doesn't corrupt the + // semicolon-delimited path when the URL is later parsed. let parts = [ - targetCalID, - sourceCalID, - sourceID, + mirrorURLComponentEncode(targetCalID), + mirrorURLComponentEncode(sourceCalID), + mirrorURLComponentEncode(sourceID), occ, String(start.timeIntervalSince1970), String(end.timeIntervalSince1970) @@ -68,7 +70,9 @@ func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: St var components = URLComponents() components.scheme = "mirror" components.host = "x" - components.path = "/" + parts.joined(separator: ";") + // Use percentEncodedPath so URLComponents does not re-encode the already + // percent-encoded IDs (double-encoding would break round-trip parsing). + components.percentEncodedPath = "/" + parts.joined(separator: ";") return components.url } diff --git a/CHANGELOG.md b/CHANGELOG.md index c283845..7e610e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to BusyMirror will be documented in this file. +## [1.5.1] - 2026-05-27 + +### Fixed +- **Mirror index dirtied on every sync**: `MirrorRecord` used synthesized `Equatable` which included `updatedAt: Date = Date()`. Because `updatedAt` is set to the current time whenever a record is constructed, the comparison used to detect changes always returned "not equal", causing `UserDefaults` to be written on every sync run even when nothing changed. A custom `==` / `hash(into:)` now excludes `updatedAt`. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift)) +- **Mirror URL corruption with special characters in calendar IDs**: `buildMirrorURL` placed raw calendar and source IDs into the URL path without percent-encoding them. If any ID contained the `;` separator character the resulting URL would be mis-parsed on the next sync. `mirrorURLComponentEncode` (which already existed and was tested) is now called on all ID fields before they are joined. The path is set via `percentEncodedPath` to prevent `URLComponents` from double-encoding the already-encoded values. ([MirrorUtils.swift](BusyMirror/MirrorUtils.swift)) +- **Dead constant**: removed unused `SKIP_ALL_DAY_DEFAULT = true` from `ContentView.swift`. +- **Deprecated `FileHandle` API**: replaced `FileHandle(forWritingAtPath:)` + `handle.closeFile()` with the modern throwing `FileHandle(forWritingTo:)`, `handle.seekToEnd()`, and `handle.write(contentsOf:)` in `AppLogStore`. ([AppLogStore.swift](BusyMirror/AppLogStore.swift)) +- **Cleanup jumps calendar picker**: `runCleanupForRoute` was mutating `sourceIndex`, `sourceID`, and `targetIDs` during route cleanup, visibly shifting the picker in the UI. Cleanup does not need to update the UI selection; those mutations are removed. +- **`--exit` flag redundancy**: `NSApp.terminate` was called whenever `isCLIRun` was true, making `--exit` a no-op. The app now exits only when `--exit` is explicitly passed, so `--routes` / `--run-saved-routes` can be used without forcing termination. + +### Added +- **Live calendar refresh**: the calendar list now updates automatically when the system calendar database changes (`EKEventStoreChanged` notification), removing the need to press "Refresh Calendars" after adding or removing a calendar. The observer is unregistered on view disappear and re-registered when the `EKEventStore` is recreated. ([ContentView.swift](BusyMirror/ContentView.swift)) + +### Changed +- **`AppLogStore` extracted**: moved from an inline private enum in `ContentView.swift` to its own file `AppLogStore.swift` for easier navigation. ([AppLogStore.swift](BusyMirror/AppLogStore.swift)) +- **`Block.span` factory**: added `Block.span(start:end:)` to replace the repetitive `Block(start:end:srcStableID:nil:label:nil:notes:nil:occurrence:nil)` construction pattern throughout `BlockMath.swift` and `MirrorEngine.swift`. ([BlockMath.swift](BusyMirror/BlockMath.swift)) +- **Removed redundant `MainActor.run` wrappers**: `MirrorEngine` is `@MainActor`; wrapping `store.save` / `store.remove` in `try await MainActor.run { }` was unnecessary and added overhead. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift)) +- **`SettingsPayload` indentation**: the nested struct was de-dented to column 0 inside `ContentView`, making it look like a top-level type. Indentation is now consistent with the surrounding members. + +### Build +- Bump version to **1.5.1** (build **20**). + ## [1.5.0] - 2026-05-27 ### Removed diff --git a/ReleaseNotes-1.5.1.md b/ReleaseNotes-1.5.1.md new file mode 100644 index 0000000..113f62b --- /dev/null +++ b/ReleaseNotes-1.5.1.md @@ -0,0 +1,21 @@ +# BusyMirror 1.5.1 + +## Bug fixes + +- **Mirror index written on every sync** — `MirrorRecord`'s synthesized equality check included an `updatedAt` timestamp that is always set to the current date when a record is constructed. This meant every sync run marked the index as dirty and rewrote it to `UserDefaults`, even when no mirror events changed. Fixed with a custom `==` that ignores `updatedAt`. + +- **Mirror URLs corrupted by special characters in calendar IDs** — Calendar and source IDs placed into the `mirror://` URL were not percent-encoded before joining with `;`. An ID containing `;` would cause the URL to be mis-parsed on the next sync, potentially losing the link between a placeholder and its source event. IDs are now encoded with `mirrorURLComponentEncode` (already present and tested since 1.4.0) and the URL path is assigned via `percentEncodedPath` to prevent double-encoding. + +- **Calendar picker jumped during route cleanup** — Running "Cleanup Placeholders" over saved routes changed the source/target picker selection for each route. Cleanup no longer mutates the UI selection. + +- **`--exit` flag was always implied** — Using `--routes` or `--run-saved-routes` always terminated the app, making `--exit` redundant. The app now exits only when `--exit` is explicitly passed. + +## Improvements + +- **Live calendar refresh** — The calendar list now updates automatically when the system calendar database changes (new account added, calendar renamed, etc.), without requiring a manual "Refresh Calendars" press. + +- `AppLogStore` extracted into its own file; deprecated `FileHandle` API replaced with the modern throwing variant. + +- `Block.span(start:end:)` convenience factory added to `BlockMath`, eliminating repetitive nil-field construction. + +- Redundant `MainActor.run {}` wrappers removed from `MirrorEngine` (already running on `@MainActor`).