Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ae396da | |||
| 2c319808c2 | |||
| f625ecc263 | |||
| fe9e813583 | |||
| cdf82b99cc | |||
| 2912d2f52a | |||
| a838e021a1 | |||
| f81403745c |
@@ -0,0 +1,159 @@
|
|||||||
|
# BusyMirror — Agent Reference
|
||||||
|
|
||||||
|
> This file is written for AI coding agents. It assumes you know nothing about the project.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**BusyMirror** is a macOS menu-bar utility that mirrors calendar events from a source calendar into one or more target calendars, creating busy-placeholder events so availability stays consistent across accounts and devices.
|
||||||
|
|
||||||
|
It is a single-platform macOS app written in **Swift 5** and **SwiftUI**, using **EventKit** to read and write calendar data. The app runs as a menu-bar-only app (`LSUIElement`) with no Dock icon.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Manual or route-driven multi-source mirroring
|
||||||
|
- 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
|
||||||
|
- CLI support for headless/scripted runs
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Language | Swift 5.0 |
|
||||||
|
| UI Framework | SwiftUI + AppKit (menu bar, panels) |
|
||||||
|
| Calendar API | EventKit (`EKEventStore`, `EKEvent`, `EKCalendar`) |
|
||||||
|
| Persistence | `UserDefaults` (JSON-encoded settings), `@AppStorage` |
|
||||||
|
| Scheduling | `launchd` / `launchctl` (user LaunchAgent) |
|
||||||
|
| Build System | Xcode project (`BusyMirror.xcodeproj`) + Makefile |
|
||||||
|
| Target OS | macOS 15.5+ |
|
||||||
|
| Signing | Ad-hoc (`CODE_SIGN_IDENTITY = "-"`) — not notarized |
|
||||||
|
|
||||||
|
No external Swift Package Manager dependencies are used. The project is self-contained.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
BusyMirror/
|
||||||
|
├── BusyMirrorApp.swift # App entry point; defines Window + MenuBarExtra
|
||||||
|
├── 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 (PBXFileSystemSynchronizedRootGroup — new .swift files are auto-included)
|
||||||
|
BusyMirrorTests/ # Unit tests: BlockMathTests, EventFiltersTests, MirrorUtilsTests (45 tests)
|
||||||
|
BusyMirrorUITests/ # UI tests (empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability.
|
||||||
|
|
||||||
|
When making changes, keep the existing data flow (`@EnvironmentObject`, `@AppStorage`, `@State`) intact in `ContentView.swift`.
|
||||||
|
|
||||||
|
## Build and Release Commands
|
||||||
|
|
||||||
|
### Makefile targets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-debug # Debug build via xcodebuild
|
||||||
|
make build-release # Release build via xcodebuild
|
||||||
|
make sign-app # Ad-hoc sign the Release app (strip xattr, codesign)
|
||||||
|
make app # Verify signed app exists
|
||||||
|
make package # Create BusyMirror-<version>-macOS.zip + .sha256
|
||||||
|
make clean # Clean derived data
|
||||||
|
```
|
||||||
|
|
||||||
|
Built products:
|
||||||
|
- Unsigned release: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||||||
|
- Signed release: `build/ReleaseSigned/BusyMirror.app`
|
||||||
|
|
||||||
|
### Xcode
|
||||||
|
|
||||||
|
1. Open `BusyMirror.xcodeproj`.
|
||||||
|
2. Select **BusyMirror** scheme → **My Mac**.
|
||||||
|
3. **Product → Build** (or **Archive** for distribution).
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
- `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` live in `project.pbxproj`.
|
||||||
|
- The Makefile extracts `MARKETING_VERSION` automatically for ZIP naming.
|
||||||
|
- Update both Debug and Release build configurations when bumping the version.
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
- **Language:** all code, comments, and user-facing strings are in **English**.
|
||||||
|
- **Concurrency:** `@MainActor` is required on methods that mutate SwiftUI `@State` or call EventKit on the main thread. The compiler enforces strict concurrency.
|
||||||
|
- **Formatting:** standard Swift style (4-space indentation). No external linter is configured.
|
||||||
|
- **Logging:** use the `log(_:)` method inside `ContentView`; it appends to both the on-screen log editor and the persistent file log (`~/Library/Logs/BusyMirror/BusyMirror.log`).
|
||||||
|
- **Error handling:** EventKit errors are caught and logged; they must never crash the app. The file logger swallows its own errors silently.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
3. Toggle WRITE and run Mirror Now; verify placeholders appear in the target calendar.
|
||||||
|
4. Move a source event and re-run; verify the placeholder updates.
|
||||||
|
5. Test Cleanup Placeholders (dry-run and write).
|
||||||
|
6. Add a route, install a schedule, verify the LaunchAgent plist is created in `~/Library/LaunchAgents/`.
|
||||||
|
7. Trigger a menu-bar sync and confirm the window opens if not visible.
|
||||||
|
|
||||||
|
## Security and Privacy Considerations
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## CLI and Scheduling
|
||||||
|
|
||||||
|
The binary supports headless execution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run saved routes (used by the LaunchAgent)
|
||||||
|
BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit
|
||||||
|
|
||||||
|
# Manual route via 1-based UI indices
|
||||||
|
BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant flags: `--privacy`, `--copy-notes`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
|
||||||
|
|
||||||
|
Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click.
|
||||||
|
|
||||||
|
## Key Files to Know
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `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 |
|
||||||
|
| `CHANGELOG.md` | Release notes (human-readable) |
|
||||||
|
| `ROADMAP.md` | Planned features |
|
||||||
|
|
||||||
|
## Notes for Agents
|
||||||
|
|
||||||
|
- 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 (`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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15
|
||||||
@@ -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 = 12;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
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.3.4;
|
MARKETING_VERSION = 1.5.1;
|
||||||
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 = 12;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
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.3.4;
|
MARKETING_VERSION = 1.5.1;
|
||||||
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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,15 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||||
"idiom" : "mac",
|
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||||
"scale" : "1x",
|
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||||
"size" : "16x16"
|
{ "filename" : "icon_64x64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||||
},
|
{ "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||||
{
|
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||||
"idiom" : "mac",
|
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||||
"scale" : "2x",
|
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||||
"size" : "16x16"
|
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||||
},
|
{ "filename" : "icon_1024x1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "512x512"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "512x512"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Block: Hashable {
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
let srcStableID: String? // stable source item ID for reschedule tracking
|
||||||
|
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
|
||||||
|
func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var out: [Block] = []
|
||||||
|
for b in blocks {
|
||||||
|
let key: String
|
||||||
|
if trackByID, let sid = b.srcStableID {
|
||||||
|
let occ = b.occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
|
key = "id|\(sid)|\(occ)"
|
||||||
|
} else {
|
||||||
|
key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
if seen.insert(key).inserted { out.append(b) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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.span(start: cur.start, end: b.end) }
|
||||||
|
} else {
|
||||||
|
out.append(cur)
|
||||||
|
cur = Block.span(start: b.start, end: b.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.append(cur)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesce(_ segs: [Block]) -> [Block] { mergeBlocks(segs, gapMinutes: 0) }
|
||||||
|
|
||||||
|
func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool {
|
||||||
|
for s in mergedSegs {
|
||||||
|
if s.start <= block.start.addingTimeInterval(tolMin * 60),
|
||||||
|
s.end >= block.end.addingTimeInterval(-tolMin * 60) { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] {
|
||||||
|
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.span(start: ss, end: ee)) }
|
||||||
|
}
|
||||||
|
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.span(start: prevEnd, end: s.start)) }
|
||||||
|
if s.end > prevEnd { prevEnd = s.end }
|
||||||
|
}
|
||||||
|
if prevEnd < block.end { gaps.append(Block.span(start: prevEnd, end: block.end)) }
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
@@ -8,5 +8,9 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.calendars</key>
|
<key>com.apple.security.personal-information.calendars</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string>Library/LaunchAgents/</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct BusyMirrorApp: App {
|
struct BusyMirrorApp: App {
|
||||||
|
@StateObject private var appController = BusyMirrorAppController()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.environmentObject(appController)
|
||||||
.frame(minWidth: 720, minHeight: 520)
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
}
|
}
|
||||||
|
.defaultSize(width: 1120, height: 760)
|
||||||
|
|
||||||
|
MenuBarExtra("BusyMirror", systemImage: appController.isSyncing ? "arrow.triangle.2.circlepath.circle.fill" : "calendar.badge.clock") {
|
||||||
|
BusyMirrorMenuBarView()
|
||||||
|
.environmentObject(appController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
import EventKit
|
||||||
|
|
||||||
|
func isOutsideWorkHours(_ startDate: Date, calendar: Calendar, startMinutes: Int, endMinutes: Int) -> Bool {
|
||||||
|
guard endMinutes > startMinutes else { return false }
|
||||||
|
let comps = calendar.dateComponents([.hour, .minute], from: startDate)
|
||||||
|
guard let hour = comps.hour else { return false }
|
||||||
|
let minute = comps.minute ?? 0
|
||||||
|
let start = hour * 60 + minute
|
||||||
|
return start < startMinutes || start >= endMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkip(title: String?, filters: [String], titlePrefix: String) -> Bool {
|
||||||
|
guard !filters.isEmpty else { return false }
|
||||||
|
let rawTitle = (title ?? "").lowercased()
|
||||||
|
let strippedTitle = stripPrefix(title, prefix: titlePrefix).lowercased()
|
||||||
|
return filters.contains { token in
|
||||||
|
rawTitle.contains(token) || strippedTitle.contains(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizerEmail(_ participant: EKParticipant?) -> String? {
|
||||||
|
guard let url = participant?.url else { return nil }
|
||||||
|
if url.scheme?.lowercased() == "mailto" {
|
||||||
|
let abs = url.absoluteString
|
||||||
|
if abs.lowercased().hasPrefix("mailto:") {
|
||||||
|
return String(abs.dropFirst("mailto:".count))
|
||||||
|
}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return url.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizerStrings(for event: EKEvent) -> [String] {
|
||||||
|
var out: [String] = []
|
||||||
|
if let org = event.organizer {
|
||||||
|
if let n = org.name, !n.isEmpty { out.append(n) }
|
||||||
|
if let e = organizerEmail(org), !e.isEmpty { out.append(e) }
|
||||||
|
}
|
||||||
|
// Fallback: some providers may not populate organizer; try chair attendee
|
||||||
|
if out.isEmpty, let attendees = event.attendees {
|
||||||
|
if let chair = attendees.first(where: { $0.participantRole == .chair }) {
|
||||||
|
if let n = chair.name, !n.isEmpty { out.append(n) }
|
||||||
|
if let e = organizerEmail(chair), !e.isEmpty { out.append(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipOrganizer(organizerValues: [String], filters: [String]) -> Bool {
|
||||||
|
guard !filters.isEmpty else { return false }
|
||||||
|
guard !organizerValues.isEmpty else { return false }
|
||||||
|
let vals = organizerValues.map { $0.lowercased() }
|
||||||
|
for token in filters {
|
||||||
|
for v in vals {
|
||||||
|
if v.contains(token) { return true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>15.5</string>
|
||||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
<string>BusyMirror needs access to your calendars to create busy placeholders.</string>
|
<string>BusyMirror needs access to your calendars to create busy placeholders.</string>
|
||||||
<key>NSRemindersFullAccessUsageDescription</key>
|
<key>NSRemindersFullAccessUsageDescription</key>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
enum BusyMirrorSceneID {
|
||||||
|
static let mainWindow = "main-window"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BusyMirrorAppController: ObservableObject {
|
||||||
|
@Published private(set) var isSyncing = false
|
||||||
|
@Published private(set) var hasPendingSyncRequest = false
|
||||||
|
@Published private(set) var syncRequestToken = UUID()
|
||||||
|
@Published private(set) var isMainWindowVisible = false
|
||||||
|
|
||||||
|
func requestSync() {
|
||||||
|
hasPendingSyncRequest = true
|
||||||
|
syncRequestToken = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPendingSyncRequest() {
|
||||||
|
hasPendingSyncRequest = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSyncing(_ syncing: Bool) {
|
||||||
|
isSyncing = syncing
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMainWindowVisible(_ visible: Bool) {
|
||||||
|
isMainWindowVisible = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMainWindow(using openWindow: OpenWindowAction) {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BusyMirrorMenuBarView: View {
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@EnvironmentObject private var appController: BusyMirrorAppController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("BusyMirror")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(appController.isSyncing ? "Sync in progress." : "Use your saved routes or current selection.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(appController.isSyncing ? "Syncing…" : "Sync Now") {
|
||||||
|
let shouldOpenWindow = !appController.isMainWindowVisible
|
||||||
|
appController.requestSync()
|
||||||
|
if shouldOpenWindow {
|
||||||
|
appController.openMainWindow(using: openWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(appController.isSyncing)
|
||||||
|
|
||||||
|
Button("Open BusyMirror") {
|
||||||
|
appController.openMainWindow(using: openWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Quit BusyMirror") {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(width: 240, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
import EventKit
|
||||||
|
|
||||||
|
struct MirrorConfig {
|
||||||
|
let daysBack: Int
|
||||||
|
let daysForward: Int
|
||||||
|
let mergeGapMin: Int
|
||||||
|
let hideDetails: Bool
|
||||||
|
let copyDescription: Bool
|
||||||
|
let mirrorAllDay: Bool
|
||||||
|
let overlapMode: OverlapMode
|
||||||
|
let titlePrefix: String
|
||||||
|
let placeholderTitle: String
|
||||||
|
let filterByWorkHours: Bool
|
||||||
|
let workHoursStart: Int
|
||||||
|
let workHoursEnd: Int
|
||||||
|
let excludedTitleFilterTerms: [String]
|
||||||
|
let excludedOrganizerFilterTerms: [String]
|
||||||
|
let mirrorAcceptedOnly: Bool
|
||||||
|
let autoDeleteMissing: Bool
|
||||||
|
let writeEnabled: Bool
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 "" }
|
||||||
|
if prefix.isEmpty { return t }
|
||||||
|
return t.hasPrefix(prefix) ? String(t.dropFirst(prefix.count)) : t
|
||||||
|
}
|
||||||
|
|
||||||
|
private let mirrorURLAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
||||||
|
|
||||||
|
func mirrorURLComponentEncode(_ raw: String) -> String {
|
||||||
|
raw.addingPercentEncoding(withAllowedCharacters: mirrorURLAllowedCharacters) ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func mirrorURLComponentDecode(_ raw: Substring) -> String {
|
||||||
|
let value = String(raw)
|
||||||
|
return value.removingPercentEncoding ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
func stableSourceIdentifier(for event: EKEvent) -> String? {
|
||||||
|
if let external = event.calendarItemExternalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!external.isEmpty {
|
||||||
|
return "ext:\(external)"
|
||||||
|
}
|
||||||
|
let local = event.calendarItemIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !local.isEmpty {
|
||||||
|
return "loc:\(local)"
|
||||||
|
}
|
||||||
|
if let legacy = event.eventIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!legacy.isEmpty {
|
||||||
|
return "evt:\(legacy)"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceOccurrenceKey(sourceCalID: String, sourceStableID: String, occurrence: Date?) -> String {
|
||||||
|
let occPart = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
|
return "\(sourceCalID)|\(sourceStableID)|\(occPart)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mirrorRecordKey(targetCalID: String, sourceKey: String) -> String {
|
||||||
|
"\(targetCalID)|\(sourceKey)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mirrorTimeKey(start: Date, end: Date) -> String {
|
||||||
|
"\(start.timeIntervalSince1970)|\(end.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
mirrorURLComponentEncode(targetCalID),
|
||||||
|
mirrorURLComponentEncode(sourceCalID),
|
||||||
|
mirrorURLComponentEncode(sourceID),
|
||||||
|
occ,
|
||||||
|
String(start.timeIntervalSince1970),
|
||||||
|
String(end.timeIntervalSince1970)
|
||||||
|
]
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "mirror"
|
||||||
|
components.host = "x"
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse mirror URL: mirror://x/<tgtID>;<srcCalID>;<srcStableID>;<occTS>;<startTS>;<endTS>
|
||||||
|
// Backward-compatible with legacy mirror://<tgtID>|<srcCalID>|... format
|
||||||
|
func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID: String?, sourceStableID: String?, occ: Date?, start: Date?, end: Date?) {
|
||||||
|
guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil, nil, nil) }
|
||||||
|
let body = abs.dropFirst("mirror://".count)
|
||||||
|
// Strip legacy host placeholder if present
|
||||||
|
let strippedBody = body.hasPrefix("x/") ? body.dropFirst("x/".count) : body
|
||||||
|
let parts = strippedBody.contains(";")
|
||||||
|
? strippedBody.split(separator: ";", omittingEmptySubsequences: false)
|
||||||
|
: strippedBody.split(separator: "|", omittingEmptySubsequences: false)
|
||||||
|
var targetCalID: String? = nil
|
||||||
|
var sourceCalID: String? = nil
|
||||||
|
var srcID: String? = nil
|
||||||
|
var occDate: Date? = nil
|
||||||
|
var sDate: Date? = nil
|
||||||
|
var eDate: Date? = nil
|
||||||
|
if parts.count >= 1 { targetCalID = mirrorURLComponentDecode(parts[0]) }
|
||||||
|
if parts.count >= 2 { sourceCalID = mirrorURLComponentDecode(parts[1]) }
|
||||||
|
if parts.count >= 3 {
|
||||||
|
let decoded = mirrorURLComponentDecode(parts[2])
|
||||||
|
srcID = decoded.isEmpty ? nil : decoded
|
||||||
|
}
|
||||||
|
if parts.count >= 4,
|
||||||
|
String(parts[3]) != "-",
|
||||||
|
let ts = TimeInterval(String(parts[3])) {
|
||||||
|
occDate = Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
if parts.count >= 6,
|
||||||
|
let sTS = TimeInterval(String(parts[4])),
|
||||||
|
let eTS = TimeInterval(String(parts[5])) {
|
||||||
|
sDate = Date(timeIntervalSince1970: sTS)
|
||||||
|
eDate = Date(timeIntervalSince1970: eTS)
|
||||||
|
}
|
||||||
|
return (targetCalID, sourceCalID, srcID, occDate, sDate, eDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure title/URL-based mirror detection (testable without EKEvent)
|
||||||
|
func isMirrorEvent(title: String?, urlString: String?, prefix: String, placeholder: String) -> Bool {
|
||||||
|
if let urlString = urlString, urlString.hasPrefix("mirror://") { return true }
|
||||||
|
let t = title ?? ""
|
||||||
|
if !prefix.isEmpty && t.hasPrefix(prefix) { return true }
|
||||||
|
if t == placeholder || (!prefix.isEmpty && t == (prefix + placeholder)) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EKEvent wrapper
|
||||||
|
func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -> Bool {
|
||||||
|
isMirrorEvent(title: ev.title, urlString: ev.url?.absoluteString, prefix: prefix, placeholder: placeholder)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import BusyMirror
|
||||||
|
|
||||||
|
final class BlockMathTests: XCTestCase {
|
||||||
|
|
||||||
|
private let d = Date(timeIntervalSince1970: 0)
|
||||||
|
|
||||||
|
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
|
||||||
|
Block(
|
||||||
|
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
|
||||||
|
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
|
||||||
|
srcStableID: id,
|
||||||
|
label: nil,
|
||||||
|
notes: nil,
|
||||||
|
occurrence: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - mergeBlocks
|
||||||
|
|
||||||
|
func testMergeBlocksNoGap() {
|
||||||
|
let blocks = [block(0, 10), block(10, 20)]
|
||||||
|
let merged = mergeBlocks(blocks, gapMinutes: 0)
|
||||||
|
XCTAssertEqual(merged.count, 1)
|
||||||
|
XCTAssertEqual(merged[0].start, blocks[0].start)
|
||||||
|
XCTAssertEqual(merged[0].end, blocks[1].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeBlocksWithGapUnderThreshold() {
|
||||||
|
let blocks = [block(0, 10), block(15, 20)]
|
||||||
|
let merged = mergeBlocks(blocks, gapMinutes: 10)
|
||||||
|
XCTAssertEqual(merged.count, 1)
|
||||||
|
XCTAssertEqual(merged[0].start, blocks[0].start)
|
||||||
|
XCTAssertEqual(merged[0].end, blocks[1].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeBlocksWithGapOverThreshold() {
|
||||||
|
let blocks = [block(0, 10), block(20, 30)]
|
||||||
|
let merged = mergeBlocks(blocks, gapMinutes: 5)
|
||||||
|
XCTAssertEqual(merged.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeBlocksEmpty() {
|
||||||
|
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeBlocksUnsortedInput() {
|
||||||
|
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
|
||||||
|
let merged = mergeBlocks(blocks, gapMinutes: 0)
|
||||||
|
XCTAssertEqual(merged.count, 2)
|
||||||
|
XCTAssertEqual(merged[0].start, blocks[1].start)
|
||||||
|
XCTAssertEqual(merged[0].end, blocks[2].end)
|
||||||
|
XCTAssertEqual(merged[1].start, blocks[0].start)
|
||||||
|
XCTAssertEqual(merged[1].end, blocks[0].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - coalesce
|
||||||
|
|
||||||
|
func testCoalesceOverlappingBlocks() {
|
||||||
|
let blocks = [block(0, 15), block(10, 20), block(25, 30)]
|
||||||
|
let result = coalesce(blocks)
|
||||||
|
XCTAssertEqual(result.count, 2)
|
||||||
|
XCTAssertEqual(result[0].start, blocks[0].start)
|
||||||
|
XCTAssertEqual(result[0].end, blocks[1].end)
|
||||||
|
XCTAssertEqual(result[1].start, blocks[2].start)
|
||||||
|
XCTAssertEqual(result[1].end, blocks[2].end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - fullyCovered
|
||||||
|
|
||||||
|
func testFullyCoveredExactMatch() {
|
||||||
|
let occupied = [block(0, 10)]
|
||||||
|
let b = block(0, 10)
|
||||||
|
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullyCoveredPartialOverlap() {
|
||||||
|
let occupied = [block(0, 5)]
|
||||||
|
let b = block(0, 10)
|
||||||
|
XCTAssertFalse(fullyCovered(occupied, block: b, tolMin: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullyCoveredWithTolerance() {
|
||||||
|
let occupied = [block(0, 10)]
|
||||||
|
let b = block(2, 8)
|
||||||
|
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullyCoveredMultipleSegments() {
|
||||||
|
let occupied = coalesce([block(0, 3), block(3, 10)])
|
||||||
|
let b = block(0, 10)
|
||||||
|
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - gapsWithin
|
||||||
|
|
||||||
|
func testGapsWithinNoOccupied() {
|
||||||
|
let b = block(0, 60)
|
||||||
|
let gaps = gapsWithin([], in: b)
|
||||||
|
XCTAssertEqual(gaps.count, 1)
|
||||||
|
XCTAssertEqual(gaps[0].start, b.start)
|
||||||
|
XCTAssertEqual(gaps[0].end, b.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGapsWithinSingleGap() {
|
||||||
|
let occupied = [block(0, 10), block(20, 30)]
|
||||||
|
let b = block(0, 30)
|
||||||
|
let gaps = gapsWithin(occupied, in: b)
|
||||||
|
XCTAssertEqual(gaps.count, 1)
|
||||||
|
XCTAssertEqual(gaps[0].start, occupied[0].end)
|
||||||
|
XCTAssertEqual(gaps[0].end, occupied[1].start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGapsWithinMultipleGaps() {
|
||||||
|
let occupied = [block(5, 10), block(15, 20)]
|
||||||
|
let b = block(0, 30)
|
||||||
|
let gaps = gapsWithin(occupied, in: b)
|
||||||
|
XCTAssertEqual(gaps.count, 3)
|
||||||
|
XCTAssertEqual(gaps[0].start, b.start)
|
||||||
|
XCTAssertEqual(gaps[0].end, occupied[0].start)
|
||||||
|
XCTAssertEqual(gaps[1].start, occupied[0].end)
|
||||||
|
XCTAssertEqual(gaps[1].end, occupied[1].start)
|
||||||
|
XCTAssertEqual(gaps[2].start, occupied[1].end)
|
||||||
|
XCTAssertEqual(gaps[2].end, b.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGapsWithinExactFit() {
|
||||||
|
let occupied = [block(0, 10)]
|
||||||
|
let b = block(0, 10)
|
||||||
|
let gaps = gapsWithin(occupied, in: b)
|
||||||
|
XCTAssertTrue(gaps.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - uniqueBlocks
|
||||||
|
|
||||||
|
func testUniqueBlocksByTime() {
|
||||||
|
let blocks = [block(0, 10), block(0, 10), block(10, 20)]
|
||||||
|
let result = uniqueBlocks(blocks, trackByID: false)
|
||||||
|
XCTAssertEqual(result.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUniqueBlocksByID() {
|
||||||
|
let blocks = [
|
||||||
|
block(0, 10, id: "a"),
|
||||||
|
block(0, 10, id: "a"),
|
||||||
|
block(5, 15, id: "b")
|
||||||
|
]
|
||||||
|
let result = uniqueBlocks(blocks, trackByID: true)
|
||||||
|
XCTAssertEqual(result.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||||
|
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
|
||||||
|
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
|
||||||
|
let result = uniqueBlocks([b1, b2], trackByID: true)
|
||||||
|
XCTAssertEqual(result.count, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import BusyMirror
|
||||||
|
|
||||||
|
final class EventFiltersTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - isOutsideWorkHours
|
||||||
|
|
||||||
|
func testInsideWorkHours() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 0))!
|
||||||
|
XCTAssertFalse(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutsideWorkHoursBeforeStart() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 8, minute: 59))!
|
||||||
|
XCTAssertTrue(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutsideWorkHoursAtEndBoundary() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 17, minute: 0))!
|
||||||
|
XCTAssertTrue(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutsideWorkHoursInvalidRange() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 0))!
|
||||||
|
// end <= start means "no enforcement"
|
||||||
|
XCTAssertFalse(isOutsideWorkHours(date, calendar: calendar, startMinutes: 17 * 60, endMinutes: 9 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - shouldSkip
|
||||||
|
|
||||||
|
func testShouldSkipEmptyFilters() {
|
||||||
|
XCTAssertFalse(shouldSkip(title: "Meeting", filters: [], titlePrefix: "🪞 "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipMatchingRawTitle() {
|
||||||
|
XCTAssertTrue(shouldSkip(title: "Standup", filters: ["standup"], titlePrefix: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipMatchingStrippedTitle() {
|
||||||
|
XCTAssertTrue(shouldSkip(title: "🪞 Standup", filters: ["standup"], titlePrefix: "🪞 "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipCaseInsensitive() {
|
||||||
|
XCTAssertTrue(shouldSkip(title: "STANDUP", filters: ["standup"], titlePrefix: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipNoMatch() {
|
||||||
|
XCTAssertFalse(shouldSkip(title: "Meeting", filters: ["standup"], titlePrefix: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipNilTitle() {
|
||||||
|
XCTAssertFalse(shouldSkip(title: nil, filters: ["standup"], titlePrefix: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipMultipleFilters() {
|
||||||
|
XCTAssertTrue(shouldSkip(title: "Lunch", filters: ["standup", "lunch"], titlePrefix: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - shouldSkipOrganizer
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerEmptyFilters() {
|
||||||
|
XCTAssertFalse(shouldSkipOrganizer(organizerValues: ["alice@example.com"], filters: []))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerEmptyValues() {
|
||||||
|
XCTAssertFalse(shouldSkipOrganizer(organizerValues: [], filters: ["alice"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerMatch() {
|
||||||
|
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["Alice Smith", "alice@example.com"], filters: ["alice"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerCaseInsensitive() {
|
||||||
|
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["ALICE@EXAMPLE.COM"], filters: ["alice"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerPartialMatch() {
|
||||||
|
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["bob@corp.com"], filters: ["corp"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldSkipOrganizerNoMatch() {
|
||||||
|
XCTAssertFalse(shouldSkipOrganizer(organizerValues: ["charlie@example.com"], filters: ["alice"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import BusyMirror
|
||||||
|
|
||||||
|
final class MirrorUtilsTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - stripPrefix
|
||||||
|
|
||||||
|
func testStripPrefixMatching() {
|
||||||
|
XCTAssertEqual(stripPrefix("🪞 Meeting", prefix: "🪞 "), "Meeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripPrefixNoMatch() {
|
||||||
|
XCTAssertEqual(stripPrefix("Meeting", prefix: "🪞 "), "Meeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripPrefixEmptyPrefix() {
|
||||||
|
XCTAssertEqual(stripPrefix("Meeting", prefix: ""), "Meeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripPrefixNilTitle() {
|
||||||
|
XCTAssertEqual(stripPrefix(nil, prefix: "🪞 "), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - mirrorURL encode/decode round-trip
|
||||||
|
|
||||||
|
func testMirrorURLEncodeDecodeRoundTrip() {
|
||||||
|
let raw = "abc|123://"
|
||||||
|
let encoded = mirrorURLComponentEncode(raw)
|
||||||
|
let decoded = mirrorURLComponentDecode(Substring(encoded))
|
||||||
|
XCTAssertEqual(decoded, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorURLAllowedCharactersUnchanged() {
|
||||||
|
let raw = "abcABC123-._~"
|
||||||
|
XCTAssertEqual(mirrorURLComponentEncode(raw), raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - buildMirrorURL / parseMirrorURL
|
||||||
|
|
||||||
|
func testMirrorURLRoundTrip() {
|
||||||
|
let calID = "ABC-123"
|
||||||
|
let sourceID = "event-456"
|
||||||
|
let occ = Date(timeIntervalSince1970: 1000)
|
||||||
|
let start = Date(timeIntervalSince1970: 2000)
|
||||||
|
let end = Date(timeIntervalSince1970: 3600)
|
||||||
|
|
||||||
|
let url = buildMirrorURL(
|
||||||
|
targetCalID: calID,
|
||||||
|
sourceCalID: calID,
|
||||||
|
sourceStableID: sourceID,
|
||||||
|
occurrence: occ,
|
||||||
|
start: start,
|
||||||
|
end: end
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(url)
|
||||||
|
XCTAssertTrue(url?.absoluteString.hasPrefix("mirror://x/") ?? false)
|
||||||
|
|
||||||
|
let parsed = parseMirrorURL(url)
|
||||||
|
XCTAssertEqual(parsed.targetCalID, calID)
|
||||||
|
XCTAssertEqual(parsed.sourceCalID, calID)
|
||||||
|
XCTAssertEqual(parsed.sourceStableID, sourceID)
|
||||||
|
XCTAssertEqual(parsed.occ?.timeIntervalSince1970, 1000)
|
||||||
|
XCTAssertEqual(parsed.start?.timeIntervalSince1970, 2000)
|
||||||
|
XCTAssertEqual(parsed.end?.timeIntervalSince1970, 3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorURLWithSpecialCharacters() {
|
||||||
|
let calID = "cal|with/pipe"
|
||||||
|
let url = buildMirrorURL(
|
||||||
|
targetCalID: calID,
|
||||||
|
sourceCalID: "src",
|
||||||
|
sourceStableID: nil,
|
||||||
|
occurrence: nil,
|
||||||
|
start: Date(),
|
||||||
|
end: Date()
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(url)
|
||||||
|
let parsed = parseMirrorURL(url)
|
||||||
|
XCTAssertEqual(parsed.targetCalID, calID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseMirrorURLInvalid() {
|
||||||
|
let parsed = parseMirrorURL(URL(string: "https://example.com"))
|
||||||
|
XCTAssertNil(parsed.targetCalID)
|
||||||
|
XCTAssertNil(parsed.sourceCalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseMirrorURLMissingOptionalFields() {
|
||||||
|
let url = URL(string: "mirror://x/tgt;src;;-;;")
|
||||||
|
let parsed = parseMirrorURL(url)
|
||||||
|
XCTAssertEqual(parsed.targetCalID, "tgt")
|
||||||
|
XCTAssertEqual(parsed.sourceCalID, "src")
|
||||||
|
XCTAssertNil(parsed.sourceStableID)
|
||||||
|
XCTAssertNil(parsed.occ)
|
||||||
|
XCTAssertNil(parsed.start)
|
||||||
|
XCTAssertNil(parsed.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - isMirrorEvent
|
||||||
|
|
||||||
|
func testIsMirrorEventByURL() {
|
||||||
|
XCTAssertTrue(isMirrorEvent(title: "Meeting", urlString: "mirror://x", prefix: "🪞 ", placeholder: "Busy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsMirrorEventByPrefix() {
|
||||||
|
XCTAssertTrue(isMirrorEvent(title: "🪞 Meeting", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsMirrorEventByPlaceholder() {
|
||||||
|
XCTAssertTrue(isMirrorEvent(title: "Busy", urlString: nil, prefix: "", placeholder: "Busy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsMirrorEventByPrefixedPlaceholder() {
|
||||||
|
XCTAssertTrue(isMirrorEvent(title: "🪞 Busy", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsMirrorEventNegative() {
|
||||||
|
XCTAssertFalse(isMirrorEvent(title: "Regular Meeting", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - key generators
|
||||||
|
|
||||||
|
func testSourceOccurrenceKey() {
|
||||||
|
let occ = Date(timeIntervalSince1970: 1234)
|
||||||
|
let key = sourceOccurrenceKey(sourceCalID: "cal1", sourceStableID: "evt1", occurrence: occ)
|
||||||
|
XCTAssertEqual(key, "cal1|evt1|1234.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSourceOccurrenceKeyNoOccurrence() {
|
||||||
|
let key = sourceOccurrenceKey(sourceCalID: "cal1", sourceStableID: "evt1", occurrence: nil)
|
||||||
|
XCTAssertEqual(key, "cal1|evt1|-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorRecordKey() {
|
||||||
|
XCTAssertEqual(mirrorRecordKey(targetCalID: "t", sourceKey: "s"), "t|s")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMirrorTimeKey() {
|
||||||
|
let s = Date(timeIntervalSince1970: 100)
|
||||||
|
let e = Date(timeIntervalSince1970: 200)
|
||||||
|
XCTAssertEqual(mirrorTimeKey(start: s, end: e), "100.0|200.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,93 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
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
|
||||||
|
- **Sandbox LaunchAgent**: added `temporary-exception` entitlement so scheduled runs work in the sandboxed app.
|
||||||
|
- **Mirror URL generation**: `buildMirrorURL` was silently broken — `URL(string:)` rejects raw `|` characters on current macOS, so mirror metadata URLs were always `nil`. Rebuilt with `URLComponents` using `;` separator and backward-compatible parser.
|
||||||
|
- **Crash on Cleanup**: `runCleanup()` no longer crashes if the selected source calendar was removed.
|
||||||
|
- **State corruption in multi-route runs**: `runConfiguredRoutes` no longer mutates global `@State` settings and restores them at the end of each loop; instead it passes a `MirrorConfig` struct into the engine.
|
||||||
|
- **KVC safety**: removed misleading `do-catch` around `setValue:forKey:` in `setEventPrivateIfSupported()` (Objective-C exceptions are uncatchable in Swift).
|
||||||
|
- **Log memory leak**: in-memory log now caps at 2,000 lines.
|
||||||
|
- **CLI race**: `tryRunCLIIfPresent()` now preloads calendars when access is already granted, eliminating the 10-second timeout race.
|
||||||
|
- **launchCtl output**: stdout and stderr now use separate pipes instead of interleaving into one.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Cancel button**: long-running mirrors now show a Cancel button; loops check `Task.isCancelled` for responsive cancellation.
|
||||||
|
- **Progress indicator**: multi-route runs display `"Route X of Y"` in the status area.
|
||||||
|
- **Unit tests**: 45 tests across `BlockMathTests`, `MirrorUtilsTests`, and `EventFiltersTests`.
|
||||||
|
- **Extracted modules**: `BlockMath.swift`, `MirrorUtils.swift`, `EventFilters.swift`, and `MirrorConfig.swift` separate pure logic from the UI monolith.
|
||||||
|
- **Target event cache**: target calendars shared across routes are fetched only once per run session.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `mergeGapMin` is now a computed property instead of redundant `@State`.
|
||||||
|
- Log editor is now read-only (still selectable/copyable).
|
||||||
|
- `SettingsPayload.excludedOrganizerFilters` is now non-optional for consistency.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- Bump minimum macOS version to `15.5` in `Info.plist`.
|
||||||
|
- Bump version to **1.4.0** (build **18**).
|
||||||
|
|
||||||
|
## [1.3.9] - 2026-04-09
|
||||||
|
- New: add a macOS menu bar extra with `Sync Now`, `Open BusyMirror`, and `Quit BusyMirror`.
|
||||||
|
- UX: menu bar sync requests reuse the existing mirror flow and can open the main window automatically when needed.
|
||||||
|
- UX: BusyMirror now runs as a menu bar-only app and no longer appears in the Dock.
|
||||||
|
- Build: bump version to 1.3.9 (build 17).
|
||||||
|
|
||||||
|
## [1.3.8] - 2026-04-08
|
||||||
|
- Fix: release ZIPs now package `BusyMirror.app` at the archive root instead of embedding the full build path.
|
||||||
|
- Fix: release builds now apply an ad-hoc bundle signature before packaging so downloaded artifacts pass `codesign --verify --deep --strict`.
|
||||||
|
- Build: suppress resource fork sidecars in release ZIPs via `ditto --norsrc --keepParent`.
|
||||||
|
- Build: bump version to 1.3.8 (build 16).
|
||||||
|
|
||||||
|
## [1.3.7] - 2026-03-24
|
||||||
|
- Fix: mirror reconciliation now survives target providers that strip BusyMirror's custom event URL metadata.
|
||||||
|
- Fix: moved and deleted source events are tracked via stable EventKit identifiers and a persisted local mirror index, so target placeholders update reliably.
|
||||||
|
- Fix: mirror updates now detect title and notes changes, not just start/end time changes.
|
||||||
|
- Build: bump version to 1.3.7 (build 15).
|
||||||
|
|
||||||
|
## [1.3.6] - 2026-03-13
|
||||||
|
- Scheduling: add in-app `Scheduled runs` controls to install or remove a user `launchd` LaunchAgent from BusyMirror itself.
|
||||||
|
- Scheduling: support `Hourly`, `Daily`, and `Weekdays` schedules; hourly mode runs saved routes via `StartInterval`.
|
||||||
|
- UX: generate and ship a proper macOS app icon set for BusyMirror.
|
||||||
|
- Build: bump version to 1.3.6 (build 14).
|
||||||
|
|
||||||
## [1.3.4] - 2026-03-13
|
## [1.3.4] - 2026-03-13
|
||||||
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
|
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
|
||||||
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload.
|
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ DEST := platform=macOS
|
|||||||
# Extract marketing version from project settings
|
# Extract marketing version from project settings
|
||||||
VERSION := $(shell sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' $(PROJECT)/project.pbxproj | head -n1)
|
VERSION := $(shell sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' $(PROJECT)/project.pbxproj | head -n1)
|
||||||
|
|
||||||
.PHONY: all clean build-debug build-release open app package
|
.PHONY: all clean build-debug build-release sign-app open app package
|
||||||
|
|
||||||
all: build-release
|
all: build-release
|
||||||
|
|
||||||
@@ -31,16 +31,25 @@ open: app
|
|||||||
|
|
||||||
# Path to built app (Release)
|
# Path to built app (Release)
|
||||||
APP_PATH := $(DERIVED)/Build/Products/Release/BusyMirror.app
|
APP_PATH := $(DERIVED)/Build/Products/Release/BusyMirror.app
|
||||||
|
SIGNED_APP_PATH := build/ReleaseSigned/BusyMirror.app
|
||||||
|
|
||||||
app: build-release
|
sign-app: build-release
|
||||||
|
@echo "Preparing signed release app…"
|
||||||
|
@rm -rf "$(SIGNED_APP_PATH)"
|
||||||
|
@mkdir -p "$(dir $(SIGNED_APP_PATH))"
|
||||||
|
@ditto "$(APP_PATH)" "$(SIGNED_APP_PATH)"
|
||||||
|
@xattr -rc "$(SIGNED_APP_PATH)"
|
||||||
|
@codesign --force --deep --sign - "$(SIGNED_APP_PATH)"
|
||||||
|
@codesign --verify --deep --strict --verbose=2 "$(SIGNED_APP_PATH)"
|
||||||
|
|
||||||
|
app: sign-app
|
||||||
@# Ensure the app exists
|
@# Ensure the app exists
|
||||||
@test -d "$(APP_PATH)" && echo "Built: $(APP_PATH)" || (echo "App not found at $(APP_PATH)" && exit 1)
|
@test -d "$(SIGNED_APP_PATH)" && echo "Built: $(SIGNED_APP_PATH)" || (echo "App not found at $(SIGNED_APP_PATH)" && exit 1)
|
||||||
@echo "Version: $(VERSION)"
|
@echo "Version: $(VERSION)"
|
||||||
@echo "OK"
|
@echo "OK"
|
||||||
|
|
||||||
package: app
|
package: app
|
||||||
@echo "Packaging BusyMirror $(VERSION)…"
|
@echo "Packaging BusyMirror $(VERSION)…"
|
||||||
@zip -qry "BusyMirror-$(VERSION)-macOS.zip" "$(APP_PATH)"
|
@ditto --norsrc -c -k --keepParent "$(SIGNED_APP_PATH)" "BusyMirror-$(VERSION)-macOS.zip"
|
||||||
@shasum -a 256 "BusyMirror-$(VERSION)-macOS.zip" | awk '{print $$1}' > "BusyMirror-$(VERSION)-macOS.zip.sha256"
|
@shasum -a 256 "BusyMirror-$(VERSION)-macOS.zip" | awk '{print $$1}' > "BusyMirror-$(VERSION)-macOS.zip.sha256"
|
||||||
@echo "Created BusyMirror-$(VERSION)-macOS.zip and .sha256"
|
@echo "Created BusyMirror-$(VERSION)-macOS.zip and .sha256"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
|
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
|
||||||
|
|
||||||
|
On macOS, BusyMirror now runs as a menu bar app. Use the menu bar icon to sync manually or open the main window; it no longer appears in the Dock.
|
||||||
|
|
||||||
## What it does (current)
|
## What it does (current)
|
||||||
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
|
- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
|
||||||
- Manual selection mirroring: pick a source and targets in the UI and run.
|
- Manual selection mirroring: pick a source and targets in the UI and run.
|
||||||
@@ -9,13 +11,17 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
|
|||||||
- Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy").
|
- Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy").
|
||||||
- Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort).
|
- Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort).
|
||||||
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
||||||
|
- Activity Log in the app plus persistent file logging on disk.
|
||||||
|
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
|
||||||
|
- Menu bar controls: trigger `Sync Now`, open the main window, or quit without keeping a Dock icon around.
|
||||||
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
||||||
- Merge adjacent events with a configurable gap.
|
- Merge adjacent events with a configurable gap.
|
||||||
- Time window controls (days back/forward) and Work Hours filter.
|
- Time window controls (days back/forward) and Work Hours filter.
|
||||||
- Accepted-only filter (mirror your accepted meetings only).
|
- Accepted-only filter (mirror your accepted meetings only).
|
||||||
- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared.
|
- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared.
|
||||||
|
- Refresh Calendars prunes stale saved calendars and routes when calendars are removed from the system.
|
||||||
- Prefix-based tagging and loop guards to prevent re-mirroring mirrors.
|
- Prefix-based tagging and loop guards to prevent re-mirroring mirrors.
|
||||||
- Settings: autosave/restore, Import/Export JSON.
|
- Settings: autosave/restore, Import/Export JSON, saved routes for scheduled/headless runs.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
||||||
@@ -48,13 +54,17 @@ See `CHANGELOG.md` for notable changes.
|
|||||||
## Logs
|
## Logs
|
||||||
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
|
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
|
||||||
- When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`.
|
- When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`.
|
||||||
|
- `launchd` stdout/stderr for scheduled runs are also written in the same folder.
|
||||||
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
||||||
|
|
||||||
## Scheduling
|
## Scheduling
|
||||||
- Yes. The recommended way is macOS `launchd` calling the built-in CLI with saved routes:
|
- BusyMirror can create its own schedule from the app UI in `Scheduled runs`.
|
||||||
|
- Choose `Hourly`, `Daily`, or `Weekdays`, then click `Install Schedule`.
|
||||||
|
- The installed LaunchAgent runs:
|
||||||
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||||
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
||||||
- A typical `launchd` job can run this on a daily or weekday schedule after you grant calendar access once in the app.
|
- Hourly schedules use `launchd` `StartInterval`; daily and weekday schedules use `StartCalendarInterval`.
|
||||||
|
- You can remove the job from the same UI with `Remove Schedule`, and inspect the generated plist with `Reveal LaunchAgent`.
|
||||||
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
- Work Hours filter and title-based skip filters
|
- Work Hours filter and title-based skip filters
|
||||||
- Privacy: placeholders with prefix + customizable title
|
- Privacy: placeholders with prefix + customizable title
|
||||||
- 1.3.0: Mark Private option (global + per-route)
|
- 1.3.0: Mark Private option (global + per-route)
|
||||||
|
- 1.3.4: persistent file logging, stale-calendar pruning on refresh, clickable top-bar mode toggle
|
||||||
|
- 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes
|
||||||
|
- 1.3.6: generated macOS app icon set and packaged release assets
|
||||||
|
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
||||||
- Hint near "Mirror Now" indicating run mode (Routes vs Manual)
|
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
|
||||||
- Better server-side privacy mapping (per-provider heuristics)
|
- Better server-side privacy mapping (per-provider heuristics)
|
||||||
|
|
||||||
## Then
|
## Then
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.6 - 2026-03-13
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Add in-app scheduling controls so BusyMirror can install and remove its own `launchd` LaunchAgent.
|
||||||
|
- Support hourly saved-route runs in addition to daily and weekday schedules.
|
||||||
|
- Ship a generated macOS app icon set for the app bundle and exported releases.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.6 (build 14).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.7 - 2026-03-24
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Fix mirrored event tracking on providers that do not preserve BusyMirror's custom event URL metadata.
|
||||||
|
- Track source events using stable EventKit identifiers and a local mirror index so moved and deleted source events update target calendars reliably.
|
||||||
|
- Detect title and notes changes during reconciliation instead of only updating mirrors when times change.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.7 (build 15).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.8 - 2026-04-08
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Fix release packaging so the ZIP contains `BusyMirror.app` at the archive root.
|
||||||
|
- Apply an ad-hoc bundle signature before packaging so the distributed app bundle verifies correctly after unzip.
|
||||||
|
- Strip resource fork sidecars from release archives to avoid malformed download contents.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.8 (build 16).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.9 - 2026-04-09
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Add a menu bar extra with `Sync Now`, `Open BusyMirror`, and `Quit BusyMirror`.
|
||||||
|
- Route menu bar sync requests through the same mirroring flow as the main window, opening the window automatically when needed.
|
||||||
|
- Run BusyMirror as a menu bar-only app so it no longer appears in the Dock.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.9 (build 17).
|
||||||
@@ -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`).
|
||||||