8 Commits

Author SHA1 Message Date
tomas.kracmar ad6ae396da Release 1.5.1
Bug fixes and code quality improvements:

- Fix mirror index dirtied on every sync (MirrorRecord.updatedAt in equality)
- Fix mirror URL corruption: encode calendar/source IDs before joining with ';'
  and use percentEncodedPath to prevent double-encoding
- Fix cleanup route mutating UI calendar picker selection unnecessarily
- Fix --exit flag redundancy (isCLIRun no longer implies termination)
- Remove dead SKIP_ALL_DAY_DEFAULT constant
- Replace deprecated FileHandle(forWritingAtPath:) with throwing variant
- Add EKEventStoreChanged observer for live calendar list refresh
- Extract AppLogStore into its own file (AppLogStore.swift)
- Add Block.span(start🔚) factory; replace verbose nil-field constructions
- Remove redundant MainActor.run{} wrappers inside @MainActor MirrorEngine
- Fix SettingsPayload indentation inside ContentView

All 45 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:48:08 +02:00
tomas.kracmar 2c319808c2 Release 1.5.0
- Remove non-functional markPrivate feature and Objective-C runtime hacks
- Extract mirror engine into MirrorEngine.swift
- Move calLabel to MirrorUtils.swift
- Update AGENTS.md architecture documentation
- Bump version to 1.5.0 (build 19)
2026-05-27 12:51:22 +02:00
tomas.kracmar f625ecc263 Release 1.4.0
Fixes:
- Sandbox: add LaunchAgent temporary-exception entitlement
- Mirror URL: fix broken buildMirrorURL (URLComponents with ; separator)
- Cleanup: add bounds check to prevent crash on missing source
- State safety: pass MirrorConfig instead of mutating global @State
- KVC: remove misleading do-catch around setValue:forKey:
- Log cap: limit in-memory log to 2000 lines
- CLI: fix race with calendar loading
- launchCtl: separate stdout/stderr pipes

Features:
- Cancel button for long-running mirrors
- Progress indicator for multi-route runs (Route X of Y)
- Target event cache across routes

Code quality:
- Extract BlockMath, MirrorUtils, EventFilters, MirrorConfig
- Add 45 unit tests across 3 test files
- Refactor mergeGapMin to computed property
- Make log editor read-only

Build:
- Bump version to 1.4.0 (build 18)
- Add LSMinimumSystemVersion 15.5
2026-05-27 11:00:18 +02:00
tomas.kracmar fe9e813583 Release 1.3.9 2026-04-09 15:55:09 +02:00
tomas.kracmar cdf82b99cc Release 1.3.8 2026-04-08 11:56:01 +02:00
tomas.kracmar 2912d2f52a Release 1.3.7 2026-03-24 10:36:44 +01:00
tomas.kracmar a838e021a1 Docs: refresh README and roadmap 2026-03-13 09:12:19 +01:00
tomas.kracmar f81403745c Release 1.3.6 2026-03-13 09:08:31 +01:00
35 changed files with 2276 additions and 940 deletions
+159
View File
@@ -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 users 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 users `~/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.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15
+4 -4
View File
@@ -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;
+51
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+80
View File
@@ -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
}
+4
View File
@@ -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>
+10 -1
View File
@@ -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)
}
} }
} }
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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
}
+4
View File
@@ -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>
+75
View File
@@ -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)
}
}
+22
View File
@@ -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
}
+626
View File
@@ -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)")
}
}
}
+127
View File
@@ -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)
}
+158
View File
@@ -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)
}
}
+92
View File
@@ -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"]))
}
}
+143
View File
@@ -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")
}
}
+87
View File
@@ -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.
+14 -5
View File
@@ -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"
+13 -3
View File
@@ -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 calendars confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices). Use one calendars 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
+5 -1
View File
@@ -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
+9
View File
@@ -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).
+9
View File
@@ -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).
+9
View File
@@ -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).
+9
View File
@@ -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).
+21
View File
@@ -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`).