Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ae396da | |||
| 2c319808c2 |
@@ -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,26 +36,26 @@ 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 (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:** 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 +96,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 +112,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,9 +135,15 @@ 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/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 |
|
||||
@@ -149,6 +154,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.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15
|
||||
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
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.4.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 = 18;
|
||||
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.4.0;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+73
-751
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
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()
|
||||
|
||||
// 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,
|
||||
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<String>,
|
||||
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<String>()
|
||||
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.span(start: ts, end: te))
|
||||
}
|
||||
}
|
||||
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 store.save(existing, span: .thisEvent, commit: true)
|
||||
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
||||
rememberMirrorEvent(existing, for: blk)
|
||||
occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)])
|
||||
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 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.span(start: blk.start, end: blk.end)])
|
||||
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<String>()
|
||||
|
||||
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 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 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 store.remove(ev, span: .thisEvent, commit: true)
|
||||
delCount += 1
|
||||
} catch {
|
||||
log("Delete failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
log("[Cleanup \(tgt.title)] deleted=\(delCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "" }
|
||||
@@ -51,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)
|
||||
@@ -62,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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
@@ -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`).
|
||||
Reference in New Issue
Block a user