3 Commits

Author SHA1 Message Date
2912d2f52a Release 1.3.7 2026-03-24 10:36:44 +01:00
a838e021a1 Docs: refresh README and roadmap 2026-03-13 09:12:19 +01:00
f81403745c Release 1.3.6 2026-03-13 09:08:31 +01:00
15 changed files with 628 additions and 218 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 = 12; CURRENT_PROJECT_VERSION = 15;
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.4; MARKETING_VERSION = 1.3.7;
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 = 12; CURRENT_PROJECT_VERSION = 15;
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.4; MARKETING_VERSION = 1.3.7;
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,55 +1,15 @@
{ {
"images" : [ "images" : [
{ { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
"idiom" : "mac", { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
"scale" : "1x", { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
"size" : "16x16" { "filename" : "icon_64x64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
}, { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
{ { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
"idiom" : "mac", { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
"scale" : "2x", { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
"size" : "16x16" { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
}, { "filename" : "icon_1024x1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
], ],
"info" : { "info" : {
"author" : "xcode", "author" : "xcode",

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -19,6 +19,8 @@ private enum AppLogStore {
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false) static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false) private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false)
static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false)
static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false)
private static let timestampFormatter: ISO8601DateFormatter = { private static let timestampFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter() let f = ISO8601DateFormatter()
@@ -58,6 +60,19 @@ enum OverlapMode: String, CaseIterable, Identifiable, Codable {
var id: String { rawValue } var id: String { rawValue }
} }
enum ScheduleMode: String, CaseIterable, Identifiable {
case hourly, daily, weekdays
var id: String { rawValue }
var title: String {
switch self {
case .hourly: return "Hourly"
case .daily: return "Daily"
case .weekdays: return "Weekdays"
}
}
}
// Calendar label helper to disambiguate identical names // Calendar label helper to disambiguate identical names
private func calLabel(_ cal: EKCalendar) -> String { private func calLabel(_ cal: EKCalendar) -> String {
let src = cal.source.title let src = cal.source.title
@@ -94,8 +109,8 @@ func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
var out: [Block] = [] var out: [Block] = []
for b in blocks { for b in blocks {
let key: String let key: String
if trackByID, let sid = b.srcEventID { if trackByID, let sid = b.srcStableID {
let occ = b.occurrence?.timeIntervalSince1970 ?? b.start.timeIntervalSince1970 let occ = b.occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
key = "id|\(sid)|\(occ)" key = "id|\(sid)|\(occ)"
} else { } else {
key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)"
@@ -105,8 +120,62 @@ func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
return out return out
} }
// Parse mirror URL: mirror://<tgtID>|<srcCalID>|<srcEventID>|<occTS>|<startTS>|<endTS> private let mirrorURLAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
private func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID: String?, srcEventID: String?, occ: Date?, start: Date?, end: Date?) {
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) } guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil, nil, nil) }
let body = abs.dropFirst("mirror://".count) let body = abs.dropFirst("mirror://".count)
let parts = body.split(separator: "|") let parts = body.split(separator: "|")
@@ -116,10 +185,14 @@ private func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID:
var occDate: Date? = nil var occDate: Date? = nil
var sDate: Date? = nil var sDate: Date? = nil
var eDate: Date? = nil var eDate: Date? = nil
if parts.count >= 1 { targetCalID = String(parts[0]) } if parts.count >= 1 { targetCalID = mirrorURLComponentDecode(parts[0]) }
if parts.count >= 2 { sourceCalID = String(parts[1]) } if parts.count >= 2 { sourceCalID = mirrorURLComponentDecode(parts[1]) }
if parts.count >= 3 { srcID = String(parts[2]) } if parts.count >= 3 {
let decoded = mirrorURLComponentDecode(parts[2])
srcID = decoded.isEmpty ? nil : decoded
}
if parts.count >= 4, if parts.count >= 4,
String(parts[3]) != "-",
let ts = TimeInterval(String(parts[3])) { let ts = TimeInterval(String(parts[3])) {
occDate = Date(timeIntervalSince1970: ts) occDate = Date(timeIntervalSince1970: ts)
} }
@@ -144,12 +217,35 @@ private func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -
struct Block: Hashable { struct Block: Hashable {
let start: Date let start: Date
let end: Date let end: Date
let srcEventID: String? // for reschedule tracking let srcStableID: String? // stable source item ID for reschedule tracking
let label: String? // source title (for dry-run / non-private) let label: String? // source title (for dry-run / non-private)
let notes: String? // source notes (for optional copy) let notes: String? // source notes (for optional copy)
let occurrence: Date? // occurrenceDate for recurring instances let occurrence: Date? // occurrenceDate for recurring instances
} }
private struct MirrorRecord: Hashable, Codable {
var targetCalendarID: String
var sourceCalendarID: String
var sourceStableID: String
var occurrenceTimestamp: TimeInterval?
var targetEventIdentifier: String?
var lastKnownStartTimestamp: TimeInterval
var lastKnownEndTimestamp: TimeInterval
var updatedAt: Date = Date()
var sourceKey: String {
sourceOccurrenceKey(
sourceCalID: sourceCalendarID,
sourceStableID: sourceStableID,
occurrence: occurrenceTimestamp.map { Date(timeIntervalSince1970: $0) }
)
}
var timeKey: String {
"\(lastKnownStartTimestamp)|\(lastKnownEndTimestamp)"
}
}
struct Route: Identifiable, Hashable, Codable { struct Route: Identifiable, Hashable, Codable {
let id = UUID() let id = UUID()
var sourceID: String var sourceID: String
@@ -209,6 +305,11 @@ struct ContentView: View {
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9 @AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17 @AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("scheduleMode") private var scheduleModeRaw: String = ScheduleMode.weekdays.rawValue
@AppStorage("scheduleHour") private var scheduleHour: Int = 8
@AppStorage("scheduleMinute") private var scheduleMinute: Int = 0
@AppStorage("scheduleWeekdaysOnly") private var scheduleWeekdaysOnly: Bool = true
@AppStorage("scheduleIntervalHours") private var scheduleIntervalHours: Int = 1
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = "" @AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = "" @AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false @AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@@ -228,6 +329,7 @@ struct ContentView: View {
@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
private let mirrorIndexDefaultsKey = "mirror-index.v1"
// Mirrors can run either by manual selection (source + at least one target) // Mirrors can run either by manual selection (source + at least one target)
// or using predefined routes. This derived flag controls the Mirror Now button. // or using predefined routes. This derived flag controls the Mirror Now button.
@@ -237,6 +339,25 @@ struct ContentView: View {
return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets) return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets)
} }
private func loadMirrorIndex() -> [String: MirrorRecord] {
guard let data = UserDefaults.standard.data(forKey: mirrorIndexDefaultsKey) else { return [:] }
do {
return try JSONDecoder().decode([String: MirrorRecord].self, from: data)
} catch {
log("✗ Failed to load mirror index: \(error.localizedDescription)")
return [:]
}
}
private func saveMirrorIndex(_ index: [String: MirrorRecord]) {
do {
let data = try JSONEncoder().encode(index)
UserDefaults.standard.set(data, forKey: mirrorIndexDefaultsKey)
} catch {
log("✗ Failed to save mirror index: \(error.localizedDescription)")
}
}
private static let intFormatter: NumberFormatter = { private static let intFormatter: NumberFormatter = {
let f = NumberFormatter() let f = NumberFormatter()
f.numberStyle = .none f.numberStyle = .none
@@ -253,6 +374,132 @@ struct ContentView: View {
return f return f
}() }()
private static let smallIntFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .none
f.minimum = 1
f.maximum = 24
return f
}()
private var scheduleMode: ScheduleMode {
get { ScheduleMode(rawValue: scheduleModeRaw) ?? (scheduleWeekdaysOnly ? .weekdays : .daily) }
nonmutating set { scheduleModeRaw = newValue.rawValue }
}
private var launchAgentLabel: String { "com.cqrenet.BusyMirror.saved-routes" }
private var launchAgentURL: URL {
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
}
private var hasInstalledSchedule: Bool {
FileManager.default.fileExists(atPath: launchAgentURL.path)
}
private var scheduleSummary: String {
switch scheduleMode {
case .hourly:
return "every \(scheduleIntervalHours) hour" + (scheduleIntervalHours == 1 ? "" : "s")
case .daily:
return String(format: "%02d:%02d daily", scheduleHour, scheduleMinute)
case .weekdays:
return String(format: "%02d:%02d weekdays", scheduleHour, scheduleMinute)
}
}
private func clampScheduleTime() {
let nextHour = min(max(scheduleHour, 0), 23)
if nextHour != scheduleHour { scheduleHour = nextHour }
let nextMinute = min(max(scheduleMinute, 0), 59)
if nextMinute != scheduleMinute { scheduleMinute = nextMinute }
let nextInterval = min(max(scheduleIntervalHours, 1), 24)
if nextInterval != scheduleIntervalHours { scheduleIntervalHours = nextInterval }
}
private func launchAgentScheduleProperties() -> [String: Any] {
switch scheduleMode {
case .hourly:
return ["StartInterval": scheduleIntervalHours * 3600]
case .daily:
return ["StartCalendarInterval": ["Hour": scheduleHour, "Minute": scheduleMinute]]
case .weekdays:
let intervals: [[String: Int]] = (1...5).map { weekday in
["Hour": scheduleHour, "Minute": scheduleMinute, "Weekday": weekday]
}
return ["StartCalendarInterval": intervals]
}
}
private func launchCtl(_ arguments: [String], allowFailure: Bool = false) throws -> String {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
proc.arguments = arguments
let output = Pipe()
proc.standardOutput = output
proc.standardError = output
try proc.run()
proc.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus != 0 && !allowFailure {
throw NSError(
domain: "BusyMirrorLaunchCtl",
code: Int(proc.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: text.isEmpty ? "launchctl failed (\(proc.terminationStatus))" : text]
)
}
return text
}
private func installSchedule() {
guard !routes.isEmpty else {
log("Cannot install schedule: no saved routes.")
return
}
clampScheduleTime()
do {
let fm = FileManager.default
try fm.createDirectory(at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try fm.createDirectory(at: AppLogStore.logDirectoryURL, withIntermediateDirectories: true)
let executablePath = Bundle.main.executableURL?.path
?? Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/BusyMirror").path
let plist: [String: Any] = [
"Label": launchAgentLabel,
"ProgramArguments": [executablePath, "--run-saved-routes", "--write", "1", "--exit"],
"RunAtLoad": false,
"StandardOutPath": AppLogStore.launchdStdoutURL.path,
"StandardErrorPath": AppLogStore.launchdStderrURL.path,
"WorkingDirectory": NSHomeDirectory()
].merging(launchAgentScheduleProperties()) { _, new in new }
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: launchAgentURL, options: .atomic)
let domain = "gui/\(getuid())"
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
_ = try launchCtl(["bootstrap", domain, launchAgentURL.path])
log("Installed schedule at \(scheduleSummary). LaunchAgent: \(launchAgentURL.path)")
} catch {
log("Failed to install schedule: \(error.localizedDescription)")
}
}
private func removeSchedule() {
do {
let domain = "gui/\(getuid())"
_ = try? launchCtl(["bootout", domain, launchAgentURL.path], allowFailure: true)
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
try FileManager.default.removeItem(at: launchAgentURL)
}
log("Removed schedule: \(launchAgentURL.path)")
} catch {
log("Failed to remove schedule: \(error.localizedDescription)")
}
}
// Deterministic ordering to keep indices stable across runs // Deterministic ordering to keep indices stable across runs
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] { private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
return cals.sorted { a, b in return cals.sorted { a, b in
@@ -947,6 +1194,74 @@ struct ContentView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
} }
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Scheduled runs")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Mode", selection: Binding(
get: { scheduleMode },
set: { newValue in
scheduleMode = newValue
scheduleWeekdaysOnly = (newValue == .weekdays)
}
)) {
ForEach(ScheduleMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(isRunning)
Spacer(minLength: 0)
}
if scheduleMode == .hourly {
HStack(spacing: 8) {
Text("Every")
TextField("1", value: $scheduleIntervalHours, formatter: Self.smallIntFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(scheduleIntervalHours == 1 ? "hour" : "hours")
Spacer(minLength: 0)
}
} else {
HStack(spacing: 8) {
Text("Time")
TextField("8", value: $scheduleHour, formatter: Self.hourFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Text(":")
TextField("0", value: $scheduleMinute, formatter: Self.intFormatter)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.disabled(isRunning)
Spacer(minLength: 0)
}
}
Text("Creates a LaunchAgent that runs the installed app with saved routes in write mode.")
.foregroundStyle(.secondary)
.font(.footnote)
Text(hasInstalledSchedule ? "Installed: \(scheduleSummary)" : "Not installed")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Install Schedule") { installSchedule() }
.disabled(isRunning || routes.isEmpty)
Button("Remove Schedule") { removeSchedule() }
.disabled(isRunning || !hasInstalledSchedule)
Button("Reveal LaunchAgent") {
NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL])
}
.disabled(!hasInstalledSchedule)
Spacer(minLength: 0)
}
}
.onChange(of: scheduleHour) { _ in clampScheduleTime() }
.onChange(of: scheduleMinute) { _ in clampScheduleTime() }
.onChange(of: scheduleIntervalHours) { _ in clampScheduleTime() }
HStack(spacing: 10) { HStack(spacing: 10) {
Button("Cleanup Placeholders") { Button("Cleanup Placeholders") {
if writeEnabled { if writeEnabled {
@@ -1446,8 +1761,8 @@ struct ContentView: View {
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 // 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 = ev.eventIdentifier // stable across launches unless event is deleted let srcID = stableSourceIdentifier(for: ev)
srcBlocks.append(Block(start: s, end: e, srcEventID: 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))
} }
if skippedMirrors > 0 { if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
@@ -1470,6 +1785,13 @@ struct ContentView: View {
// Merge for Flights or similar // Merge for Flights or similar
let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks
let trackByID = (mergeGapMin == 0) let trackByID = (mergeGapMin == 0)
var mirrorIndex = loadMirrorIndex()
var mirrorIndexChanged = false
func sourceKey(for blk: Block) -> String? {
guard trackByID, let sid = blk.srcStableID else { return nil }
return sourceOccurrenceKey(sourceCalID: srcCal.calendarIdentifier, sourceStableID: sid, occurrence: blk.occurrence)
}
for tgt in targets { for tgt in targets {
let tgtName = calLabel(tgt) let tgtName = calLabel(tgt)
@@ -1490,23 +1812,43 @@ struct ContentView: View {
var placeholderSet = Set<String>() var placeholderSet = Set<String>()
var occupied: [Block] = [] var occupied: [Block] = []
var placeholdersByOccurrenceID: [String: EKEvent] = [:] var placeholdersBySourceKey: [String: EKEvent] = [:]
var placeholdersByTime: [String: EKEvent] = [:] var placeholdersByTime: [String: EKEvent] = [:]
var targetEventsByIdentifier: [String: EKEvent] = [:]
for tv in tgtEvents { for tv in tgtEvents {
// Defensive: should already be filtered, but double-check target identity
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
if let eid = tv.eventIdentifier {
targetEventsByIdentifier[eid] = tv
}
if let ts = tv.startDate, let te = tv.endDate { if let ts = tv.startDate, let te = tv.endDate {
let timeKey = "\(ts.timeIntervalSince1970)|\(te.timeIntervalSince1970)" let timeKey = mirrorTimeKey(start: ts, end: te)
if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) { if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) {
placeholderSet.insert(timeKey) placeholderSet.insert(timeKey)
placeholdersByTime[timeKey] = tv placeholdersByTime[timeKey] = tv
let parsed = parseMirrorURL(tv.url) let parsed = parseMirrorURL(tv.url)
if let sid = parsed.srcEventID, let occ = parsed.occ { if let sourceCalID = parsed.sourceCalID,
let key = "\(sid)|\(occ.timeIntervalSince1970)" let sourceStableID = parsed.sourceStableID,
placeholdersByOccurrenceID[key] = tv !sourceCalID.isEmpty,
!sourceStableID.isEmpty {
let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ)
placeholdersBySourceKey[key] = tv
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
let record = MirrorRecord(
targetCalendarID: tgt.calendarIdentifier,
sourceCalendarID: sourceCalID,
sourceStableID: sourceStableID,
occurrenceTimestamp: parsed.occ?.timeIntervalSince1970,
targetEventIdentifier: tv.eventIdentifier,
lastKnownStartTimestamp: ts.timeIntervalSince1970,
lastKnownEndTimestamp: te.timeIntervalSince1970
)
if mirrorIndex[recordKey] != record {
mirrorIndex[recordKey] = record
mirrorIndexChanged = true
}
} }
} }
occupied.append(Block(start: ts, end: te, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil))
} }
} }
occupied = coalesce(occupied) occupied = coalesce(occupied)
@@ -1516,146 +1858,185 @@ struct ContentView: View {
var updated = 0 var updated = 0
var warnedPrivateUnsupported = false var warnedPrivateUnsupported = false
// 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 {
if trackByID, let sid = blk.srcEventID { if let key = sourceKey(for: blk) {
let occ = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 return "\(key)|\(targetID)"
return "\(srcCal.calendarIdentifier)|\(sid)|\(occ)|\(targetID)" }
} else { return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)"
return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)" }
func desiredNotes(for blk: Block) -> String? {
(!hideDetails && copyDescription) ? blk.notes : nil
}
func upsertMirrorRecord(for blk: Block, event: EKEvent) {
guard let sid = blk.srcStableID,
let key = sourceKey(for: blk),
let startDate = event.startDate,
let endDate = event.endDate else { return }
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
let record = MirrorRecord(
targetCalendarID: tgt.calendarIdentifier,
sourceCalendarID: srcCal.calendarIdentifier,
sourceStableID: sid,
occurrenceTimestamp: blk.occurrence?.timeIntervalSince1970,
targetEventIdentifier: event.eventIdentifier,
lastKnownStartTimestamp: startDate.timeIntervalSince1970,
lastKnownEndTimestamp: endDate.timeIntervalSince1970
)
if mirrorIndex[recordKey] != record {
mirrorIndex[recordKey] = record
mirrorIndexChanged = true
} }
} }
func removeMirrorRecord(for key: String) {
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
if mirrorIndex.removeValue(forKey: recordKey) != nil {
mirrorIndexChanged = true
}
}
func resolveMappedEvent(for record: MirrorRecord) -> EKEvent? {
if let eid = record.targetEventIdentifier,
let event = targetEventsByIdentifier[eid],
event.calendar.calendarIdentifier == tgt.calendarIdentifier {
return event
}
return placeholdersByTime[record.timeKey]
}
func rememberMirrorEvent(_ event: EKEvent, for blk: Block) {
if let startDate = event.startDate, let endDate = event.endDate {
let timeKey = mirrorTimeKey(start: startDate, end: endDate)
placeholderSet.insert(timeKey)
placeholdersByTime[timeKey] = event
}
if let key = sourceKey(for: blk) {
placeholdersBySourceKey[key] = event
}
if let eid = event.eventIdentifier {
targetEventsByIdentifier[eid] = event
}
upsertMirrorRecord(for: blk, event: event)
}
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
let curS = existing.startDate ?? blk.start
let curE = existing.endDate ?? blk.end
if abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN * 60 { return true }
if abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN * 60 { return true }
if (existing.title ?? "") != displayTitle { return true }
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
if existing.isAllDay { return true }
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
return false
}
func createOrUpdateIfNeeded(_ blk: Block) async { func createOrUpdateIfNeeded(_ blk: Block) async {
// Cross-route loop guard: skip if this (source occurrence -> target) was handled earlier this click
let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier) let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier)
if sessionGuard.contains(gKey) { if sessionGuard.contains(gKey) {
skipped += 1 skipped += 1
log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
return return
} }
// Privacy-aware title/notes (strip our prefix so it never doubles up)
let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix) let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix)
let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle) let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle)
let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : "\(baseSourceTitle)") let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : "\(baseSourceTitle)")
let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle
let notes = desiredNotes(for: blk)
let desiredURL = buildMirrorURL(
targetCalID: tgt.calendarIdentifier,
sourceCalID: srcCal.calendarIdentifier,
sourceStableID: blk.srcStableID,
occurrence: blk.occurrence,
start: blk.start,
end: blk.end
)
let exactTimeKey = mirrorTimeKey(start: blk.start, end: blk.end)
let blkSourceKey = sourceKey(for: blk)
// Fallback 0: if an existing mirrored event has the exact same time, update it func updateExisting(_ existing: EKEvent, byTime: Bool) async {
let exactTimeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" let curS = existing.startDate ?? blk.start
if let existingByTime = placeholdersByTime[exactTimeKey] { let curE = existing.endDate ?? blk.end
rememberMirrorEvent(existing, for: blk)
if !needsUpdate(existing: existing, blk: blk, displayTitle: displayTitle, desiredNotes: notes, desiredURL: desiredURL) {
sessionGuard.insert(gKey)
skipped += 1
return
}
let byTimeSuffix = byTime ? " (by time)" : ""
if !writeEnabled { if !writeEnabled {
sessionGuard.insert(gKey) sessionGuard.insert(gKey)
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end) [title: \(displayTitle)]") log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
updated += 1 updated += 1
return return
} }
existingByTime.title = displayTitle existing.title = displayTitle
existingByTime.startDate = blk.start existing.startDate = blk.start
existingByTime.endDate = blk.end existing.endDate = blk.end
existingByTime.isAllDay = false existing.isAllDay = false
if !hideDetails && copyDescription { existing.notes = notes
existingByTime.notes = blk.notes existing.url = desiredURL
} else { let ok = setEventPrivateIfSupported(existing, markPrivate)
existingByTime.notes = nil if markPrivate && !ok && !warnedPrivateUnsupported {
}
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)]") log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true warnedPrivateUnsupported = true
} }
do { do {
try await MainActor.run { try await MainActor.run {
try store.save(existingByTime, span: .thisEvent, commit: true) try store.save(existing, span: .thisEvent, commit: true)
} }
log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
placeholderSet.insert(exactTimeKey) rememberMirrorEvent(existing, for: blk)
placeholdersByTime[exactTimeKey] = existingByTime occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)])
sessionGuard.insert(gKey) sessionGuard.insert(gKey)
updated += 1 updated += 1
} catch { } catch {
log("Update failed: \(error.localizedDescription)") log("Update failed: \(error.localizedDescription)")
} }
}
if let blkSourceKey,
let record = mirrorIndex[mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: blkSourceKey)],
let existing = resolveMappedEvent(for: record) {
await updateExisting(existing, byTime: false)
return return
} }
// If we can track by source event ID and we have one, prefer updating the existing placeholder if let blkSourceKey, let existing = placeholdersBySourceKey[blkSourceKey] {
if trackByID, let sid = blk.srcEventID { await updateExisting(existing, byTime: false)
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 return
let lookupKey = "\(sid)|\(occTS)"
if let existing = placeholdersByOccurrenceID[lookupKey] {
let curS = existing.startDate ?? blk.start
let curE = existing.endDate ?? blk.end
let needsUpdate = abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN*60 || abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN*60
if !needsUpdate {
skipped += 1
return
}
if !writeEnabled {
sessionGuard.insert(gKey)
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
updated += 1
return
}
existing.startDate = blk.start
existing.endDate = blk.end
existing.title = displayTitle
existing.isAllDay = false
if !hideDetails && copyDescription {
existing.notes = blk.notes
} else {
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 await MainActor.run {
try store.save(existing, span: .thisEvent, commit: true)
}
log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
placeholderSet.insert("\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
let timeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
placeholdersByTime[timeKey] = existing
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)])
placeholdersByOccurrenceID[lookupKey] = existing
sessionGuard.insert(gKey)
updated += 1
} catch {
log("Update failed: \(error.localizedDescription)")
}
return
}
} }
// No existing placeholder for this block; dedupe by exact times if let existingByTime = placeholdersByTime[exactTimeKey] {
let k = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" await updateExisting(existingByTime, byTime: true)
if placeholderSet.contains(k) { skipped += 1; return } return
}
if placeholderSet.contains(exactTimeKey) {
skipped += 1
return
}
if !writeEnabled { if !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
} }
// Invariant: never write to the source calendar by mistake guard tgt.calendarIdentifier != srcCal.calendarIdentifier else {
guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { skipped += 1; log("- SKIP invariant: target is source [\(srcName)]"); return } skipped += 1
log("- SKIP invariant: target is source [\(srcName)]")
return
}
let newEv = EKEvent(eventStore: store) let newEv = EKEvent(eventStore: store)
newEv.calendar = tgt newEv.calendar = tgt
newEv.title = displayTitle newEv.title = displayTitle
newEv.startDate = blk.start newEv.startDate = blk.start
newEv.endDate = blk.end newEv.endDate = blk.end
newEv.isAllDay = false newEv.isAllDay = false
if !hideDetails && copyDescription { newEv.notes = notes
newEv.notes = blk.notes newEv.url = desiredURL
}
let sid = blk.srcEventID ?? ""
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 newEv.availability = .busy
let okNew = setEventPrivateIfSupported(newEv, markPrivate) let okNew = setEventPrivateIfSupported(newEv, markPrivate)
if markPrivate && !okNew && !warnedPrivateUnsupported { if markPrivate && !okNew && !warnedPrivateUnsupported {
@@ -1668,14 +2049,8 @@ struct ContentView: View {
} }
created += 1 created += 1
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
placeholderSet.insert(k) rememberMirrorEvent(newEv, for: blk)
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
if !sid.isEmpty {
let key = "\(sid)|\(occTS)"
placeholdersByOccurrenceID[key] = newEv
}
let timeKey = k
placeholdersByTime[timeKey] = newEv
sessionGuard.insert(gKey) sessionGuard.insert(gKey)
} catch { } catch {
log("Save failed: \(error.localizedDescription)") log("Save failed: \(error.localizedDescription)")
@@ -1704,25 +2079,11 @@ 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.
// 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 trackByID = (mergeGapMin == 0) let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) })
let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) })
let isMultiRouteRun = !routes.isEmpty let isMultiRouteRun = !routes.isEmpty
// 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)"
})
// 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] = [:] var byID: [String: EKEvent] = [:]
for tv in placeholdersByTime.values { for tv in placeholdersByTime.values {
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
@@ -1732,35 +2093,77 @@ struct ContentView: View {
var removed = 0 var removed = 0
var skippedOtherSource = 0 var skippedOtherSource = 0
var skippedLegacyNoURL = 0 var skippedLegacyNoURL = 0
var handledEventIDs = Set<String>()
let staleMirrorRecords = mirrorIndex.filter {
$0.value.targetCalendarID == tgt.calendarIdentifier &&
$0.value.sourceCalendarID == srcCal.calendarIdentifier &&
!activeSourceKeys.contains($0.value.sourceKey)
}
for (recordKey, record) in staleMirrorRecords {
let candidate = resolveMappedEvent(for: record)
if let candidate {
if !writeEnabled {
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
} else {
do {
try await MainActor.run {
try store.remove(candidate, span: .thisEvent, commit: true)
}
removed += 1
} catch {
log("Delete failed: \(error.localizedDescription)")
}
}
if let eid = candidate.eventIdentifier {
handledEventIDs.insert(eid)
}
}
if writeEnabled || candidate == nil {
if mirrorIndex.removeValue(forKey: recordKey) != nil {
mirrorIndexChanged = true
}
}
}
for ev in byID.values { for ev in byID.values {
if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) {
continue
}
let parsed = parseMirrorURL(ev.url) let parsed = parseMirrorURL(ev.url)
var shouldDelete = false var shouldDelete = false
var parsedSourceKey: String? = nil
if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty { if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty {
// Only clean up placeholders that belong to this route's source calendar.
if sourceCalID != srcCal.calendarIdentifier { if sourceCalID != srcCal.calendarIdentifier {
skippedOtherSource += 1 skippedOtherSource += 1
continue continue
} }
if let sid = parsed.srcEventID, let occ = parsed.occ { if let sourceStableID = parsed.sourceStableID, !sourceStableID.isEmpty {
let k = "\(sid)|\(occ.timeIntervalSince1970)" let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ)
// If key not present among current source instances, delete parsedSourceKey = key
if !validOccKeys.contains(k) { shouldDelete = true } if !activeSourceKeys.contains(key) { shouldDelete = true }
} else if trackByID { } else if trackByID,
// Legacy-ish URL payload for this source: compare exact time window membership. let s = ev.startDate,
if let s = ev.startDate, let e = ev.endDate { let e = ev.endDate,
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)" !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) {
if !validTimeKeys.contains(tk) { shouldDelete = true } shouldDelete = true
}
} }
} else if trackByID && !isMultiRouteRun { } else if trackByID && !isMultiRouteRun {
// Legacy fallback without URL source only in single-source runs. if let s = ev.startDate,
if let s = ev.startDate, let e = ev.endDate { let e = ev.endDate,
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)" !validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) {
if !validTimeKeys.contains(tk) { shouldDelete = true } shouldDelete = true
} }
} else if trackByID && isMultiRouteRun { } else if trackByID && isMultiRouteRun {
// In multi-route runs, avoid deleting URL-less placeholders that may belong to another source. let hasMapping = mirrorIndex.values.contains {
skippedLegacyNoURL += 1 $0.targetCalendarID == tgt.calendarIdentifier &&
$0.sourceCalendarID == srcCal.calendarIdentifier &&
$0.targetEventIdentifier == ev.eventIdentifier
}
if !hasMapping {
skippedLegacyNoURL += 1
}
continue continue
} }
if shouldDelete { if shouldDelete {
@@ -1772,8 +2175,12 @@ struct ContentView: View {
try store.remove(ev, span: .thisEvent, commit: true) try store.remove(ev, span: .thisEvent, commit: true)
} }
removed += 1 removed += 1
} catch {
log("Delete failed: \(error.localizedDescription)")
} }
catch { log("Delete failed: \(error.localizedDescription)") } }
if let key = parsedSourceKey {
removeMirrorRecord(for: key)
} }
} }
} }
@@ -1782,10 +2189,13 @@ struct ContentView: View {
log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)") log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)")
} }
if skippedLegacyNoURL > 0 { if skippedLegacyNoURL > 0 {
log("- INFO cleanup skipped \(skippedLegacyNoURL) legacy placeholders without source URL on \(tgtName)") log("- INFO cleanup skipped \(skippedLegacyNoURL) unmanaged legacy placeholders without source metadata on \(tgtName)")
} }
} }
} }
if mirrorIndexChanged {
saveMirrorIndex(mirrorIndex)
}
} }
// MARK: - Export / Import Settings // MARK: - Export / Import Settings
@@ -2071,14 +2481,14 @@ private struct SettingsPayload: Codable {
guard !blocks.isEmpty else { return [] } guard !blocks.isEmpty else { return [] }
let sorted = blocks.sorted { $0.start < $1.start } let sorted = blocks.sorted { $0.start < $1.start }
var out: [Block] = [] var out: [Block] = []
var cur = Block(start: sorted[0].start, end: sorted[0].end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) var cur = Block(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
for b in sorted.dropFirst() { for b in sorted.dropFirst() {
let gap = b.start.timeIntervalSince(cur.end) / 60.0 let gap = b.start.timeIntervalSince(cur.end) / 60.0
if gap <= Double(gapMinutes) { if gap <= Double(gapMinutes) {
if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) } if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) }
} else { } else {
out.append(cur) out.append(cur)
cur = Block(start: b.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
} }
} }
out.append(cur) out.append(cur)
@@ -2093,22 +2503,22 @@ private struct SettingsPayload: Codable {
return false return false
} }
func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] { func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] {
if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] } if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
var segs: [Block] = [] var segs: [Block] = []
for s in mergedSegs where s.end > block.start && s.start < block.end { for s in mergedSegs where s.end > block.start && s.start < block.end {
let ss = max(s.start, block.start) let ss = max(s.start, block.start)
let ee = min(s.end, block.end) let ee = min(s.end, block.end)
if ee > ss { segs.append(Block(start: ss, end: ee, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } 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, srcEventID: 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) let merged = coalesce(segs)
var gaps: [Block] = [] var gaps: [Block] = []
var prevEnd = block.start var prevEnd = block.start
for s in merged { for s in merged {
if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } 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 s.end > prevEnd { prevEnd = s.end }
} }
if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
return gaps return gaps
} }

