Version update
This commit is contained in:
@@ -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
|
||||||
@@ -280,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)
|
||||||
@@ -314,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 event’s notes/description into the placeholder.")
|
.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)
|
overlapPicker(for: routeBinding)
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
Toggle("All-day", isOn: routeBinding.allDay)
|
Toggle("All-day", isOn: routeBinding.allDay)
|
||||||
@@ -425,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)
|
||||||
@@ -542,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 {
|
||||||
@@ -676,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 }
|
||||||
@@ -1016,6 +1052,7 @@ 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)")
|
||||||
|
_ = setEventPrivateIfSupported(existingByTime, markPrivate)
|
||||||
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)")
|
||||||
@@ -1058,6 +1095,7 @@ 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)")
|
||||||
|
_ = setEventPrivateIfSupported(existing, markPrivate)
|
||||||
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)")
|
||||||
@@ -1098,6 +1136,7 @@ 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
|
||||||
|
_ = setEventPrivateIfSupported(newEv, markPrivate)
|
||||||
do {
|
do {
|
||||||
try store.save(newEv, span: .thisEvent, commit: true)
|
try store.save(newEv, span: .thisEvent, commit: true)
|
||||||
created += 1
|
created += 1
|
||||||
@@ -1177,6 +1216,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
|
||||||
@@ -1203,6 +1243,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,
|
||||||
@@ -1229,6 +1270,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
|
||||||
@@ -1332,6 +1374,26 @@ 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
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
|
26
README.md
26
README.md
@@ -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 don’t 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 calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
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.
|
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)
|
||||||
|
|
||||||
|
21
ROADMAP.md
21
ROADMAP.md
@@ -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
|
||||||
|
Reference in New Issue
Block a user