Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f625ecc263 | |||
| fe9e813583 | |||
| cdf82b99cc | |||
| 2912d2f52a | |||
| a838e021a1 | |||
| f81403745c | |||
| 58d88e9fa5 | |||
| 3ecf29f499 | |||
| eb643ac74d | |||
| df06564434 | |||
| 74b9949610 | |||
| 6676e62889 | |||
| d1fbd4c81f | |||
| 6ef0feecc1 | |||
| aac4de3fb3 | |||
| 8f80a5f672 | |||
| ae40b42e6f | |||
| 691575c554 | |||
| b931f3ba2c | |||
| 53f21492da |
@@ -18,4 +18,7 @@ ExportOptions.plist
|
||||
|
||||
# Misc
|
||||
*.swp
|
||||
*.profraw
|
||||
*.zip
|
||||
*.sha256
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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 modes: hide details (placeholder title) or mark events Private server-side (best-effort)
|
||||
- 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, mirror engine, settings, CLI, scheduling (≈2600 lines)
|
||||
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
||||
├── Info.plist # LSUIElement, calendar usage descriptions
|
||||
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||||
└── Assets.xcassets/ # AppIcon set and accent color
|
||||
|
||||
BusyMirror.xcodeproj/ # Xcode project
|
||||
BusyMirrorTests/ # Empty (no tests implemented)
|
||||
BusyMirrorUITests/ # Empty (no tests implemented)
|
||||
```
|
||||
|
||||
**Architecture note:** nearly all business logic lives inside `ContentView.swift`. This includes:
|
||||
- SwiftUI view body and subviews
|
||||
- EventKit mirror engine (read, deduplicate, merge, create/update/delete)
|
||||
- Settings serialization/deserialization
|
||||
- CLI argument parsing
|
||||
- `launchd` LaunchAgent installation/removal
|
||||
- Logging to file and UI
|
||||
|
||||
When making changes, be aware that `ContentView.swift` is a large monolithic file. Extracting helpers is fine, but keep the existing data flow ( `@EnvironmentObject`, `@AppStorage`, `@State`) intact.
|
||||
|
||||
## 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
|
||||
|
||||
- **There are no implemented tests.** `BusyMirrorTests/` and `BusyMirrorUITests/` exist as Xcode targets but contain no source files.
|
||||
- When adding logic, prefer extracting pure functions (e.g., block merging, gap calculation, filter logic) so they can be unit-tested later.
|
||||
- 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.
|
||||
- **Private events:** the "Mark Private" feature uses Objective-C runtime tricks (`perform(Selector:)`, KVC `setValue:forKey:`) because EventKit does not expose a public privacy API. This is best-effort and may silently fail on some calendar providers (e.g., some Exchange configurations).
|
||||
- **Loop guard:** a `sessionGuard` set prevents mirroring an event into the same target twice in one run, and prefix-based detection (`titlePrefix`) prevents re-mirroring already-mirrored placeholders.
|
||||
- **Logging:** log files are written to the user’s `~/Library/Logs/BusyMirror/`. No log data is transmitted externally.
|
||||
|
||||
## 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, business logic, mirror engine, settings, CLI, scheduling |
|
||||
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
||||
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
||||
| `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 is tightly coupled to SwiftUI state. Extract helpers for testability, but do not break the `@MainActor`/`@State` flow without careful review.
|
||||
- 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.
|
||||
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -465,7 +465,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -482,7 +482,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -498,7 +498,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
|
||||
@@ -513,7 +513,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
|
||||
|
||||
@@ -1,55 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||
{ "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" },
|
||||
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||
{ "filename" : "icon_1024x1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||
],
|
||||
"info" : {
|
||||
"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,75 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||
for b in sorted.dropFirst() {
|
||||
let gap = b.start.timeIntervalSince(cur.end) / 60.0
|
||||
if gap <= Double(gapMinutes) {
|
||||
if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) }
|
||||
} else {
|
||||
out.append(cur)
|
||||
cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||
}
|
||||
}
|
||||
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(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||
var segs: [Block] = []
|
||||
for s in mergedSegs where s.end > block.start && s.start < block.end {
|
||||
let ss = max(s.start, block.start)
|
||||
let ee = min(s.end, block.end)
|
||||
if ee > ss { segs.append(Block(start: ss, end: ee, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
}
|
||||
if segs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||
let merged = coalesce(segs)
|
||||
var gaps: [Block] = []
|
||||
var prevEnd = block.start
|
||||
for s in merged {
|
||||
if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
if s.end > prevEnd { prevEnd = s.end }
|
||||
}
|
||||
if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||
return gaps
|
||||
}
|
||||
@@ -8,5 +8,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
|
||||
<array>
|
||||
<string>Library/LaunchAgents/</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,10 +2,19 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct BusyMirrorApp: App {
|
||||
@StateObject private var appController = BusyMirrorAppController()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Window("BusyMirror", id: BusyMirrorSceneID.mainWindow) {
|
||||
ContentView()
|
||||
.environmentObject(appController)
|
||||
.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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.5</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>BusyMirror needs access to your calendars to create busy placeholders.</string>
|
||||
<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,23 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
struct MirrorConfig {
|
||||
let daysBack: Int
|
||||
let daysForward: Int
|
||||
let mergeGapMin: Int
|
||||
let hideDetails: Bool
|
||||
let copyDescription: Bool
|
||||
let markPrivate: 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,117 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// 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) } ?? "-"
|
||||
let parts = [
|
||||
targetCalID,
|
||||
sourceCalID,
|
||||
sourceID,
|
||||
occ,
|
||||
String(start.timeIntervalSince1970),
|
||||
String(end.timeIntervalSince1970)
|
||||
]
|
||||
var components = URLComponents()
|
||||
components.scheme = "mirror"
|
||||
components.host = "x"
|
||||
components.path = "/" + 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to BusyMirror will be documented in this file.
|
||||
|
||||
## [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
|
||||
- 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.
|
||||
- UX: the top bar `DRY RUN` / `WRITE` status pill is clickable, the left column keeps its own height on desktop, and the app can reveal its log file from the UI.
|
||||
- Logging: mirror activity is persisted to `~/Library/Logs/BusyMirror/BusyMirror.log` with simple rotation to `BusyMirror.previous.log`.
|
||||
- CLI: add `--run-saved-routes` so scheduled `launchd` runs can use the saved UI routes instead of fragile index-based route definitions.
|
||||
|
||||
## [1.3.1] - 2025-10-13
|
||||
- Fix: auto-delete of mirrored placeholders when the source is removed now works even if no source instances remain in the window. Also cleans legacy mirrors without URLs by matching exact times.
|
||||
|
||||
## [1.3.2] - 2025-10-13
|
||||
- New: Organizer filters — skip events by organizer (name/email/URL). UI under Options and persisted in settings.
|
||||
- CLI: add `--exclude-organizers` (and `--exclude-titles`) flags to control filters when running headless.
|
||||
|
||||
## [1.2.4] - 2025-10-10
|
||||
- Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present.
|
||||
|
||||
## [1.3.0] - 2025-10-10
|
||||
- New: Mark Private option to mirror with prefix + real title and set event privacy on supported servers; available globally and per-route; persisted.
|
||||
- Misc: calendar access fixes, concurrency annotations, accepted‑only filter, settings autosave/restore, Mirror Now enablement.
|
||||
|
||||
## [1.2.3] - 2025-10-10
|
||||
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
|
||||
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.
|
||||
- Build: bump version to 1.2.3 (build 5).
|
||||
|
||||
## [1.2.1] - 2025-10-10
|
||||
- Fix: reinitialize EKEventStore after permission grant to avoid “Loaded 0 calendars” right after approval.
|
||||
- Fix: attendee status filter uses current user’s attendee `participantStatus == .accepted` instead of unavailable APIs.
|
||||
- Concurrency: mark `requestAccess()` and `reloadCalendars()` as `@MainActor` to satisfy strict concurrency checks.
|
||||
- Dev: add Makefile with `build-debug`, `build-release`, and `package` targets; produce versioned ZIP + SHA-256.
|
||||
|
||||
## [1.2.0] - 2024-09-29
|
||||
- Feature: multi-route mirroring, overlap modes, merge gaps, work hours filter, CLI support, export/import settings.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Simple build and package helpers for BusyMirror
|
||||
|
||||
SCHEME ?= BusyMirror
|
||||
PROJECT ?= BusyMirror.xcodeproj
|
||||
DERIVED ?= build/DerivedData
|
||||
DEST := platform=macOS
|
||||
|
||||
# Extract marketing version from project settings
|
||||
VERSION := $(shell sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' $(PROJECT)/project.pbxproj | head -n1)
|
||||
|
||||
.PHONY: all clean build-debug build-release sign-app open app package
|
||||
|
||||
all: build-release
|
||||
|
||||
clean:
|
||||
@echo "Cleaning derived data…"
|
||||
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -derivedDataPath $(DERIVED) -destination '$(DEST)' CODE_SIGNING_ALLOWED=NO clean >/dev/null
|
||||
@echo "Done."
|
||||
|
||||
build-debug:
|
||||
@echo "Building Debug…"
|
||||
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Debug -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
build-release:
|
||||
@echo "Building Release…"
|
||||
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Release -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Convenience to open the built app in Finder
|
||||
open: app
|
||||
@open "$<"
|
||||
|
||||
# Path to built app (Release)
|
||||
APP_PATH := $(DERIVED)/Build/Products/Release/BusyMirror.app
|
||||
SIGNED_APP_PATH := build/ReleaseSigned/BusyMirror.app
|
||||
|
||||
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
|
||||
@test -d "$(SIGNED_APP_PATH)" && echo "Built: $(SIGNED_APP_PATH)" || (echo "App not found at $(SIGNED_APP_PATH)" && exit 1)
|
||||
@echo "Version: $(VERSION)"
|
||||
@echo "OK"
|
||||
|
||||
package: app
|
||||
@echo "Packaging BusyMirror $(VERSION)…"
|
||||
@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"
|
||||
@echo "Created BusyMirror-$(VERSION)-macOS.zip and .sha256"
|
||||
@@ -2,23 +2,71 @@
|
||||
|
||||
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
|
||||
|
||||
## What it does (current checkpoint)
|
||||
- Manual “Run” to mirror events across selected routes (Source → Targets).
|
||||
- DRY-RUN mode shows what would happen.
|
||||
- Prefix-based tagging of mirrored events.
|
||||
- Cleanup of placeholders (with confirmation).
|
||||
- Loop/duplicate guards so mirrors don’t replicate themselves.
|
||||
- Time window and merge-gap settings.
|
||||
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)
|
||||
- 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.
|
||||
- Two privacy modes:
|
||||
- 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).
|
||||
- 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`.
|
||||
- Merge adjacent events with a configurable gap.
|
||||
- Time window controls (days back/forward) and Work Hours filter.
|
||||
- Accepted-only filter (mirror your accepted meetings only).
|
||||
- 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.
|
||||
- Settings: autosave/restore, Import/Export JSON, saved routes for scheduled/headless runs.
|
||||
|
||||
## Why
|
||||
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
||||
|
||||
## Build (macOS)
|
||||
Option A — Xcode
|
||||
1. Open `BusyMirror.xcodeproj` in Xcode.
|
||||
2. Select the BusyMirror scheme → My Mac.
|
||||
3. Product → Build.
|
||||
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
|
||||
|
||||
Option B — Makefile (reproducible)
|
||||
- Build Release: `make build-release`
|
||||
- Package ZIP: `make package` (creates `BusyMirror-<version>-macOS.zip` + `.sha256`)
|
||||
- Built app: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||||
|
||||
See `CHANGELOG.md` for notable changes.
|
||||
|
||||
## CLI (optional)
|
||||
- Run from Terminal with `--routes` to mirror without the UI. Example:
|
||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
|
||||
- Run the routes already saved in the app settings:
|
||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters.
|
||||
- Filters:
|
||||
- `--exclude-titles "token1, token2"`
|
||||
- `--exclude-organizers "alice@example.com, Example Org"`
|
||||
- Tokens are comma or newline separated; matching is case-insensitive.
|
||||
|
||||
## Logs
|
||||
- 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`.
|
||||
- `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.
|
||||
|
||||
## Scheduling
|
||||
- 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`
|
||||
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
||||
- 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.
|
||||
|
||||
## Roadmap
|
||||
See [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
# BusyMirror Roadmap
|
||||
|
||||
## Shipped (highlights)
|
||||
- Route-driven mirroring (multi-source)
|
||||
- Accepted-only filter (mirror your accepted meetings)
|
||||
- Persistent settings with autosave/restore; Import/Export JSON
|
||||
- Overlap modes (allow, skipCovered, fillGaps) and merge-gap
|
||||
- Work Hours filter and title-based skip filters
|
||||
- Privacy: placeholders with prefix + customizable title
|
||||
- 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
|
||||
- Source filters (name patterns like `[HOLD]`, `#nomirror`)
|
||||
- Mirror only **Accepted** meetings (exclude tentative/declined)
|
||||
- Persistent settings (routes, window, prefix)
|
||||
- Import/Export settings (.busymirror.json)
|
||||
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
||||
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
|
||||
- Better server-side privacy mapping (per-provider heuristics)
|
||||
|
||||
## Then
|
||||
- iOS/iPadOS app (Run Now, Shortcuts, iCloud sync)
|
||||
- UI: route editor & clearer toggles
|
||||
- Signed/notarized binaries and release pipeline
|
||||
- CLI quality: friendlier `--routes` parsing and help flag
|
||||
- “Dry-run by default” preference
|
||||
|
||||
## Later
|
||||
- Background monitoring (macOS)
|
||||
- Smarter cleanup & conflict resolution
|
||||
- iOS/iPadOS helper (Shortcuts integration)
|
||||
- Profiles & MDM/Managed Config support
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# BusyMirror 1.2.3 — 2025-10-10
|
||||
|
||||
This release focuses on reliable settings persistence and quality-of-life fixes from the 1.2.1 hotfix.
|
||||
|
||||
Highlights
|
||||
- Settings persist between runs: autosave key options on change; restore on launch.
|
||||
- Source/Target selection is remembered using calendar IDs and rehydrated into UI indices.
|
||||
|
||||
Fixes and improvements
|
||||
- Save on change for: days back/forward, default merge gap, privacy/copy notes, all-day, accepted-only, overlap mode, title/placeholder prefixes, auto-delete.
|
||||
- Restore saved `selectedSourceID` and `selectedTargetIDs` and rebuild index selections.
|
||||
- Keep backward compatibility with older saved payloads.
|
||||
- Version bump to 1.2.3 (build 5).
|
||||
|
||||
Included from 1.2.1
|
||||
- Reinitialize `EKEventStore` after permission grant to avoid “Loaded 0 calendars”.
|
||||
- Use attendee `participantStatus == .accepted` for accepted-only filter.
|
||||
- Mark `requestAccess()` and `reloadCalendars()` as `@MainActor`.
|
||||
- Makefile for reproducible builds and packaging.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.3-macOS.zip and .sha256
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# BusyMirror 1.2.4 — 2025-10-10
|
||||
|
||||
Bugfix release improving route-driven mirroring.
|
||||
|
||||
Fixes
|
||||
- Mirror Now is enabled when routes are defined, even if nothing is checked in the main window. This allows fully route-driven runs without requiring a temporary manual selection.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.4-macOS.zip and .sha256
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# BusyMirror 1.3.0 — 2025-10-10
|
||||
|
||||
New
|
||||
- Mark Private option: mirror events with your prefix + real title while marking them Private on supported servers (e.g., Exchange). Co‑workers see the time block but not the details.
|
||||
- Per-route and global toggles for Mark Private; persists in settings and export/import.
|
||||
|
||||
Fixes & improvements
|
||||
- More reliable calendar loading after permission grant (reinit EKEventStore).
|
||||
- Concurrency: `@MainActor` on permission/refresh methods.
|
||||
- Accepted‑only filter via current user attendee `participantStatus`.
|
||||
- Settings autosave and restore (including source/target selections by IDs).
|
||||
- Mirror Now enabled when calendars available; routes or manual selection used as appropriate.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.3.0-macOS.zip and .sha256
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BusyMirror 1.3.1 — Bugfix Release
|
||||
|
||||
- Fix: Auto-delete mirrored placeholders when the source event is removed.
|
||||
- Triggers even if no source instances remain in the selected window.
|
||||
- Also cleans legacy mirrors without mirror URLs by matching exact times.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
BusyMirror 1.3.2 — 2025-10-13
|
||||
|
||||
Changes
|
||||
- Organizer filters: skip mirroring events whose organizer matches a name, email, or URL token. Case-insensitive. Configure in Options.
|
||||
- CLI flags: `--exclude-organizers` and `--exclude-titles` accept comma/newline separated tokens. Example:
|
||||
- `--routes "1->2" --write 1 --exclude-organizers "alice@example.com, Example Org" --exit`
|
||||
|
||||
Notes
|
||||
- Export/Import settings now includes organizer filters (backwards compatible).
|
||||
- No changes to event URL format; feature is fully optional.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
BusyMirror 1.3.3 — 2025-10-13
|
||||
|
||||
Changes
|
||||
- UI: Options panel is scrollable to ensure new filters are always visible on smaller windows.
|
||||
- Organizer filter: skip by organizer name/email/URL; settings persisted; usable via CLI with `--exclude-organizers`.
|
||||
|
||||
Build
|
||||
- Version bump to 1.3.3 (build stays 11).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
BusyMirror 1.3.4 - 2026-03-13
|
||||
|
||||
Changes
|
||||
- Fix multi-route cleanup so one source route no longer deletes mirrored placeholders created by another route.
|
||||
- Persist activity logs to `~/Library/Logs/BusyMirror/BusyMirror.log` and expose a `Reveal Log File` action in the app.
|
||||
- Add `--run-saved-routes` for headless runs using the routes configured in the UI, which makes `launchd` scheduling practical.
|
||||
- Improve calendar refresh by pruning stale saved identifiers and recreating the EventKit store.
|
||||
- Keep the left column from stretching to match the routes/log column on desktop layouts.
|
||||
|
||||
Build
|
||||
- Version bump to 1.3.4 (build 12).
|
||||
@@ -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).
|
||||