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
This commit is contained in:
@@ -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_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 17;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
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.9;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 17;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
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.9;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -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/>
|
<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>
|
||||||
|
|||||||
+201
-372
@@ -96,133 +96,6 @@ private func calChip(_ cal: EKCalendar) -> some View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private let mirrorURLAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
|
||||||
|
|
||||||
private func mirrorURLComponentEncode(_ raw: String) -> String {
|
|
||||||
raw.addingPercentEncoding(withAllowedCharacters: mirrorURLAllowedCharacters) ?? raw
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mirrorURLComponentDecode(_ raw: Substring) -> String {
|
|
||||||
let value = String(raw)
|
|
||||||
return value.removingPercentEncoding ?? value
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sourceOccurrenceKey(sourceCalID: String, sourceStableID: String, occurrence: Date?) -> String {
|
|
||||||
let occPart = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
|
||||||
return "\(sourceCalID)|\(sourceStableID)|\(occPart)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mirrorRecordKey(targetCalID: String, sourceKey: String) -> String {
|
|
||||||
"\(targetCalID)|\(sourceKey)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mirrorTimeKey(start: Date, end: Date) -> String {
|
|
||||||
"\(start.timeIntervalSince1970)|\(end.timeIntervalSince1970)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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 = [
|
|
||||||
mirrorURLComponentEncode(targetCalID),
|
|
||||||
mirrorURLComponentEncode(sourceCalID),
|
|
||||||
mirrorURLComponentEncode(sourceID),
|
|
||||||
occ,
|
|
||||||
String(start.timeIntervalSince1970),
|
|
||||||
String(end.timeIntervalSince1970)
|
|
||||||
]
|
|
||||||
return URL(string: "mirror://\(parts.joined(separator: "|"))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse mirror URL: mirror://<tgtID>|<srcCalID>|<srcStableID>|<occTS>|<startTS>|<endTS>
|
|
||||||
private 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)
|
|
||||||
let parts = body.split(separator: "|")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recognize a mirrored placeholder even if URL is missing
|
|
||||||
private func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -> Bool {
|
|
||||||
if ev.url?.absoluteString.hasPrefix("mirror://") ?? false { return true }
|
|
||||||
let t = ev.title ?? ""
|
|
||||||
if !prefix.isEmpty && t.hasPrefix(prefix) { return true }
|
|
||||||
if t == placeholder || (!prefix.isEmpty && t == (prefix + placeholder)) { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MirrorRecord: Hashable, Codable {
|
private struct MirrorRecord: Hashable, Codable {
|
||||||
var targetCalendarID: String
|
var targetCalendarID: String
|
||||||
var sourceCalendarID: String
|
var sourceCalendarID: String
|
||||||
@@ -296,8 +169,8 @@ struct ContentView: View {
|
|||||||
@State private var routes: [Route] = []
|
@State private var routes: [Route] = []
|
||||||
@AppStorage("daysForward") private var daysForward: Int = 7
|
@AppStorage("daysForward") private var daysForward: Int = 7
|
||||||
@AppStorage("daysBack") private var daysBack: Int = 1
|
@AppStorage("daysBack") private var daysBack: Int = 1
|
||||||
@State private var mergeGapMin: Int = 0
|
|
||||||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||||||
|
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
|
||||||
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
||||||
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
||||||
@AppStorage("markPrivate") private var markPrivate: Bool = false // If ON, set event Private (server-side) when mirroring
|
@AppStorage("markPrivate") private var markPrivate: Bool = false // If ON, set event Private (server-side) when mirroring
|
||||||
@@ -323,10 +196,11 @@ struct ContentView: View {
|
|||||||
@State private var isRunning = false
|
@State private var isRunning = false
|
||||||
@State private var isCLIRun = false
|
@State private var isCLIRun = false
|
||||||
@State private var confirmCleanup = false
|
@State private var confirmCleanup = false
|
||||||
|
@State private var mirrorTask: Task<Void, Never>? = nil
|
||||||
|
@State private var progressText: String? = nil
|
||||||
// Run-session guard: prevents the same source event from being mirrored
|
// Run-session guard: prevents the same source event from being mirrored
|
||||||
// into the same target more than once across multiple routes within a
|
// into the same target more than once across multiple routes within a
|
||||||
// single "Mirror Now" click.
|
// single "Mirror Now" click.
|
||||||
@State private var sessionGuard = Set<String>()
|
|
||||||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||||||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||||||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||||||
@@ -438,21 +312,25 @@ struct ContentView: View {
|
|||||||
let proc = Process()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||||
proc.arguments = arguments
|
proc.arguments = arguments
|
||||||
let output = Pipe()
|
let stdout = Pipe()
|
||||||
proc.standardOutput = output
|
let stderr = Pipe()
|
||||||
proc.standardError = output
|
proc.standardOutput = stdout
|
||||||
|
proc.standardError = stderr
|
||||||
try proc.run()
|
try proc.run()
|
||||||
proc.waitUntilExit()
|
proc.waitUntilExit()
|
||||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
|
||||||
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let outText = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let errText = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let combined = [outText, errText].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||||
if proc.terminationStatus != 0 && !allowFailure {
|
if proc.terminationStatus != 0 && !allowFailure {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "BusyMirrorLaunchCtl",
|
domain: "BusyMirrorLaunchCtl",
|
||||||
code: Int(proc.terminationStatus),
|
code: Int(proc.terminationStatus),
|
||||||
userInfo: [NSLocalizedDescriptionKey: text.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : text]
|
userInfo: [NSLocalizedDescriptionKey: combined.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : combined]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return text
|
return combined
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installSchedule() {
|
private func installSchedule() {
|
||||||
@@ -474,7 +352,8 @@ struct ContentView: View {
|
|||||||
"RunAtLoad": false,
|
"RunAtLoad": false,
|
||||||
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
|
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
|
||||||
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
|
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
|
||||||
"WorkingDirectory": NSHomeDirectory()
|
"WorkingDirectory": NSHomeDirectory(),
|
||||||
|
"EnvironmentVariables": ["HOME": NSHomeDirectory()]
|
||||||
].merging(launchAgentScheduleProperties()) { _, new in new }
|
].merging(launchAgentScheduleProperties()) { _, new in new }
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: launchAgentURL, options: .atomic)
|
try data.write(to: launchAgentURL, options: .atomic)
|
||||||
@@ -922,12 +801,13 @@ struct ContentView: View {
|
|||||||
routes.removeAll { $0.id == id }
|
routes.removeAll { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runConfiguredRoutes(_ configuredRoutes: [Route]) async {
|
private func runConfiguredRoutes(_ configuredRoutes: [Route], sessionGuard: inout Set<String>) async {
|
||||||
var ranAnyRoute = false
|
var ranAnyRoute = false
|
||||||
var skippedMissingSource = 0
|
var skippedMissingSource = 0
|
||||||
var skippedNoTargets = 0
|
var skippedNoTargets = 0
|
||||||
|
|
||||||
for r in configuredRoutes {
|
for (idx, r) in configuredRoutes.enumerated() {
|
||||||
|
if Task.isCancelled { break }
|
||||||
guard let sIdx = indexForCalendar(id: r.sourceID) else {
|
guard let sIdx = indexForCalendar(id: r.sourceID) else {
|
||||||
skippedMissingSource += 1
|
skippedMissingSource += 1
|
||||||
continue
|
continue
|
||||||
@@ -942,37 +822,36 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ranAnyRoute = true
|
ranAnyRoute = true
|
||||||
let prevPrivacy = hideDetails
|
let config = MirrorConfig(
|
||||||
let prevCopy = copyDescription
|
daysBack: daysBack,
|
||||||
let prevGapH = mergeGapHours
|
daysForward: daysForward,
|
||||||
let prevGapM = mergeGapMin
|
mergeGapMin: max(0, r.mergeGapHours * 60),
|
||||||
let prevOverlap = overlapMode
|
hideDetails: r.privacy,
|
||||||
let prevAllDay = mirrorAllDay
|
copyDescription: r.copyNotes,
|
||||||
let prevMarkPrivate = markPrivate
|
markPrivate: r.markPrivate,
|
||||||
|
mirrorAllDay: r.allDay,
|
||||||
|
overlapMode: r.overlap,
|
||||||
|
titlePrefix: titlePrefix,
|
||||||
|
placeholderTitle: placeholderTitle,
|
||||||
|
filterByWorkHours: filterByWorkHours,
|
||||||
|
workHoursStart: workHoursStart,
|
||||||
|
workHoursEnd: workHoursEnd,
|
||||||
|
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||||||
|
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||||||
|
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||||||
|
autoDeleteMissing: autoDeleteMissing,
|
||||||
|
writeEnabled: writeEnabled
|
||||||
|
)
|
||||||
|
let srcCal = calendars[sIdx]
|
||||||
|
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
sourceIndex = sIdx
|
sourceIndex = sIdx
|
||||||
sourceID = r.sourceID
|
sourceID = r.sourceID
|
||||||
targetIDs = validTargets
|
targetIDs = validTargets
|
||||||
targetIDs.remove(r.sourceID)
|
targetIDs.remove(r.sourceID)
|
||||||
hideDetails = r.privacy
|
progressText = "Route \(idx + 1) of \(configuredRoutes.count)"
|
||||||
copyDescription = r.copyNotes
|
|
||||||
mergeGapHours = max(0, r.mergeGapHours)
|
|
||||||
mergeGapMin = mergeGapHours * 60
|
|
||||||
overlapModeRaw = r.overlap.rawValue
|
|
||||||
mirrorAllDay = r.allDay
|
|
||||||
markPrivate = r.markPrivate
|
|
||||||
}
|
|
||||||
await runMirror()
|
|
||||||
await MainActor.run {
|
|
||||||
hideDetails = prevPrivacy
|
|
||||||
copyDescription = prevCopy
|
|
||||||
mergeGapHours = prevGapH
|
|
||||||
mergeGapMin = prevGapM
|
|
||||||
overlapModeRaw = prevOverlap.rawValue
|
|
||||||
mirrorAllDay = prevAllDay
|
|
||||||
markPrivate = prevMarkPrivate
|
|
||||||
}
|
}
|
||||||
|
await runMirror(config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if skippedMissingSource > 0 {
|
if skippedMissingSource > 0 {
|
||||||
@@ -986,25 +865,73 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeMirrorConfig() -> MirrorConfig {
|
||||||
|
MirrorConfig(
|
||||||
|
daysBack: daysBack,
|
||||||
|
daysForward: daysForward,
|
||||||
|
mergeGapMin: mergeGapMin,
|
||||||
|
hideDetails: hideDetails,
|
||||||
|
copyDescription: copyDescription,
|
||||||
|
markPrivate: markPrivate,
|
||||||
|
mirrorAllDay: mirrorAllDay,
|
||||||
|
overlapMode: overlapMode,
|
||||||
|
titlePrefix: titlePrefix,
|
||||||
|
placeholderTitle: placeholderTitle,
|
||||||
|
filterByWorkHours: filterByWorkHours,
|
||||||
|
workHoursStart: workHoursStart,
|
||||||
|
workHoursEnd: workHoursEnd,
|
||||||
|
excludedTitleFilterTerms: excludedTitleFilterTerms,
|
||||||
|
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||||||
|
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||||||
|
autoDeleteMissing: autoDeleteMissing,
|
||||||
|
writeEnabled: writeEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func startMirrorNow() {
|
private func startMirrorNow() {
|
||||||
guard !appController.isSyncing else { return }
|
guard !appController.isSyncing else { return }
|
||||||
|
guard mirrorTask == nil else { return }
|
||||||
appController.setSyncing(true)
|
appController.setSyncing(true)
|
||||||
Task {
|
isRunning = true
|
||||||
|
progressText = nil
|
||||||
|
mirrorTask = Task {
|
||||||
defer {
|
defer {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appController.setSyncing(false)
|
appController.setSyncing(false)
|
||||||
|
isRunning = false
|
||||||
|
mirrorTask = nil
|
||||||
|
progressText = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// New click -> reset the guard so we don't re-process
|
var sessionGuard = Set<String>()
|
||||||
sessionGuard.removeAll()
|
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
await runMirror()
|
guard calendars.indices.contains(sourceIndex) else {
|
||||||
|
log("Cannot mirror: selected source is invalid.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let config = makeMirrorConfig()
|
||||||
|
let srcCal = calendars[sourceIndex]
|
||||||
|
await MainActor.run {
|
||||||
|
sourceID = srcCal.calendarIdentifier
|
||||||
|
enforceNoSourceInTargets()
|
||||||
|
}
|
||||||
|
let targetSet = Set(targetIDs).subtracting([srcCal.calendarIdentifier])
|
||||||
|
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||||||
|
await runMirror(config: config, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||||||
} else {
|
} else {
|
||||||
await runConfiguredRoutes(routes)
|
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cancelMirror() {
|
||||||
|
mirrorTask?.cancel()
|
||||||
|
mirrorTask = nil
|
||||||
|
appController.setSyncing(false)
|
||||||
|
isRunning = false
|
||||||
|
log("Cancelled.")
|
||||||
|
}
|
||||||
|
|
||||||
private func handlePendingMenuBarSyncIfNeeded() {
|
private func handlePendingMenuBarSyncIfNeeded() {
|
||||||
guard appController.hasPendingSyncRequest else { return }
|
guard appController.hasPendingSyncRequest else { return }
|
||||||
guard !isRunning else { return }
|
guard !isRunning else { return }
|
||||||
@@ -1076,9 +1003,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: daysBack) { v in daysBack = max(0, v) }
|
.onChange(of: daysBack) { v in daysBack = max(0, v) }
|
||||||
.onChange(of: daysForward) { v in daysForward = max(0, v) }
|
.onChange(of: daysForward) { v in daysForward = max(0, v) }
|
||||||
.onChange(of: mergeGapHours) { newVal in
|
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
|
||||||
mergeGapMin = max(0, newVal * 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -1325,7 +1250,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func logSection() -> some View {
|
private func logSection() -> some View {
|
||||||
TextEditor(text: $logText)
|
TextEditor(text: Binding(get: { logText }, set: { _ in }))
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.frame(minHeight: 180)
|
.frame(minHeight: 180)
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -1370,12 +1295,25 @@ struct ContentView: View {
|
|||||||
if isRunning {
|
if isRunning {
|
||||||
statusPill("RUNNING", systemImage: "arrow.triangle.2.circlepath", fill: .orange)
|
statusPill("RUNNING", systemImage: "arrow.triangle.2.circlepath", fill: .orange)
|
||||||
}
|
}
|
||||||
Button(isRunning ? "Running…" : "Mirror Now") {
|
if let progressText {
|
||||||
startMirrorNow()
|
Text(progressText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if isRunning {
|
||||||
|
Button("Cancel") {
|
||||||
|
cancelMirror()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
} else {
|
||||||
|
Button("Mirror Now") {
|
||||||
|
startMirrorNow()
|
||||||
|
}
|
||||||
|
.disabled(!canRunMirrorNow)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
}
|
}
|
||||||
.disabled(!canRunMirrorNow)
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.large)
|
|
||||||
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") {
|
||||||
requestAccess()
|
requestAccess()
|
||||||
}
|
}
|
||||||
@@ -1468,7 +1406,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("This will remove events identified as mirrored (by URL prefix or title prefix ‘\(titlePrefix)’) within the current window (Days back/forward) from the selected target calendars.")
|
let prefixNote = titlePrefix.isEmpty ? "" : " (title prefix ‘\(titlePrefix)’)"
|
||||||
|
Text("This will remove events identified as mirrored by URL prefix\(prefixNote) within the current window (Days back/forward) from the selected target calendars.")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appController.setMainWindowVisible(true)
|
appController.setMainWindowVisible(true)
|
||||||
@@ -1476,7 +1415,6 @@ struct ContentView: View {
|
|||||||
log("Log file: \(AppLogStore.logFileURL.path)")
|
log("Log file: \(AppLogStore.logFileURL.path)")
|
||||||
requestAccess()
|
requestAccess()
|
||||||
loadSettingsFromDefaults()
|
loadSettingsFromDefaults()
|
||||||
mergeGapMin = max(0, mergeGapHours * 60)
|
|
||||||
tryRunCLIIfPresent()
|
tryRunCLIIfPresent()
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
handlePendingMenuBarSyncIfNeeded()
|
handlePendingMenuBarSyncIfNeeded()
|
||||||
@@ -1555,7 +1493,6 @@ struct ContentView: View {
|
|||||||
daysForward = intArg("--days-forward", default: daysForward)
|
daysForward = intArg("--days-forward", default: daysForward)
|
||||||
daysBack = intArg("--days-back", default: daysBack)
|
daysBack = intArg("--days-back", default: daysBack)
|
||||||
mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours)
|
mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours)
|
||||||
mergeGapMin = max(0, mergeGapHours * 60)
|
|
||||||
if let modeStr = strArg("--mode")?.lowercased() {
|
if let modeStr = strArg("--mode")?.lowercased() {
|
||||||
switch modeStr {
|
switch modeStr {
|
||||||
case "allow": overlapModeRaw = OverlapMode.allow.rawValue
|
case "allow": overlapModeRaw = OverlapMode.allow.rawValue
|
||||||
@@ -1579,6 +1516,11 @@ struct ContentView: View {
|
|||||||
log("CLI: routes=\(routesSpec)")
|
log("CLI: routes=\(routesSpec)")
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
|
// If permission already granted, force a sync calendar reload so
|
||||||
|
// the CLI doesn't race the async permission callback.
|
||||||
|
if hasAccess {
|
||||||
|
await MainActor.run { reloadCalendars() }
|
||||||
|
}
|
||||||
// Wait up to ~10s for calendars to load
|
// Wait up to ~10s for calendars to load
|
||||||
for _ in 0..<50 {
|
for _ in 0..<50 {
|
||||||
if hasAccess && !calendars.isEmpty { break }
|
if hasAccess && !calendars.isEmpty { break }
|
||||||
@@ -1590,6 +1532,7 @@ struct ContentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cliConfig = makeMirrorConfig()
|
||||||
if runSavedRoutes {
|
if runSavedRoutes {
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
log("CLI: no saved routes; aborting")
|
log("CLI: no saved routes; aborting")
|
||||||
@@ -1606,21 +1549,24 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await runConfiguredRoutes(routes)
|
var sessionGuard = Set<String>()
|
||||||
|
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for part in routeParts where !part.isEmpty {
|
for part in routeParts where !part.isEmpty {
|
||||||
// Format: "S->T1,T2,T3" (indices are 1-based as shown in UI)
|
|
||||||
let lr = part.split(separator: "->", maxSplits: 1).map { String($0) }
|
let lr = part.split(separator: "->", maxSplits: 1).map { String($0) }
|
||||||
guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
||||||
let srcIdx0 = max(0, s1 - 1)
|
let srcIdx0 = max(0, s1 - 1)
|
||||||
let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 }
|
let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 }
|
||||||
if srcIdx0 >= calendars.count { continue }
|
if srcIdx0 >= calendars.count { continue }
|
||||||
|
let srcCal = calendars[srcIdx0]
|
||||||
|
let targetSet = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }).subtracting([srcCal.calendarIdentifier])
|
||||||
|
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
sourceIndex = srcIdx0
|
sourceIndex = srcIdx0
|
||||||
sourceID = calendars[srcIdx0].calendarIdentifier
|
sourceID = srcCal.calendarIdentifier
|
||||||
targetSelections = Set(tgtIdxs0)
|
targetSelections = Set(tgtIdxs0)
|
||||||
targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil })
|
targetIDs = targetSet
|
||||||
}
|
}
|
||||||
|
|
||||||
if boolArg("--cleanup-only", default: false) {
|
if boolArg("--cleanup-only", default: false) {
|
||||||
@@ -1628,7 +1574,8 @@ struct ContentView: View {
|
|||||||
await runCleanup()
|
await runCleanup()
|
||||||
} else {
|
} else {
|
||||||
log("CLI: mirror route \(part)")
|
log("CLI: mirror route \(part)")
|
||||||
await runMirror()
|
var sessionGuard = Set<String>()
|
||||||
|
await runMirror(config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1695,7 +1642,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mirror engine (EventKit)
|
// MARK: - Mirror engine (EventKit)
|
||||||
func runMirror() async {
|
func runMirror(config: MirrorConfig, sourceCalendar: EKCalendar, targetCalendars: [EKCalendar], sessionGuard: inout Set<String>, isMultiRouteRun: Bool) async {
|
||||||
guard hasAccess else {
|
guard hasAccess else {
|
||||||
log("Cannot mirror: calendar access is not granted.")
|
log("Cannot mirror: calendar access is not granted.")
|
||||||
return
|
return
|
||||||
@@ -1704,25 +1651,9 @@ struct ContentView: View {
|
|||||||
log("Cannot mirror: no calendars loaded. Try Refresh Calendars.")
|
log("Cannot mirror: no calendars loaded. Try Refresh Calendars.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard calendars.indices.contains(sourceIndex) else {
|
let srcCal = sourceCalendar
|
||||||
log("Cannot mirror: selected source is invalid.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isRunning = true
|
|
||||||
defer { isRunning = false }
|
|
||||||
|
|
||||||
let srcCal = calendars[sourceIndex]
|
|
||||||
// Ensure sourceID is set when we start
|
|
||||||
sourceID = srcCal.calendarIdentifier
|
|
||||||
// Extra safety: drop source from targets before computing them
|
|
||||||
enforceNoSourceInTargets()
|
|
||||||
let srcName = calLabel(srcCal)
|
let srcName = calLabel(srcCal)
|
||||||
// Build targets by identifier to be robust against index/order changes
|
let targets = targetCalendars.filter { $0.calendarIdentifier != srcCal.calendarIdentifier }
|
||||||
let targetSet = Set(targetIDs)
|
|
||||||
// Additional guard: log and strip if source sneaks into targets
|
|
||||||
if targetSet.contains(srcCal.calendarIdentifier) { log("- WARN: source is present in targets, removing: \(srcName)") }
|
|
||||||
let targetSetNoSrc = targetSet.subtracting([srcCal.calendarIdentifier])
|
|
||||||
let targets = calendars.filter { targetSetNoSrc.contains($0.calendarIdentifier) }
|
|
||||||
if targets.isEmpty {
|
if targets.isEmpty {
|
||||||
log("No target calendars selected. Choose at least one target or add a route with valid targets.")
|
log("No target calendars selected. Choose at least one target or add a route with valid targets.")
|
||||||
return
|
return
|
||||||
@@ -1730,19 +1661,18 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let todayStart = cal.startOfDay(for: Date())
|
let todayStart = cal.startOfDay(for: Date())
|
||||||
let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)!
|
let windowStart = cal.date(byAdding: .day, value: -config.daysBack, to: todayStart)!
|
||||||
let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)!
|
let windowEnd = cal.date(byAdding: .day, value: config.daysForward, to: todayStart)!
|
||||||
log("=== BusyMirror ===")
|
log("=== BusyMirror ===")
|
||||||
log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))")
|
log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))")
|
||||||
log("Window: \(windowStart) -> \(windowEnd)")
|
log("Window: \(windowStart) -> \(windowEnd)")
|
||||||
log("WRITE: \(writeEnabled) \(writeEnabled ? "" : "(DRY-RUN)") mode: \(overlapMode.rawValue) mergeGapMin: \(mergeGapMin) allDay: \(mirrorAllDay)")
|
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: ", "))}")
|
log("Route: \(srcName) → {\(targets.map { calLabel($0) }.joined(separator: ", "))}")
|
||||||
|
|
||||||
// Source events (recurrences expanded by EventKit)
|
// Source events (recurrences expanded by EventKit)
|
||||||
let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal])
|
let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal])
|
||||||
var srcEvents = store.events(matching: srcPred)
|
var srcEvents = store.events(matching: srcPred)
|
||||||
let srcFetched = srcEvents.count
|
let srcFetched = srcEvents.count
|
||||||
// HARD FILTER: even if EventKit returns events from other calendars, keep only exact source calendar
|
|
||||||
srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier }
|
srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier }
|
||||||
let srcKept = srcEvents.count
|
let srcKept = srcEvents.count
|
||||||
if srcKept != srcFetched {
|
if srcKept != srcFetched {
|
||||||
@@ -1752,18 +1682,18 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var srcBlocks: [Block] = []
|
var srcBlocks: [Block] = []
|
||||||
var skippedMirrors = 0
|
var skippedMirrors = 0
|
||||||
let titleFilters = excludedTitleFilterTerms
|
let titleFilters = config.excludedTitleFilterTerms
|
||||||
let organizerFilters = excludedOrganizerFilterTerms
|
let organizerFilters = config.excludedOrganizerFilterTerms
|
||||||
let enforceWorkHours = filterByWorkHours && workHoursEnd > workHoursStart
|
let enforceWorkHours = config.filterByWorkHours && config.workHoursEnd > config.workHoursStart
|
||||||
let allowedStartMinutes = workHoursStart * 60
|
let allowedStartMinutes = config.workHoursStart * 60
|
||||||
let allowedEndMinutes = workHoursEnd * 60
|
let allowedEndMinutes = config.workHoursEnd * 60
|
||||||
var skippedWorkHours = 0
|
var skippedWorkHours = 0
|
||||||
var skippedTitles = 0
|
var skippedTitles = 0
|
||||||
var skippedOrganizers = 0
|
var skippedOrganizers = 0
|
||||||
var skippedStatus = 0
|
var skippedStatus = 0
|
||||||
for ev in srcEvents {
|
for ev in srcEvents {
|
||||||
if mirrorAcceptedOnly, ev.hasAttendees {
|
if Task.isCancelled { break }
|
||||||
// Only include events where the current user's attendee status is Accepted
|
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
||||||
let attendees = ev.attendees ?? []
|
let attendees = ev.attendees ?? []
|
||||||
if let me = attendees.first(where: { $0.isCurrentUser }) {
|
if let me = attendees.first(where: { $0.isCurrentUser }) {
|
||||||
if me.participantStatus != .accepted {
|
if me.participantStatus != .accepted {
|
||||||
@@ -1771,7 +1701,6 @@ struct ContentView: View {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If we cannot determine a self attendee, treat as not accepted
|
|
||||||
skippedStatus += 1
|
skippedStatus += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1781,22 +1710,20 @@ struct ContentView: View {
|
|||||||
skippedWorkHours += 1
|
skippedWorkHours += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shouldSkip(event: ev, filters: titleFilters) {
|
if shouldSkip(title: ev.title, filters: titleFilters, titlePrefix: config.titlePrefix) {
|
||||||
skippedTitles += 1
|
skippedTitles += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shouldSkipOrganizer(event: ev, filters: organizerFilters) {
|
if shouldSkipOrganizer(organizerValues: organizerStrings(for: ev), filters: organizerFilters) {
|
||||||
skippedOrganizers += 1
|
skippedOrganizers += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue }
|
if !config.mirrorAllDay && ev.isAllDay { continue }
|
||||||
if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) {
|
if isMirrorEvent(ev, prefix: config.titlePrefix, placeholder: config.placeholderTitle) {
|
||||||
// Aggregate skip count for mirrored-on-source
|
|
||||||
skippedMirrors += 1
|
skippedMirrors += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||||
// Defensive: never treat events from another calendar as source
|
|
||||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||||
let srcID = stableSourceIdentifier(for: ev)
|
let srcID = stableSourceIdentifier(for: ev)
|
||||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
|
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
|
||||||
@@ -1816,12 +1743,10 @@ struct ContentView: View {
|
|||||||
if skippedStatus > 0 {
|
if skippedStatus > 0 {
|
||||||
log("- SKIP non-accepted status: \(skippedStatus) event(s)")
|
log("- SKIP non-accepted status: \(skippedStatus) event(s)")
|
||||||
}
|
}
|
||||||
// Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races)
|
srcBlocks = uniqueBlocks(srcBlocks, trackByID: config.mergeGapMin == 0)
|
||||||
srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0)
|
|
||||||
|
|
||||||
// Merge for Flights or similar
|
let baseBlocks = (config.mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: config.mergeGapMin) : srcBlocks
|
||||||
let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks
|
let trackByID = (config.mergeGapMin == 0)
|
||||||
let trackByID = (mergeGapMin == 0)
|
|
||||||
var mirrorIndex = loadMirrorIndex()
|
var mirrorIndex = loadMirrorIndex()
|
||||||
var mirrorIndexChanged = false
|
var mirrorIndexChanged = false
|
||||||
|
|
||||||
@@ -1830,21 +1755,29 @@ struct ContentView: View {
|
|||||||
return sourceOccurrenceKey(sourceCalID: srcCal.calendarIdentifier, sourceStableID: sid, occurrence: blk.occurrence)
|
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 {
|
for tgt in targets {
|
||||||
|
if Task.isCancelled { break }
|
||||||
let tgtName = calLabel(tgt)
|
let tgtName = calLabel(tgt)
|
||||||
log(">>> Target: \(tgtName)")
|
log(">>> Target: \(tgtName)")
|
||||||
if tgt.calendarIdentifier == srcCal.calendarIdentifier {
|
if tgt.calendarIdentifier == srcCal.calendarIdentifier {
|
||||||
log("- SKIP target is same as source: \(tgtName)")
|
log("- SKIP target is same as source: \(tgtName)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Prefetch target window
|
let tgtEvents: [EKEvent]
|
||||||
let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt])
|
if let cached = targetEventCache[tgt.calendarIdentifier] {
|
||||||
var tgtEvents = store.events(matching: tgtPred)
|
tgtEvents = cached
|
||||||
let tgtFetched = tgtEvents.count
|
} else {
|
||||||
// HARD FILTER: ensure we only consider events truly on the target calendar
|
let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt])
|
||||||
tgtEvents = tgtEvents.filter { $0.calendar.calendarIdentifier == tgt.calendarIdentifier }
|
var evs = store.events(matching: tgtPred)
|
||||||
if tgtFetched != tgtEvents.count {
|
let tgtFetched = evs.count
|
||||||
log("- WARN: filtered \(tgtFetched - tgtEvents.count) stray target event(s) not in \(tgtName)")
|
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 placeholderSet = Set<String>()
|
||||||
@@ -1859,7 +1792,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
if let ts = tv.startDate, let te = tv.endDate {
|
if let ts = tv.startDate, let te = tv.endDate {
|
||||||
let timeKey = mirrorTimeKey(start: ts, end: te)
|
let timeKey = mirrorTimeKey(start: ts, end: te)
|
||||||
if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) {
|
if isMirrorEvent(tv, prefix: config.titlePrefix, placeholder: config.placeholderTitle) {
|
||||||
placeholderSet.insert(timeKey)
|
placeholderSet.insert(timeKey)
|
||||||
placeholdersByTime[timeKey] = tv
|
placeholdersByTime[timeKey] = tv
|
||||||
let parsed = parseMirrorURL(tv.url)
|
let parsed = parseMirrorURL(tv.url)
|
||||||
@@ -1903,7 +1836,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func desiredNotes(for blk: Block) -> String? {
|
func desiredNotes(for blk: Block) -> String? {
|
||||||
(!hideDetails && copyDescription) ? blk.notes : nil
|
(!config.hideDetails && config.copyDescription) ? blk.notes : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertMirrorRecord(for blk: Block, event: EKEvent) {
|
func upsertMirrorRecord(for blk: Block, event: EKEvent) {
|
||||||
@@ -1978,10 +1911,10 @@ struct ContentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix)
|
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
||||||
let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle)
|
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
||||||
let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
|
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
|
||||||
let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle
|
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle
|
||||||
let notes = desiredNotes(for: blk)
|
let notes = desiredNotes(for: blk)
|
||||||
let desiredURL = buildMirrorURL(
|
let desiredURL = buildMirrorURL(
|
||||||
targetCalID: tgt.calendarIdentifier,
|
targetCalID: tgt.calendarIdentifier,
|
||||||
@@ -2004,7 +1937,7 @@ struct ContentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let byTimeSuffix = byTime ? " (by time)" : ""
|
let byTimeSuffix = byTime ? " (by time)" : ""
|
||||||
if !writeEnabled {
|
if !config.writeEnabled {
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
||||||
updated += 1
|
updated += 1
|
||||||
@@ -2016,8 +1949,8 @@ struct ContentView: View {
|
|||||||
existing.isAllDay = false
|
existing.isAllDay = false
|
||||||
existing.notes = notes
|
existing.notes = notes
|
||||||
existing.url = desiredURL
|
existing.url = desiredURL
|
||||||
let ok = setEventPrivateIfSupported(existing, markPrivate)
|
let ok = setEventPrivateIfSupported(existing, config.markPrivate)
|
||||||
if markPrivate && !ok && !warnedPrivateUnsupported {
|
if config.markPrivate && !ok && !warnedPrivateUnsupported {
|
||||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
||||||
warnedPrivateUnsupported = true
|
warnedPrivateUnsupported = true
|
||||||
}
|
}
|
||||||
@@ -2056,7 +1989,7 @@ struct ContentView: View {
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !writeEnabled {
|
if !config.writeEnabled {
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
||||||
return
|
return
|
||||||
@@ -2075,8 +2008,8 @@ struct ContentView: View {
|
|||||||
newEv.notes = notes
|
newEv.notes = notes
|
||||||
newEv.url = desiredURL
|
newEv.url = desiredURL
|
||||||
newEv.availability = .busy
|
newEv.availability = .busy
|
||||||
let okNew = setEventPrivateIfSupported(newEv, markPrivate)
|
let okNew = setEventPrivateIfSupported(newEv, config.markPrivate)
|
||||||
if markPrivate && !okNew && !warnedPrivateUnsupported {
|
if config.markPrivate && !okNew && !warnedPrivateUnsupported {
|
||||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
||||||
warnedPrivateUnsupported = true
|
warnedPrivateUnsupported = true
|
||||||
}
|
}
|
||||||
@@ -2095,7 +2028,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for b in baseBlocks {
|
for b in baseBlocks {
|
||||||
switch overlapMode {
|
if Task.isCancelled { break }
|
||||||
|
switch config.overlapMode {
|
||||||
case .allow:
|
case .allow:
|
||||||
await createOrUpdateIfNeeded(b)
|
await createOrUpdateIfNeeded(b)
|
||||||
case .skipCovered:
|
case .skipCovered:
|
||||||
@@ -2116,10 +2050,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)")
|
log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)")
|
||||||
if autoDeleteMissing {
|
if config.autoDeleteMissing {
|
||||||
let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) })
|
let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) })
|
||||||
let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) })
|
let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) })
|
||||||
let isMultiRouteRun = !routes.isEmpty
|
|
||||||
|
|
||||||
var byID: [String: EKEvent] = [:]
|
var byID: [String: EKEvent] = [:]
|
||||||
for tv in placeholdersByTime.values {
|
for tv in placeholdersByTime.values {
|
||||||
@@ -2139,9 +2072,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (recordKey, record) in staleMirrorRecords {
|
for (recordKey, record) in staleMirrorRecords {
|
||||||
|
if Task.isCancelled { break }
|
||||||
let candidate = resolveMappedEvent(for: record)
|
let candidate = resolveMappedEvent(for: record)
|
||||||
if let candidate {
|
if let candidate {
|
||||||
if !writeEnabled {
|
if !config.writeEnabled {
|
||||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
@@ -2157,7 +2091,7 @@ struct ContentView: View {
|
|||||||
handledEventIDs.insert(eid)
|
handledEventIDs.insert(eid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeEnabled || candidate == nil {
|
if config.writeEnabled || candidate == nil {
|
||||||
if mirrorIndex.removeValue(forKey: recordKey) != nil {
|
if mirrorIndex.removeValue(forKey: recordKey) != nil {
|
||||||
mirrorIndexChanged = true
|
mirrorIndexChanged = true
|
||||||
}
|
}
|
||||||
@@ -2165,6 +2099,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for ev in byID.values {
|
for ev in byID.values {
|
||||||
|
if Task.isCancelled { break }
|
||||||
if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) {
|
if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2204,7 +2139,7 @@ struct ContentView: View {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shouldDelete {
|
if shouldDelete {
|
||||||
if !writeEnabled {
|
if !config.writeEnabled {
|
||||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
|
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
@@ -2248,7 +2183,7 @@ private struct SettingsPayload: Codable {
|
|||||||
var workHoursStart: Int = 9
|
var workHoursStart: Int = 9
|
||||||
var workHoursEnd: Int = 17
|
var workHoursEnd: Int = 17
|
||||||
var excludedTitleFilters: [String] = []
|
var excludedTitleFilters: [String] = []
|
||||||
var excludedOrganizerFilters: [String]? = nil
|
var excludedOrganizerFilters: [String] = []
|
||||||
var mirrorAcceptedOnly: Bool = false
|
var mirrorAcceptedOnly: Bool = false
|
||||||
var overlapMode: String
|
var overlapMode: String
|
||||||
var titlePrefix: String
|
var titlePrefix: String
|
||||||
@@ -2294,7 +2229,6 @@ private struct SettingsPayload: Codable {
|
|||||||
daysBack = s.daysBack
|
daysBack = s.daysBack
|
||||||
daysForward = s.daysForward
|
daysForward = s.daysForward
|
||||||
mergeGapHours = s.mergeGapHours
|
mergeGapHours = s.mergeGapHours
|
||||||
mergeGapMin = max(0, s.mergeGapHours * 60)
|
|
||||||
hideDetails = s.hideDetails
|
hideDetails = s.hideDetails
|
||||||
copyDescription = s.copyDescription
|
copyDescription = s.copyDescription
|
||||||
mirrorAllDay = s.mirrorAllDay
|
mirrorAllDay = s.mirrorAllDay
|
||||||
@@ -2303,9 +2237,7 @@ private struct SettingsPayload: Codable {
|
|||||||
workHoursStart = s.workHoursStart
|
workHoursStart = s.workHoursStart
|
||||||
workHoursEnd = s.workHoursEnd
|
workHoursEnd = s.workHoursEnd
|
||||||
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
|
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
|
||||||
if let orgs = s.excludedOrganizerFilters {
|
excludedOrganizerFiltersRaw = s.excludedOrganizerFilters.joined(separator: "\n")
|
||||||
excludedOrganizerFiltersRaw = orgs.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
mirrorAcceptedOnly = s.mirrorAcceptedOnly
|
mirrorAcceptedOnly = s.mirrorAcceptedOnly
|
||||||
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
|
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
|
||||||
titlePrefix = s.titlePrefix
|
titlePrefix = s.titlePrefix
|
||||||
@@ -2396,15 +2328,6 @@ private struct SettingsPayload: Codable {
|
|||||||
|
|
||||||
// MARK: - Filters
|
// MARK: - Filters
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort: mark an event as Private if the account/server supports it.
|
// Best-effort: mark an event as Private if the account/server supports it.
|
||||||
// Uses ObjC selector lookup to avoid crashes on unsupported keys.
|
// Uses ObjC selector lookup to avoid crashes on unsupported keys.
|
||||||
private func setEventPrivateIfSupported(_ ev: EKEvent, _ flag: Bool) -> Bool {
|
private func setEventPrivateIfSupported(_ ev: EKEvent, _ flag: Bool) -> Bool {
|
||||||
@@ -2430,67 +2353,13 @@ private struct SettingsPayload: Codable {
|
|||||||
("classification", 1) // iCalendar CLASS: 1 might map to PRIVATE
|
("classification", 1) // iCalendar CLASS: 1 might map to PRIVATE
|
||||||
]
|
]
|
||||||
for (key, val) in kvPairs {
|
for (key, val) in kvPairs {
|
||||||
do {
|
ev.setValue(val, forKey: key)
|
||||||
ev.setValue(val, forKey: key)
|
return true
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
// ignore and try next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Not supported; no-op
|
// Not supported; no-op
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldSkip(event: EKEvent, filters: [String]) -> Bool {
|
|
||||||
guard !filters.isEmpty else { return false }
|
|
||||||
let rawTitle = (event.title ?? "").lowercased()
|
|
||||||
let strippedTitle = stripPrefix(event.title, prefix: titlePrefix).lowercased()
|
|
||||||
return filters.contains { token in
|
|
||||||
rawTitle.contains(token) || strippedTitle.contains(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Organizer filters
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldSkipOrganizer(event: EKEvent, filters: [String]) -> Bool {
|
|
||||||
guard !filters.isEmpty else { return false }
|
|
||||||
let vals = organizerStrings(for: event).map { $0.lowercased() }
|
|
||||||
guard !vals.isEmpty else { return false }
|
|
||||||
for token in filters {
|
|
||||||
for v in vals {
|
|
||||||
if v.contains(token) { return true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clampWorkHours() {
|
private func clampWorkHours() {
|
||||||
let clampedStart = min(max(workHoursStart, 0), 23)
|
let clampedStart = min(max(workHoursStart, 0), 23)
|
||||||
if clampedStart != workHoursStart { workHoursStart = clampedStart }
|
if clampedStart != workHoursStart { workHoursStart = clampedStart }
|
||||||
@@ -2507,61 +2376,21 @@ private struct SettingsPayload: Codable {
|
|||||||
AppLogStore.append(s)
|
AppLogStore.append(s)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
logText.append("\n" + s)
|
logText.append("\n" + s)
|
||||||
}
|
let maxLines = 2000
|
||||||
}
|
let lines = logText.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
if lines.count > maxLines {
|
||||||
// MARK: - Block helpers
|
logText = lines.suffix(maxLines).joined(separator: "\n")
|
||||||
func key(_ b: Block) -> String {
|
|
||||||
"\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)"
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup: delete Busy placeholders in the active window on selected targets
|
// MARK: - Cleanup: delete Busy placeholders in the active window on selected targets
|
||||||
func runCleanup() async {
|
func runCleanup() async {
|
||||||
guard hasAccess, !calendars.isEmpty else { return }
|
guard hasAccess, !calendars.isEmpty else { return }
|
||||||
|
guard calendars.indices.contains(sourceIndex) else {
|
||||||
|
log("Cannot cleanup: selected source is invalid.")
|
||||||
|
return
|
||||||
|
}
|
||||||
isRunning = true
|
isRunning = true
|
||||||
defer { isRunning = false }
|
defer { isRunning = false }
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>15.5</string>
|
||||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
<string>BusyMirror needs access to your calendars to create busy placeholders.</string>
|
<string>BusyMirror needs access to your calendars to create busy placeholders.</string>
|
||||||
<key>NSRemindersFullAccessUsageDescription</key>
|
<key>NSRemindersFullAccessUsageDescription</key>
|
||||||
|
|||||||
@@ -0,0 +1,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
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
|
## [1.3.9] - 2026-04-09
|
||||||
- New: add a macOS menu bar extra with `Sync Now`, `Open BusyMirror`, and `Quit BusyMirror`.
|
- 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: menu bar sync requests reuse the existing mirror flow and can open the main window automatically when needed.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- 1.3.4: persistent file logging, stale-calendar pruning on refresh, clickable top-bar mode toggle
|
- 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: in-app scheduling via `launchd` with hourly/daily/weekday modes
|
||||||
- 1.3.6: generated macOS app icon set and packaged release assets
|
- 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user