View File

@@ -2,6 +2,18 @@
All notable changes to BusyMirror will be documented in this file. All notable changes to BusyMirror will be documented in this file.
## [1.3.7] - 2026-03-24
- Fix: mirror reconciliation now survives target providers that strip BusyMirror's custom event URL metadata.
- Fix: moved and deleted source events are tracked via stable EventKit identifiers and a persisted local mirror index, so target placeholders update reliably.
- Fix: mirror updates now detect title and notes changes, not just start/end time changes.
- Build: bump version to 1.3.7 (build 15).
## [1.3.6] - 2026-03-13
- Scheduling: add in-app `Scheduled runs` controls to install or remove a user `launchd` LaunchAgent from BusyMirror itself.
- Scheduling: support `Hourly`, `Daily`, and `Weekdays` schedules; hourly mode runs saved routes via `StartInterval`.
- UX: generate and ship a proper macOS app icon set for BusyMirror.
- Build: bump version to 1.3.6 (build 14).
## [1.3.4] - 2026-03-13 ## [1.3.4] - 2026-03-13
- Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run. - Fix: route-scoped cleanup no longer deletes placeholders created by other source routes during the same multi-route run.
- Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload. - Fix: stale calendars are pruned from saved selections and routes during refresh, and refresh now recreates `EKEventStore` for a hard reload.

