diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 22cbe05..a35217a 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -1,6 +1,7 @@ import SwiftUI import EventKit import AppKit +import ObjectiveC /// Placeholder title is configurable via state (see `placeholderTitle`) private let SAME_TIME_TOL_MIN: Double = 5 @@ -108,6 +109,32 @@ struct Route: Identifiable, Hashable, Codable { var mergeGapHours: Int // per-route merge gap (hours) var overlap: OverlapMode // per-route overlap behavior var allDay: Bool // per-route mirror all-day + var markPrivate: Bool = false // mark mirrored events as Private (if supported by account) + + enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay, markPrivate } + + init(sourceID: String, targetIDs: Set, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool, markPrivate: Bool = false) { + self.sourceID = sourceID + self.targetIDs = targetIDs + self.privacy = privacy + self.copyNotes = copyNotes + self.mergeGapHours = mergeGapHours + self.overlap = overlap + self.allDay = allDay + self.markPrivate = markPrivate + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.sourceID = try c.decode(String.self, forKey: .sourceID) + self.targetIDs = try c.decode(Set.self, forKey: .targetIDs) + self.privacy = try c.decode(Bool.self, forKey: .privacy) + self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes) + self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours) + self.overlap = try c.decode(OverlapMode.self, forKey: .overlap) + self.allDay = try c.decode(Bool.self, forKey: .allDay) + self.markPrivate = (try? c.decode(Bool.self, forKey: .markPrivate)) ?? false + } } struct ContentView: View { @@ -126,6 +153,7 @@ struct ContentView: View { @AppStorage("mergeGapHours") private var mergeGapHours: Int = 0 @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("markPrivate") private var markPrivate: Bool = false // If ON, set event Private (server-side) when mirroring @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @@ -280,7 +308,8 @@ struct ContentView: View { copyNotes: copyDescription, mergeGapHours: mergeGapHours, overlap: overlapMode, - allDay: mirrorAllDay) + allDay: mirrorAllDay, + markPrivate: markPrivate) routes.append(r) } .disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) @@ -314,6 +343,9 @@ struct ContentView: View { Toggle("Copy desc", isOn: routeBinding.copyNotes) .disabled(isRunning || route.privacy) .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") + Toggle("Mark Private", isOn: routeBinding.markPrivate) + .disabled(isRunning) + .help("If ON, attempt to mark mirrored events as Private on the server (e.g., Exchange). Titles still use your prefix and source title.") overlapPicker(for: routeBinding) .disabled(isRunning) Toggle("All-day", isOn: routeBinding.allDay) @@ -425,6 +457,8 @@ struct ContentView: View { Toggle("Copy description when mirroring", isOn: $copyDescription) .disabled(isRunning || hideDetails) + Toggle("Mark mirrored events as Private (if supported)", isOn: $markPrivate) + .disabled(isRunning) Toggle("Mirror all-day events", isOn: $mirrorAllDay) .disabled(isRunning) Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly) @@ -542,6 +576,7 @@ struct ContentView: View { mergeGapMin = mergeGapHours * 60 overlapModeRaw = r.overlap.rawValue mirrorAllDay = r.allDay + markPrivate = r.markPrivate } await runMirror() await MainActor.run { @@ -676,6 +711,7 @@ struct ContentView: View { .onChange(of: titlePrefix) { _ in saveSettingsToDefaults() } .onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() } .onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() } + .onChange(of: markPrivate) { _ in saveSettingsToDefaults() } .onChange(of: sourceIndex) { newValue in // Track selected source by persistent ID and ensure it is not a target if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } @@ -1016,6 +1052,7 @@ struct ContentView: View { let sid0 = blk.srcEventID ?? "" let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + _ = setEventPrivateIfSupported(existingByTime, markPrivate) do { try store.save(existingByTime, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") @@ -1058,6 +1095,7 @@ struct ContentView: View { existing.notes = nil } existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") + _ = setEventPrivateIfSupported(existing, markPrivate) do { try store.save(existing, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") @@ -1098,6 +1136,7 @@ struct ContentView: View { let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 newEv.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") newEv.availability = .busy + _ = setEventPrivateIfSupported(newEv, markPrivate) do { try store.save(newEv, span: .thisEvent, commit: true) created += 1 @@ -1177,6 +1216,7 @@ private struct SettingsPayload: Codable { var mergeGapHours: Int var hideDetails: Bool var copyDescription: Bool + var markPrivate: Bool var mirrorAllDay: Bool var filterByWorkHours: Bool = false var workHoursStart: Int = 9 @@ -1203,6 +1243,7 @@ private struct SettingsPayload: Codable { mergeGapHours: mergeGapHours, hideDetails: hideDetails, copyDescription: copyDescription, + markPrivate: markPrivate, mirrorAllDay: mirrorAllDay, filterByWorkHours: filterByWorkHours, workHoursStart: workHoursStart, @@ -1229,6 +1270,7 @@ private struct SettingsPayload: Codable { hideDetails = s.hideDetails copyDescription = s.copyDescription mirrorAllDay = s.mirrorAllDay + markPrivate = s.markPrivate filterByWorkHours = s.filterByWorkHours workHoursStart = s.workHoursStart workHoursEnd = s.workHoursEnd @@ -1332,6 +1374,26 @@ private struct SettingsPayload: Codable { return start < startMinutes || start >= endMinutes } + // Best-effort: mark an event as Private if the account/server supports it. + // Uses ObjC selector lookup to avoid crashes on unsupported keys. + private func setEventPrivateIfSupported(_ ev: EKEvent, _ flag: Bool) -> Bool { + guard flag else { return false } + let sel = Selector(("setPrivate:")) + if ev.responds(to: sel) { + _ = ev.perform(sel, with: NSNumber(value: true)) + return true + } + // Some backends may expose privacy level via a numeric setter + let sel2 = Selector(("setPrivacy:")) + if ev.responds(to: sel2) { + // 1 may represent private on some providers; this is best-effort. + _ = ev.perform(sel2, with: NSNumber(value: 1)) + return true + } + // Not supported; no-op + return false + } + private func shouldSkip(event: EKEvent, filters: [String]) -> Bool { guard !filters.isEmpty else { return false } let rawTitle = (event.title ?? "").lowercased() diff --git a/README.md b/README.md index 1638de7..7bad004 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,20 @@ BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices. -## What it does (current checkpoint) -- Manual “Run” to mirror events across selected routes (Source → Targets). -- DRY-RUN mode shows what would happen. -- Prefix-based tagging of mirrored events. -- Cleanup of placeholders (with confirmation). -- Loop/duplicate guards so mirrors don’t replicate themselves. -- Time window and merge-gap settings. +## What it does (current) +- Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go. +- Manual selection mirroring: pick a source and targets in the UI and run. +- Two privacy modes: + - Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy"). + - Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort). +- DRY-RUN mode: see what would be created/updated/deleted without writing. +- Overlap modes: `allow`, `skipCovered`, `fillGaps`. +- Merge adjacent events with a configurable gap. +- Time window controls (days back/forward) and Work Hours filter. +- Accepted-only filter (mirror your accepted meetings only). +- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared. +- Prefix-based tagging and loop guards to prevent re-mirroring mirrors. +- Settings: autosave/restore, Import/Export JSON. ## Why Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices). @@ -27,6 +34,11 @@ Option B — Makefile (reproducible) See `CHANGELOG.md` for notable changes. +## CLI (optional) +- Run from Terminal with `--routes` to mirror without the UI. Example: + - `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit` +- Flags exist for privacy, all-day, merge gap, days window, overlap mode, and cleanup. + ## Roadmap See [ROADMAP.md](ROADMAP.md) diff --git a/ROADMAP.md b/ROADMAP.md index 395c839..dafa357 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,17 +1,26 @@ # BusyMirror Roadmap +## Shipped (highlights) +- Route-driven mirroring (multi-source) +- Accepted-only filter (mirror your accepted meetings) +- Persistent settings with autosave/restore; Import/Export JSON +- Overlap modes (allow, skipCovered, fillGaps) and merge-gap +- Work Hours filter and title-based skip filters +- Privacy: placeholders with prefix + customizable title +- 1.3.0: Mark Private option (global + per-route) + ## Next -- Source filters (name patterns like `[HOLD]`, `#nomirror`) -- Mirror only **Accepted** meetings (exclude tentative/declined) -- Persistent settings (routes, window, prefix) -- Import/Export settings (.busymirror.json) +- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less) +- Hint near "Mirror Now" indicating run mode (Routes vs Manual) +- Better server-side privacy mapping (per-provider heuristics) ## Then -- iOS/iPadOS app (Run Now, Shortcuts, iCloud sync) -- UI: route editor & clearer toggles +- Signed/notarized binaries and release pipeline +- CLI quality: friendlier `--routes` parsing and help flag - “Dry-run by default” preference ## Later - Background monitoring (macOS) - Smarter cleanup & conflict resolution +- iOS/iPadOS helper (Shortcuts integration) - Profiles & MDM/Managed Config support