Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
3ecf29f499 | |||
eb643ac74d | |||
df06564434 | |||
74b9949610 | |||
6676e62889 | |||
d1fbd4c81f | |||
6ef0feecc1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ ExportOptions.plist
|
||||
*.swp
|
||||
*.zip
|
||||
*.sha256
|
||||
dist/
|
||||
|
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
@@ -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<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 {
|
||||
@@ -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
|
||||
@@ -150,6 +178,14 @@ struct ContentView: View {
|
||||
@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
|
||||
|
||||
// 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 = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .none
|
||||
@@ -272,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)
|
||||
@@ -306,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)
|
||||
@@ -417,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)
|
||||
@@ -534,6 +576,7 @@ struct ContentView: View {
|
||||
mergeGapMin = mergeGapHours * 60
|
||||
overlapModeRaw = r.overlap.rawValue
|
||||
mirrorAllDay = r.allDay
|
||||
markPrivate = r.markPrivate
|
||||
}
|
||||
await runMirror()
|
||||
await MainActor.run {
|
||||
@@ -550,7 +593,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty)
|
||||
.disabled(!canRunMirrorNow)
|
||||
|
||||
Button("Cleanup Placeholders") {
|
||||
if writeEnabled {
|
||||
@@ -656,18 +699,34 @@ struct ContentView: View {
|
||||
tryRunCLIIfPresent()
|
||||
enforceNoSourceInTargets()
|
||||
}
|
||||
// Persist key settings whenever they change, to ensure restore between runs
|
||||
.onChange(of: daysBack) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: daysForward) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
|
||||
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
|
||||
.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 }
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: targetSelections) { _ in
|
||||
// If the new source is accidentally included, drop it
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: targetIDs) { _ in
|
||||
// If IDs contain the source’s ID, drop it
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: routes) { _ in
|
||||
saveSettingsToDefaults()
|
||||
@@ -947,6 +1006,7 @@ struct ContentView: View {
|
||||
var created = 0
|
||||
var skipped = 0
|
||||
var updated = 0
|
||||
var warnedPrivateUnsupported = false
|
||||
|
||||
// Cross-route loop guard: unique key generator for (source, occurrence/time, target)
|
||||
func guardKey(for blk: Block, targetID: String) -> String {
|
||||
@@ -993,6 +1053,11 @@ 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)")
|
||||
let okTime = setEventPrivateIfSupported(existingByTime, markPrivate)
|
||||
if markPrivate && !okTime && !warnedPrivateUnsupported {
|
||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
||||
warnedPrivateUnsupported = true
|
||||
}
|
||||
do {
|
||||
try store.save(existingByTime, span: .thisEvent, commit: true)
|
||||
log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)")
|
||||
@@ -1035,6 +1100,11 @@ struct ContentView: View {
|
||||
existing.notes = nil
|
||||
}
|
||||
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 {
|
||||
try store.save(existing, span: .thisEvent, commit: true)
|
||||
log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
||||
@@ -1075,6 +1145,11 @@ 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
|
||||
let okNew = setEventPrivateIfSupported(newEv, markPrivate)
|
||||
if markPrivate && !okNew && !warnedPrivateUnsupported {
|
||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
||||
warnedPrivateUnsupported = true
|
||||
}
|
||||
do {
|
||||
try store.save(newEv, span: .thisEvent, commit: true)
|
||||
created += 1
|
||||
@@ -1115,34 +1190,55 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let validKeys: Set<String> = Set(baseBlocks.compactMap { blk in
|
||||
if (mergeGapMin == 0), let sid = blk.srcEventID {
|
||||
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
||||
return "\(sid)|\(occTS)"
|
||||
}
|
||||
return nil
|
||||
let trackByID = (mergeGapMin == 0)
|
||||
// Build valid occurrence keys from current source blocks (when trackByID)
|
||||
let validOccKeys: Set<String> = Set(baseBlocks.compactMap { blk in
|
||||
guard trackByID, let sid = blk.srcEventID else { return nil }
|
||||
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
||||
return "\(sid)|\(occTS)"
|
||||
})
|
||||
if !validKeys.isEmpty {
|
||||
var removed = 0
|
||||
for (_, ev) in placeholdersByOccurrenceID {
|
||||
if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue }
|
||||
let parsed = parseMirrorURL(ev.url)
|
||||
if let sid = parsed.srcEventID, let occ = parsed.occ {
|
||||
let k = "\(sid)|\(occ.timeIntervalSince1970)"
|
||||
if !validKeys.contains(k) {
|
||||
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)") }
|
||||
}
|
||||
}
|
||||
// Also build valid time keys for legacy/fallback cleanup
|
||||
let validTimeKeys: Set<String> = Set(baseBlocks.map { blk in
|
||||
"\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
|
||||
})
|
||||
|
||||
// Consider all known placeholders on target (URL or title-identified)
|
||||
// Deduplicate by eventIdentifier to avoid double-processing updated items
|
||||
var byID: [String: EKEvent] = [:]
|
||||
for tv in placeholdersByTime.values {
|
||||
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
|
||||
if let eid = tv.eventIdentifier { byID[eid] = tv }
|
||||
}
|
||||
|
||||
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)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1154,6 +1250,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
|
||||
@@ -1165,6 +1262,9 @@ private struct SettingsPayload: Codable {
|
||||
var placeholderTitle: String
|
||||
var autoDeleteMissing: Bool
|
||||
var routes: [Route]
|
||||
// UI selections (optional for backward compatibility)
|
||||
var selectedSourceID: String? = nil
|
||||
var selectedTargetIDs: [String]? = nil
|
||||
// optional metadata
|
||||
var appVersion: String?
|
||||
var exportedAt: Date = Date()
|
||||
@@ -1177,6 +1277,7 @@ private struct SettingsPayload: Codable {
|
||||
mergeGapHours: mergeGapHours,
|
||||
hideDetails: hideDetails,
|
||||
copyDescription: copyDescription,
|
||||
markPrivate: markPrivate,
|
||||
mirrorAllDay: mirrorAllDay,
|
||||
filterByWorkHours: filterByWorkHours,
|
||||
workHoursStart: workHoursStart,
|
||||
@@ -1188,6 +1289,8 @@ private struct SettingsPayload: Codable {
|
||||
placeholderTitle: placeholderTitle,
|
||||
autoDeleteMissing: autoDeleteMissing,
|
||||
routes: routes,
|
||||
selectedSourceID: sourceID,
|
||||
selectedTargetIDs: Array(targetIDs).sorted(),
|
||||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||
exportedAt: Date()
|
||||
)
|
||||
@@ -1201,6 +1304,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
|
||||
@@ -1211,7 +1315,12 @@ private struct SettingsPayload: Codable {
|
||||
placeholderTitle = s.placeholderTitle
|
||||
autoDeleteMissing = s.autoDeleteMissing
|
||||
routes = s.routes
|
||||
// Restore UI selections if provided
|
||||
if let selSrc = s.selectedSourceID { sourceID = selSrc }
|
||||
if let selTgts = s.selectedTargetIDs { targetIDs = Set(selTgts) }
|
||||
clampWorkHours()
|
||||
// Rebuild indices from IDs after restoring selections
|
||||
rebuildSelectionsFromIDs()
|
||||
}
|
||||
|
||||
private func exportSettings() {
|
||||
@@ -1299,6 +1408,42 @@ 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
|
||||
}
|
||||
// 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 {
|
||||
guard !filters.isEmpty else { return false }
|
||||
let rawTitle = (event.title ?? "").lowercased()
|
||||
|
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
# Changelog
|
||||
|
||||
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
|
||||
- Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present.
|
||||
|
||||
## [1.3.0] - 2025-10-10
|
||||
- New: Mark Private option to mirror with prefix + real title and set event privacy on supported servers; available globally and per-route; persisted.
|
||||
- Misc: calendar access fixes, concurrency annotations, accepted‑only filter, settings autosave/restore, Mirror Now enablement.
|
||||
|
||||
## [1.2.3] - 2025-10-10
|
||||
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
|
||||
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.
|
||||
- Build: bump version to 1.2.3 (build 5).
|
||||
|
||||
## [1.2.1] - 2025-10-10
|
||||
- Fix: reinitialize EKEventStore after permission grant to avoid “Loaded 0 calendars” right after approval.
|
||||
- Fix: attendee status filter uses current user’s attendee `participantStatus == .accepted` instead of unavailable APIs.
|
||||
- Concurrency: mark `requestAccess()` and `reloadCalendars()` as `@MainActor` to satisfy strict concurrency checks.
|
||||
- Dev: add Makefile with `build-debug`, `build-release`, and `package` targets; produce versioned ZIP + SHA-256.
|
||||
|
||||
## [1.2.0] - 2024-09-29
|
||||
- Feature: multi-route mirroring, overlap modes, merge gaps, work hours filter, CLI support, export/import settings.
|
34
README.md
34
README.md
@@ -2,23 +2,43 @@
|
||||
|
||||
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).
|
||||
|
||||
## Build (macOS)
|
||||
Option A — Xcode
|
||||
1. Open `BusyMirror.xcodeproj` in Xcode.
|
||||
2. Select the BusyMirror scheme → My Mac.
|
||||
3. Product → Build.
|
||||
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
|
||||
|
||||
Option B — Makefile (reproducible)
|
||||
- Build Release: `make build-release`
|
||||
- Package ZIP: `make package` (creates `BusyMirror-<version>-macOS.zip` + `.sha256`)
|
||||
- Built app: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||||
|
||||
See `CHANGELOG.md` for notable changes.
|
||||
|
||||
## CLI (optional)
|
||||
- Run from Terminal with `--routes` to mirror without the UI. Example:
|
||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
|
||||
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, and cleanup.
|
||||
|
||||
## Roadmap
|
||||
See [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
|
21
ROADMAP.md
21
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
|
||||
|
24
ReleaseNotes-1.2.3.md
Normal file
24
ReleaseNotes-1.2.3.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# BusyMirror 1.2.3 — 2025-10-10
|
||||
|
||||
This release focuses on reliable settings persistence and quality-of-life fixes from the 1.2.1 hotfix.
|
||||
|
||||
Highlights
|
||||
- Settings persist between runs: autosave key options on change; restore on launch.
|
||||
- Source/Target selection is remembered using calendar IDs and rehydrated into UI indices.
|
||||
|
||||
Fixes and improvements
|
||||
- Save on change for: days back/forward, default merge gap, privacy/copy notes, all-day, accepted-only, overlap mode, title/placeholder prefixes, auto-delete.
|
||||
- Restore saved `selectedSourceID` and `selectedTargetIDs` and rebuild index selections.
|
||||
- Keep backward compatibility with older saved payloads.
|
||||
- Version bump to 1.2.3 (build 5).
|
||||
|
||||
Included from 1.2.1
|
||||
- Reinitialize `EKEventStore` after permission grant to avoid “Loaded 0 calendars”.
|
||||
- Use attendee `participantStatus == .accepted` for accepted-only filter.
|
||||
- Mark `requestAccess()` and `reloadCalendars()` as `@MainActor`.
|
||||
- Makefile for reproducible builds and packaging.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.3-macOS.zip and .sha256
|
||||
|
11
ReleaseNotes-1.2.4.md
Normal file
11
ReleaseNotes-1.2.4.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# BusyMirror 1.2.4 — 2025-10-10
|
||||
|
||||
Bugfix release improving route-driven mirroring.
|
||||
|
||||
Fixes
|
||||
- Mirror Now is enabled when routes are defined, even if nothing is checked in the main window. This allows fully route-driven runs without requiring a temporary manual selection.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.4-macOS.zip and .sha256
|
||||
|
17
ReleaseNotes-1.3.0.md
Normal file
17
ReleaseNotes-1.3.0.md
Normal 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). Co‑workers see the time block but not the details.
|
||||
- Per-route and global toggles for Mark Private; persists in settings and export/import.
|
||||
|
||||
Fixes & improvements
|
||||
- More reliable calendar loading after permission grant (reinit EKEventStore).
|
||||
- Concurrency: `@MainActor` on permission/refresh methods.
|
||||
- Accepted‑only filter via current user attendee `participantStatus`.
|
||||
- Settings autosave and restore (including source/target selections by IDs).
|
||||
- Mirror Now enabled when calendars available; routes or manual selection used as appropriate.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.3.0-macOS.zip and .sha256
|
||||
|
6
ReleaseNotes-1.3.1.md
Normal file
6
ReleaseNotes-1.3.1.md
Normal 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.
|
||||
|
Reference in New Issue
Block a user