View File

@@ -9,13 +9,16 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
- Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy"). - 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). - 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. - DRY-RUN mode: see what would be created/updated/deleted without writing.
- Activity Log in the app plus persistent file logging on disk.
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
- Overlap modes: `allow`, `skipCovered`, `fillGaps`. - Overlap modes: `allow`, `skipCovered`, `fillGaps`.
- Merge adjacent events with a configurable gap. - Merge adjacent events with a configurable gap.
- Time window controls (days back/forward) and Work Hours filter. - Time window controls (days back/forward) and Work Hours filter.
- Accepted-only filter (mirror your accepted meetings only). - Accepted-only filter (mirror your accepted meetings only).
- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared. - Cleanup of placeholders, including auto-delete of mirrors whose source disappeared.
- Refresh Calendars prunes stale saved calendars and routes when calendars are removed from the system.
- Prefix-based tagging and loop guards to prevent re-mirroring mirrors. - Prefix-based tagging and loop guards to prevent re-mirroring mirrors.
- Settings: autosave/restore, Import/Export JSON. - Settings: autosave/restore, Import/Export JSON, saved routes for scheduled/headless runs.
## 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).
@@ -48,13 +51,17 @@ See `CHANGELOG.md` for notable changes.
## Logs ## Logs
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`. - BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
- When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`. - When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`.
- `launchd` stdout/stderr for scheduled runs are also written in the same folder.
- In the UI, use `Reveal Log File` to open the current log directly in Finder. - In the UI, use `Reveal Log File` to open the current log directly in Finder.
## Scheduling ## Scheduling
- Yes. The recommended way is macOS `launchd` calling the built-in CLI with saved routes: - BusyMirror can create its own schedule from the app UI in `Scheduled runs`.
- Choose `Hourly`, `Daily`, or `Weekdays`, then click `Install Schedule`.
- The installed LaunchAgent runs:
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit` - `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI. - This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
- A typical `launchd` job can run this on a daily or weekday schedule after you grant calendar access once in the app. - Hourly schedules use `launchd` `StartInterval`; daily and weekday schedules use `StartCalendarInterval`.
- You can remove the job from the same UI with `Remove Schedule`, and inspect the generated plist with `Reveal LaunchAgent`.
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build. - Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
## Roadmap ## Roadmap

View File

@@ -8,10 +8,13 @@
- Work Hours filter and title-based skip filters - Work Hours filter and title-based skip filters
- Privacy: placeholders with prefix + customizable title - Privacy: placeholders with prefix + customizable title
- 1.3.0: Mark Private option (global + per-route) - 1.3.0: Mark Private option (global + per-route)
- 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: generated macOS app icon set and packaged release assets
## Next ## Next
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less) - Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
- Hint near "Mirror Now" indicating run mode (Routes vs Manual) - Better scheduled-run diagnostics in the UI (last run / last error / next run)
- Better server-side privacy mapping (per-provider heuristics) - Better server-side privacy mapping (per-provider heuristics)
## Then ## Then

9
ReleaseNotes-1.3.6.md Normal file
View File

@@ -0,0 +1,9 @@
BusyMirror 1.3.6 - 2026-03-13
Changes
- Add in-app scheduling controls so BusyMirror can install and remove its own `launchd` LaunchAgent.
- Support hourly saved-route runs in addition to daily and weekday schedules.
- Ship a generated macOS app icon set for the app bundle and exported releases.
Build
- Version bump to 1.3.6 (build 14).

9
ReleaseNotes-1.3.7.md Normal file
View File

@@ -0,0 +1,9 @@
BusyMirror 1.3.7 - 2026-03-24
Changes
- Fix mirrored event tracking on providers that do not preserve BusyMirror's custom event URL metadata.
- Track source events using stable EventKit identifiers and a local mirror index so moved and deleted source events update target calendars reliably.
- Detect title and notes changes during reconciliation instead of only updating mirrors when times change.
Build
- Version bump to 1.3.7 (build 15).