5 Commits
v1.2.4 ... main

7 changed files with 213 additions and 42 deletions

View File

@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 9;
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.2.4; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 9;
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.2.4; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;

View File

@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import EventKit import EventKit
import AppKit import AppKit
import ObjectiveC
/// Placeholder title is configurable via state (see `placeholderTitle`) /// Placeholder title is configurable via state (see `placeholderTitle`)
private let SAME_TIME_TOL_MIN: Double = 5 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 mergeGapHours: Int // per-route merge gap (hours)
var overlap: OverlapMode // per-route overlap behavior var overlap: OverlapMode // per-route overlap behavior
var allDay: Bool // per-route mirror all-day 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<String>, 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<String>.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 { struct ContentView: View {
@@ -126,6 +153,7 @@ struct ContentView: View {
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0 @AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@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("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@@ -150,6 +178,14 @@ struct ContentView: View {
@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
// Mirrors can run either by manual selection (source + at least one target)
// or using predefined routes. This derived flag controls the Mirror Now button.
private var canRunMirrorNow: Bool {
// Enable Mirror Now whenever calendars are available and permission is granted.
// The action itself chooses between routes or manual selection.
return hasAccess && !isRunning && !calendars.isEmpty
}
private static let intFormatter: NumberFormatter = { private static let intFormatter: NumberFormatter = {
let f = NumberFormatter() let f = NumberFormatter()
f.numberStyle = .none f.numberStyle = .none
@@ -272,7 +308,8 @@ struct ContentView: View {
copyNotes: copyDescription, copyNotes: copyDescription,
mergeGapHours: mergeGapHours, mergeGapHours: mergeGapHours,
overlap: overlapMode, overlap: overlapMode,
allDay: mirrorAllDay) allDay: mirrorAllDay,
markPrivate: markPrivate)
routes.append(r) routes.append(r)
} }
.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) .disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty)
@@ -306,6 +343,9 @@ struct ContentView: View {
Toggle("Copy desc", isOn: routeBinding.copyNotes) Toggle("Copy desc", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy) .disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source events notes/description into the placeholder.") .help("If ON and Private is OFF, copy the source events 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) overlapPicker(for: routeBinding)
.disabled(isRunning) .disabled(isRunning)
Toggle("All-day", isOn: routeBinding.allDay) Toggle("All-day", isOn: routeBinding.allDay)
@@ -417,6 +457,8 @@ struct ContentView: View {
Toggle("Copy description when mirroring", isOn: $copyDescription) Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(isRunning || hideDetails) .disabled(isRunning || hideDetails)
Toggle("Mark mirrored events as Private (if supported)", isOn: $markPrivate)
.disabled(isRunning)
Toggle("Mirror all-day events", isOn: $mirrorAllDay) Toggle("Mirror all-day events", isOn: $mirrorAllDay)
.disabled(isRunning) .disabled(isRunning)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly) Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
@@ -534,6 +576,7 @@ struct ContentView: View {
mergeGapMin = mergeGapHours * 60 mergeGapMin = mergeGapHours * 60
overlapModeRaw = r.overlap.rawValue overlapModeRaw = r.overlap.rawValue
mirrorAllDay = r.allDay mirrorAllDay = r.allDay
markPrivate = r.markPrivate
} }
await runMirror() await runMirror()
await MainActor.run { await MainActor.run {
@@ -550,7 +593,7 @@ struct ContentView: View {
} }
} }
} }
.disabled(isRunning || calendars.isEmpty || (routes.isEmpty && targetSelections.isEmpty)) .disabled(!canRunMirrorNow)
Button("Cleanup Placeholders") { Button("Cleanup Placeholders") {
if writeEnabled { if writeEnabled {
@@ -668,6 +711,7 @@ struct ContentView: View {
.onChange(of: titlePrefix) { _ in saveSettingsToDefaults() } .onChange(of: titlePrefix) { _ in saveSettingsToDefaults() }
.onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() } .onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() }
.onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() } .onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() }
.onChange(of: markPrivate) { _ in saveSettingsToDefaults() }
.onChange(of: sourceIndex) { newValue in .onChange(of: sourceIndex) { newValue in
// Track selected source by persistent ID and ensure it is not a target // Track selected source by persistent ID and ensure it is not a target
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
@@ -962,6 +1006,7 @@ struct ContentView: View {
var created = 0 var created = 0
var skipped = 0 var skipped = 0
var updated = 0 var updated = 0
var warnedPrivateUnsupported = false
// Cross-route loop guard: unique key generator for (source, occurrence/time, target) // Cross-route loop guard: unique key generator for (source, occurrence/time, target)
func guardKey(for blk: Block, targetID: String) -> String { func guardKey(for blk: Block, targetID: String) -> String {
@@ -1008,6 +1053,11 @@ struct ContentView: View {
let sid0 = blk.srcEventID ?? "" let sid0 = blk.srcEventID ?? ""
let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 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)") existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
let okTime = setEventPrivateIfSupported(existingByTime, markPrivate)
if markPrivate && !okTime && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
try store.save(existingByTime, span: .thisEvent, commit: true) try store.save(existingByTime, span: .thisEvent, commit: true)
log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)")
@@ -1050,6 +1100,11 @@ struct ContentView: View {
existing.notes = nil existing.notes = nil
} }
existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
let okOcc = setEventPrivateIfSupported(existing, markPrivate)
if markPrivate && !okOcc && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
try store.save(existing, span: .thisEvent, commit: true) try store.save(existing, span: .thisEvent, commit: true)
log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
@@ -1090,6 +1145,11 @@ struct ContentView: View {
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 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.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
newEv.availability = .busy newEv.availability = .busy
let okNew = setEventPrivateIfSupported(newEv, markPrivate)
if markPrivate && !okNew && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
try store.save(newEv, span: .thisEvent, commit: true) try store.save(newEv, span: .thisEvent, commit: true)
created += 1 created += 1
@@ -1130,34 +1190,55 @@ struct ContentView: View {
} }
} }
log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)") log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)")
// Auto-delete placeholders whose source instance no longer exists // Auto-delete placeholders whose source instance no longer exists.
// Works even when no source events remain in the window, and
// also cleans up legacy mirrors that lack a mirror URL (by time).
if autoDeleteMissing { if autoDeleteMissing {
let validKeys: Set<String> = Set(baseBlocks.compactMap { blk in let trackByID = (mergeGapMin == 0)
if (mergeGapMin == 0), let sid = blk.srcEventID { // Build valid occurrence keys from current source blocks (when trackByID)
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 let validOccKeys: Set<String> = Set(baseBlocks.compactMap { blk in
return "\(sid)|\(occTS)" guard trackByID, let sid = blk.srcEventID else { return nil }
} let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
return nil return "\(sid)|\(occTS)"
}) })
if !validKeys.isEmpty { // Also build valid time keys for legacy/fallback cleanup
var removed = 0 let validTimeKeys: Set<String> = Set(baseBlocks.map { blk in
for (_, ev) in placeholdersByOccurrenceID { "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue } })
let parsed = parseMirrorURL(ev.url)
if let sid = parsed.srcEventID, let occ = parsed.occ { // Consider all known placeholders on target (URL or title-identified)
let k = "\(sid)|\(occ.timeIntervalSince1970)" // Deduplicate by eventIdentifier to avoid double-processing updated items
if !validKeys.contains(k) { var byID: [String: EKEvent] = [:]
if !writeEnabled { for tv in placeholdersByTime.values {
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
} else { if let eid = tv.eventIdentifier { byID[eid] = tv }
do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 } }
catch { log("Delete failed: \(error.localizedDescription)") }
} var removed = 0
} for ev in byID.values {
let parsed = parseMirrorURL(ev.url)
var shouldDelete = false
if let sid = parsed.srcEventID, let occ = parsed.occ {
let k = "\(sid)|\(occ.timeIntervalSince1970)"
// If key not present among current source instances, delete
if !validOccKeys.contains(k) { shouldDelete = true }
} else if trackByID {
// Legacy fallback: no URL -> compare exact time window membership
if let s = ev.startDate, let e = ev.endDate {
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)"
if !validTimeKeys.contains(tk) { shouldDelete = true }
}
}
if shouldDelete {
if !writeEnabled {
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
} else {
do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 }
catch { log("Delete failed: \(error.localizedDescription)") }
} }
} }
if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") }
} }
if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") }
} }
} }
} }
@@ -1169,6 +1250,7 @@ private struct SettingsPayload: Codable {
var mergeGapHours: Int var mergeGapHours: Int
var hideDetails: Bool var hideDetails: Bool
var copyDescription: Bool var copyDescription: Bool
var markPrivate: Bool
var mirrorAllDay: Bool var mirrorAllDay: Bool
var filterByWorkHours: Bool = false var filterByWorkHours: Bool = false
var workHoursStart: Int = 9 var workHoursStart: Int = 9
@@ -1195,6 +1277,7 @@ private struct SettingsPayload: Codable {
mergeGapHours: mergeGapHours, mergeGapHours: mergeGapHours,
hideDetails: hideDetails, hideDetails: hideDetails,
copyDescription: copyDescription, copyDescription: copyDescription,
markPrivate: markPrivate,
mirrorAllDay: mirrorAllDay, mirrorAllDay: mirrorAllDay,
filterByWorkHours: filterByWorkHours, filterByWorkHours: filterByWorkHours,
workHoursStart: workHoursStart, workHoursStart: workHoursStart,
@@ -1221,6 +1304,7 @@ private struct SettingsPayload: Codable {
hideDetails = s.hideDetails hideDetails = s.hideDetails
copyDescription = s.copyDescription copyDescription = s.copyDescription
mirrorAllDay = s.mirrorAllDay mirrorAllDay = s.mirrorAllDay
markPrivate = s.markPrivate
filterByWorkHours = s.filterByWorkHours filterByWorkHours = s.filterByWorkHours
workHoursStart = s.workHoursStart workHoursStart = s.workHoursStart
workHoursEnd = s.workHoursEnd workHoursEnd = s.workHoursEnd
@@ -1324,6 +1408,42 @@ private struct SettingsPayload: Codable {
return start < startMinutes || start >= endMinutes 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
}
// Try common KVC keys seen in some providers (best-effort, may be no-ops)
let kvPairs: [(String, Any)] = [
("private", true),
("isPrivate", true),
("privacy", 1),
("sensitivity", 1), // Exchange often: 1=personal, 2=private; varies
("classification", 1) // iCalendar CLASS: 1 might map to PRIVATE
]
for (key, val) in kvPairs {
do {
ev.setValue(val, forKey: key)
return true
} catch {
// ignore and try next
}
}
// Not supported; no-op
return false
}
private func shouldSkip(event: EKEvent, filters: [String]) -> Bool { private func shouldSkip(event: EKEvent, filters: [String]) -> Bool {
guard !filters.isEmpty else { return false } guard !filters.isEmpty else { return false }
let rawTitle = (event.title ?? "").lowercased() let rawTitle = (event.title ?? "").lowercased()

View File

@@ -3,9 +3,16 @@
All notable changes to BusyMirror will be documented in this file. All notable changes to BusyMirror will be documented in this file.
## [1.3.1] - 2025-10-13
- Fix: auto-delete of mirrored placeholders when the source is removed now works even if no source instances remain in the window. Also cleans legacy mirrors without URLs by matching exact times.
## [1.2.4] - 2025-10-10 ## [1.2.4] - 2025-10-10
- Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present. - Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present.
## [1.3.0] - 2025-10-10
- New: Mark Private option to mirror with prefix + real title and set event privacy on supported servers; available globally and per-route; persisted.
- Misc: calendar access fixes, concurrency annotations, acceptedonly filter, settings autosave/restore, Mirror Now enablement.
## [1.2.3] - 2025-10-10 ## [1.2.3] - 2025-10-10
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs. - Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs. - UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.

View File

@@ -2,13 +2,20 @@
BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices. BusyMirror mirrors meetings between your calendars so your availability stays consistent across accounts/devices.
## What it does (current checkpoint) ## What it does (current)
- Manual “Run” to mirror events across selected routes (Source → Targets). - Route-driven mirroring (multi-source): define Source → Targets routes and run them in one go.
- DRY-RUN mode shows what would happen. - Manual selection mirroring: pick a source and targets in the UI and run.
- Prefix-based tagging of mirrored events. - Two privacy modes:
- Cleanup of placeholders (with confirmation). - Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy").
- Loop/duplicate guards so mirrors dont replicate themselves. - Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort).
- Time window and merge-gap settings. - 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 ## Why
Use one calendars confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices). Use one calendars confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
@@ -27,6 +34,11 @@ Option B — Makefile (reproducible)
See `CHANGELOG.md` for notable changes. 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 ## Roadmap
See [ROADMAP.md](ROADMAP.md) See [ROADMAP.md](ROADMAP.md)

View File

@@ -1,17 +1,26 @@
# BusyMirror Roadmap # 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 ## Next
- Source filters (name patterns like `[HOLD]`, `#nomirror`) - Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
- Mirror only **Accepted** meetings (exclude tentative/declined) - Hint near "Mirror Now" indicating run mode (Routes vs Manual)
- Persistent settings (routes, window, prefix) - Better server-side privacy mapping (per-provider heuristics)
- Import/Export settings (.busymirror.json)
## Then ## Then
- iOS/iPadOS app (Run Now, Shortcuts, iCloud sync) - Signed/notarized binaries and release pipeline
- UI: route editor & clearer toggles - CLI quality: friendlier `--routes` parsing and help flag
- “Dry-run by default” preference - “Dry-run by default” preference
## Later ## Later
- Background monitoring (macOS) - Background monitoring (macOS)
- Smarter cleanup & conflict resolution - Smarter cleanup & conflict resolution
- iOS/iPadOS helper (Shortcuts integration)
- Profiles & MDM/Managed Config support - Profiles & MDM/Managed Config support

17
ReleaseNotes-1.3.0.md Normal file
View File

@@ -0,0 +1,17 @@
# BusyMirror 1.3.0 — 2025-10-10
New
- Mark Private option: mirror events with your prefix + real title while marking them Private on supported servers (e.g., Exchange). Coworkers see the time block but not the details.
- Per-route and global toggles for Mark Private; persists in settings and export/import.
Fixes & improvements
- More reliable calendar loading after permission grant (reinit EKEventStore).
- Concurrency: `@MainActor` on permission/refresh methods.
- Acceptedonly filter via current user attendee `participantStatus`.
- Settings autosave and restore (including source/target selections by IDs).
- Mirror Now enabled when calendars available; routes or manual selection used as appropriate.
Build
- `make build-release`
- `make package` → BusyMirror-1.3.0-macOS.zip and .sha256

6
ReleaseNotes-1.3.1.md Normal file
View File

@@ -0,0 +1,6 @@
BusyMirror 1.3.1 — Bugfix Release
- Fix: Auto-delete mirrored placeholders when the source event is removed.
- Triggers even if no source instances remain in the selected window.
- Also cleans legacy mirrors without mirror URLs by matching exact times.