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)
This commit is contained in:
@@ -10,7 +10,7 @@ It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using
|
|||||||
|
|
||||||
Key capabilities:
|
Key capabilities:
|
||||||
- Manual or route-driven multi-source mirroring
|
- 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
|
- DRY-RUN mode to preview changes without writing
|
||||||
- Scheduled headless runs via a self-installed `launchd` LaunchAgent
|
- Scheduled headless runs via a self-installed `launchd` LaunchAgent
|
||||||
- Settings autosave/restore, plus Import/Export JSON
|
- 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/
|
BusyMirror/
|
||||||
├── BusyMirrorApp.swift # App entry point; defines Window + MenuBarExtra
|
├── 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
|
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
||||||
├── Info.plist # LSUIElement, calendar usage descriptions
|
├── Info.plist # LSUIElement, calendar usage descriptions
|
||||||
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||||||
@@ -47,15 +52,9 @@ BusyMirrorTests/ # Empty (no tests implemented)
|
|||||||
BusyMirrorUITests/ # Empty (no tests implemented)
|
BusyMirrorUITests/ # Empty (no tests implemented)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Architecture note:** nearly all business logic lives inside `ContentView.swift`. This includes:
|
**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.
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
## Build and Release Commands
|
||||||
|
|
||||||
@@ -96,8 +95,8 @@ Built products:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **There are no implemented tests.** `BusyMirrorTests/` and `BusyMirrorUITests/` exist as Xcode targets but contain no source files.
|
- 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 later.
|
- 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:
|
- Manual testing checklist for releases:
|
||||||
1. Grant Calendar permission.
|
1. Grant Calendar permission.
|
||||||
2. Select a source and target, run DRY-RUN, verify log output.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 |
|
| 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/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
||||||
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
||||||
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
|
| `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.
|
- 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.
|
- 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.
|
- 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.
|
- Do not run `git commit`, `git push`, or similar operations unless explicitly asked.
|
||||||
|
|||||||
@@ -410,7 +410,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
+47
-705
@@ -1,10 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import EventKit
|
import EventKit
|
||||||
import AppKit
|
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 let SKIP_ALL_DAY_DEFAULT = true
|
||||||
|
|
||||||
private enum AppLogStore {
|
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
|
// Calendar color helpers
|
||||||
private func calColor(_ cal: EKCalendar) -> Color {
|
private func calColor(_ cal: EKCalendar) -> Color {
|
||||||
#if os(macOS)
|
#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 {
|
struct Route: Identifiable, Hashable, Codable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
var sourceID: String
|
var sourceID: String
|
||||||
@@ -128,11 +96,9 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
var mergeGapHours: Int // per-route merge gap (hours)
|
var mergeGapHours: Int // per-route merge gap (hours)
|
||||||
var overlap: OverlapMode // per-route overlap behavior
|
var overlap: OverlapMode // per-route overlap behavior
|
||||||
var allDay: Bool // per-route mirror all-day
|
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<String>, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
|
||||||
|
|
||||||
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool, markPrivate: Bool = false) {
|
|
||||||
self.sourceID = sourceID
|
self.sourceID = sourceID
|
||||||
self.targetIDs = targetIDs
|
self.targetIDs = targetIDs
|
||||||
self.privacy = privacy
|
self.privacy = privacy
|
||||||
@@ -140,7 +106,6 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
self.mergeGapHours = mergeGapHours
|
self.mergeGapHours = mergeGapHours
|
||||||
self.overlap = overlap
|
self.overlap = overlap
|
||||||
self.allDay = allDay
|
self.allDay = allDay
|
||||||
self.markPrivate = markPrivate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -152,7 +117,7 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
|
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
|
||||||
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
|
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
|
||||||
self.allDay = try c.decode(Bool.self, forKey: .allDay)
|
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) }
|
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
|
||||||
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
@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("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("mirrorAllDay") private var mirrorAllDay: Bool = false
|
||||||
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
|
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
|
||||||
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
|
@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("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||||||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
@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
|
@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)
|
// Mirrors can run either by manual selection (source + at least one target)
|
||||||
// or using predefined routes. This derived flag controls the Mirror Now button.
|
// 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)
|
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 = {
|
private static let intFormatter: NumberFormatter = {
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter()
|
||||||
f.numberStyle = .none
|
f.numberStyle = .none
|
||||||
@@ -647,8 +591,7 @@ struct ContentView: View {
|
|||||||
copyNotes: copyDescription,
|
copyNotes: copyDescription,
|
||||||
mergeGapHours: mergeGapHours,
|
mergeGapHours: mergeGapHours,
|
||||||
overlap: overlapMode,
|
overlap: overlapMode,
|
||||||
allDay: mirrorAllDay,
|
allDay: mirrorAllDay)
|
||||||
markPrivate: markPrivate)
|
|
||||||
routes.append(r)
|
routes.append(r)
|
||||||
}
|
}
|
||||||
.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty)
|
.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty)
|
||||||
@@ -691,9 +634,6 @@ struct ContentView: View {
|
|||||||
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
||||||
.disabled(isRunning || route.privacy)
|
.disabled(isRunning || route.privacy)
|
||||||
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
|
.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)
|
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
.help("Mirror all-day events for this source.")
|
.help("Mirror all-day events for this source.")
|
||||||
@@ -828,7 +768,6 @@ struct ContentView: View {
|
|||||||
mergeGapMin: max(0, r.mergeGapHours * 60),
|
mergeGapMin: max(0, r.mergeGapHours * 60),
|
||||||
hideDetails: r.privacy,
|
hideDetails: r.privacy,
|
||||||
copyDescription: r.copyNotes,
|
copyDescription: r.copyNotes,
|
||||||
markPrivate: r.markPrivate,
|
|
||||||
mirrorAllDay: r.allDay,
|
mirrorAllDay: r.allDay,
|
||||||
overlapMode: r.overlap,
|
overlapMode: r.overlap,
|
||||||
titlePrefix: titlePrefix,
|
titlePrefix: titlePrefix,
|
||||||
@@ -851,7 +790,8 @@ struct ContentView: View {
|
|||||||
targetIDs.remove(r.sourceID)
|
targetIDs.remove(r.sourceID)
|
||||||
progressText = "Route \(idx + 1) of \(configuredRoutes.count)"
|
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 {
|
if skippedMissingSource > 0 {
|
||||||
@@ -872,7 +812,6 @@ struct ContentView: View {
|
|||||||
mergeGapMin: mergeGapMin,
|
mergeGapMin: mergeGapMin,
|
||||||
hideDetails: hideDetails,
|
hideDetails: hideDetails,
|
||||||
copyDescription: copyDescription,
|
copyDescription: copyDescription,
|
||||||
markPrivate: markPrivate,
|
|
||||||
mirrorAllDay: mirrorAllDay,
|
mirrorAllDay: mirrorAllDay,
|
||||||
overlapMode: overlapMode,
|
overlapMode: overlapMode,
|
||||||
titlePrefix: titlePrefix,
|
titlePrefix: titlePrefix,
|
||||||
@@ -917,7 +856,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||||||
let targets = calendars.filter { targetSet.contains($0.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 {
|
} else {
|
||||||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||||||
}
|
}
|
||||||
@@ -1011,8 +951,6 @@ struct ContentView: View {
|
|||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
Toggle("Copy description when mirroring", isOn: $copyDescription)
|
Toggle("Copy description when mirroring", isOn: $copyDescription)
|
||||||
.disabled(isRunning || hideDetails)
|
.disabled(isRunning || hideDetails)
|
||||||
Toggle("Mark mirrored events as Private (if supported)", isOn: $markPrivate)
|
|
||||||
.disabled(isRunning)
|
|
||||||
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
|
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
|
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
|
||||||
@@ -1218,17 +1156,10 @@ struct ContentView: View {
|
|||||||
// Dry-run: run without confirmation
|
// Dry-run: run without confirmation
|
||||||
Task {
|
Task {
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
await runCleanup()
|
await runCleanupForCurrentSelection()
|
||||||
} else {
|
} else {
|
||||||
for r in routes {
|
for r in routes {
|
||||||
if let sIdx = indexForCalendar(id: r.sourceID) {
|
await runCleanupForRoute(r)
|
||||||
await MainActor.run {
|
|
||||||
sourceIndex = sIdx
|
|
||||||
sourceID = r.sourceID
|
|
||||||
targetIDs = r.targetIDs
|
|
||||||
}
|
|
||||||
await runCleanup()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1389,17 +1320,10 @@ struct ContentView: View {
|
|||||||
Button("Delete now", role: .destructive) {
|
Button("Delete now", role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
await runCleanup()
|
await runCleanupForCurrentSelection()
|
||||||
} else {
|
} else {
|
||||||
for r in routes {
|
for r in routes {
|
||||||
if let sIdx = indexForCalendar(id: r.sourceID) {
|
await runCleanupForRoute(r)
|
||||||
await MainActor.run {
|
|
||||||
sourceIndex = sIdx
|
|
||||||
sourceID = r.sourceID
|
|
||||||
targetIDs = r.targetIDs
|
|
||||||
}
|
|
||||||
await runCleanup()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1361,6 @@ struct ContentView: View {
|
|||||||
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: markPrivate) { _ in saveSettingsToDefaults() }
|
|
||||||
.onChange(of: sourceIndex) { newValue in
|
.onChange(of: sourceIndex) { newValue in
|
||||||
// Track selected source by persistent ID and ensure it is not a target
|
// Track selected source by persistent ID and ensure it is not a target
|
||||||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||||||
@@ -1538,15 +1461,8 @@ struct ContentView: View {
|
|||||||
log("CLI: no saved routes; aborting")
|
log("CLI: no saved routes; aborting")
|
||||||
} else if boolArg("--cleanup-only", default: false) {
|
} else if boolArg("--cleanup-only", default: false) {
|
||||||
for r in routes {
|
for r in routes {
|
||||||
if let sIdx = indexForCalendar(id: r.sourceID) {
|
log("CLI: cleanup saved route \(r.sourceID)")
|
||||||
await MainActor.run {
|
await runCleanupForRoute(r)
|
||||||
sourceIndex = sIdx
|
|
||||||
sourceID = r.sourceID
|
|
||||||
targetIDs = r.targetIDs
|
|
||||||
}
|
|
||||||
log("CLI: cleanup saved route \(r.sourceID)")
|
|
||||||
await runCleanup()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var sessionGuard = Set<String>()
|
var sessionGuard = Set<String>()
|
||||||
@@ -1571,11 +1487,13 @@ struct ContentView: View {
|
|||||||
|
|
||||||
if boolArg("--cleanup-only", default: false) {
|
if boolArg("--cleanup-only", default: false) {
|
||||||
log("CLI: cleanup route \(part)")
|
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 {
|
} else {
|
||||||
log("CLI: mirror route \(part)")
|
log("CLI: mirror route \(part)")
|
||||||
|
let engine = makeEngine()
|
||||||
var sessionGuard = Set<String>()
|
var sessionGuard = Set<String>()
|
||||||
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()
|
handlePendingMenuBarSyncIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mirror engine (EventKit)
|
|
||||||
func runMirror(config: MirrorConfig, sourceCalendar: EKCalendar, targetCalendars: [EKCalendar], sessionGuard: inout Set<String>, 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<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(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<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 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
|
// MARK: - Export / Import Settings
|
||||||
private struct SettingsPayload: Codable {
|
private struct SettingsPayload: Codable {
|
||||||
var daysBack: Int
|
var daysBack: Int
|
||||||
@@ -2177,7 +1566,6 @@ private struct SettingsPayload: Codable {
|
|||||||
var mergeGapHours: Int
|
var mergeGapHours: Int
|
||||||
var hideDetails: Bool
|
var hideDetails: Bool
|
||||||
var copyDescription: Bool
|
var copyDescription: Bool
|
||||||
var markPrivate: Bool
|
|
||||||
var mirrorAllDay: Bool
|
var mirrorAllDay: Bool
|
||||||
var filterByWorkHours: Bool = false
|
var filterByWorkHours: Bool = false
|
||||||
var workHoursStart: Int = 9
|
var workHoursStart: Int = 9
|
||||||
@@ -2205,7 +1593,6 @@ private struct SettingsPayload: Codable {
|
|||||||
mergeGapHours: mergeGapHours,
|
mergeGapHours: mergeGapHours,
|
||||||
hideDetails: hideDetails,
|
hideDetails: hideDetails,
|
||||||
copyDescription: copyDescription,
|
copyDescription: copyDescription,
|
||||||
markPrivate: markPrivate,
|
|
||||||
mirrorAllDay: mirrorAllDay,
|
mirrorAllDay: mirrorAllDay,
|
||||||
filterByWorkHours: filterByWorkHours,
|
filterByWorkHours: filterByWorkHours,
|
||||||
workHoursStart: workHoursStart,
|
workHoursStart: workHoursStart,
|
||||||
@@ -2232,7 +1619,6 @@ private struct SettingsPayload: Codable {
|
|||||||
hideDetails = s.hideDetails
|
hideDetails = s.hideDetails
|
||||||
copyDescription = s.copyDescription
|
copyDescription = s.copyDescription
|
||||||
mirrorAllDay = s.mirrorAllDay
|
mirrorAllDay = s.mirrorAllDay
|
||||||
markPrivate = s.markPrivate
|
|
||||||
filterByWorkHours = s.filterByWorkHours
|
filterByWorkHours = s.filterByWorkHours
|
||||||
workHoursStart = s.workHoursStart
|
workHoursStart = s.workHoursStart
|
||||||
workHoursEnd = s.workHoursEnd
|
workHoursEnd = s.workHoursEnd
|
||||||
@@ -2290,6 +1676,35 @@ private struct SettingsPayload: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Settings persistence (UserDefaults)
|
// 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 settingsDefaultsKey = "settings.v2"
|
||||||
private let legacyRoutesDefaultsKey = "routes.v1"
|
private let legacyRoutesDefaultsKey = "routes.v1"
|
||||||
|
|
||||||
@@ -2328,38 +1743,6 @@ private struct SettingsPayload: Codable {
|
|||||||
|
|
||||||
// MARK: - Filters
|
// 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() {
|
private func clampWorkHours() {
|
||||||
let clampedStart = min(max(workHoursStart, 0), 23)
|
let clampedStart = min(max(workHoursStart, 0), 23)
|
||||||
if clampedStart != workHoursStart { workHoursStart = clampedStart }
|
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ struct MirrorConfig {
|
|||||||
let mergeGapMin: Int
|
let mergeGapMin: Int
|
||||||
let hideDetails: Bool
|
let hideDetails: Bool
|
||||||
let copyDescription: Bool
|
let copyDescription: Bool
|
||||||
let markPrivate: Bool
|
|
||||||
let mirrorAllDay: Bool
|
let mirrorAllDay: Bool
|
||||||
let overlapMode: OverlapMode
|
let overlapMode: OverlapMode
|
||||||
let titlePrefix: String
|
let titlePrefix: String
|
||||||
|
|||||||
@@ -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<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(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<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 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import EventKit
|
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
|
// Remove our prefix when building titles so it never doubles up
|
||||||
func stripPrefix(_ title: String?, prefix: String) -> String {
|
func stripPrefix(_ title: String?, prefix: String) -> String {
|
||||||
guard let t = title else { return "" }
|
guard let t = title else { return "" }
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
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
|
## [1.4.0] - 2026-05-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Reference in New Issue
Block a user