11 Commits

9 changed files with 657 additions and 142 deletions

2
.gitignore vendored
View File

@@ -19,3 +19,5 @@ ExportOptions.plist
# Misc # Misc
*.swp *.swp
*.zip *.zip
*.sha256
dist/

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 = 1; CURRENT_PROJECT_VERSION = 9;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist; INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.3.0;
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 = 1; CURRENT_PROJECT_VERSION = 9;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist; INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.3.0;
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;
@@ -465,7 +465,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -482,7 +482,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -498,7 +498,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
@@ -513,7 +513,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;

View File

@@ -6,7 +6,7 @@ import AppKit
private let SAME_TIME_TOL_MIN: Double = 5 private let SAME_TIME_TOL_MIN: Double = 5
private let SKIP_ALL_DAY_DEFAULT = true private let SKIP_ALL_DAY_DEFAULT = true
enum OverlapMode: String, CaseIterable, Identifiable { enum OverlapMode: String, CaseIterable, Identifiable, Codable {
case allow, skipCovered, fillGaps case allow, skipCovered, fillGaps
var id: String { rawValue } var id: String { rawValue }
} }
@@ -68,8 +68,13 @@ private func parseMirrorURL(_ url: URL?) -> (srcEventID: String?, occ: Date?, st
var sDate: Date? = nil var sDate: Date? = nil
var eDate: Date? = nil var eDate: Date? = nil
if parts.count >= 3 { srcID = String(parts[2]) } if parts.count >= 3 { srcID = String(parts[2]) }
if parts.count >= 4, let ts = TimeInterval(parts[3]) { occDate = Date(timeIntervalSince1970: ts) } if parts.count >= 4,
if parts.count >= 6, let sTS = TimeInterval(parts[4]), let eTS = TimeInterval(parts[5]) { let ts = TimeInterval(String(parts[3])) {
occDate = Date(timeIntervalSince1970: ts)
}
if parts.count >= 6,
let sTS = TimeInterval(String(parts[4])),
let eTS = TimeInterval(String(parts[5])) {
sDate = Date(timeIntervalSince1970: sTS) sDate = Date(timeIntervalSince1970: sTS)
eDate = Date(timeIntervalSince1970: eTS) eDate = Date(timeIntervalSince1970: eTS)
} }
@@ -94,7 +99,7 @@ struct Block: Hashable {
let occurrence: Date? // occurrenceDate for recurring instances let occurrence: Date? // occurrenceDate for recurring instances
} }
struct Route: Identifiable, Hashable { struct Route: Identifiable, Hashable, Codable {
let id = UUID() let id = UUID()
var sourceID: String var sourceID: String
var targetIDs: Set<String> var targetIDs: Set<String>
@@ -115,14 +120,23 @@ struct ContentView: View {
@State private var sourceID: String? = nil @State private var sourceID: String? = nil
@State private var targetIDs = Set<String>() @State private var targetIDs = Set<String>()
@State private var routes: [Route] = [] @State private var routes: [Route] = []
@State private var daysForward: Int = 7 @AppStorage("daysForward") private var daysForward: Int = 7
@State private var daysBack: Int = 1 @AppStorage("daysBack") private var daysBack: Int = 1
@State private var mergeGapMin: Int = 0 @State private var mergeGapMin: Int = 0
@State private var mergeGapHours: Int = 0 @AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@State private var hideDetails = true // Privacy ON by default -> use "Busy" @AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
@State private var copyDescription = false // Only applies when hideDetails == false @AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
@State private var overlapMode: OverlapMode = .allow @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@State private var mirrorAllDay = false @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
var overlapMode: OverlapMode {
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
nonmutating set { overlapModeRaw = newValue.rawValue }
}
@State private var writeEnabled = false // dry-run unless checked @State private var writeEnabled = false // dry-run unless checked
@State private var logText = "Ready." @State private var logText = "Ready."
@State private var isRunning = false @State private var isRunning = false
@@ -132,9 +146,17 @@ struct ContentView: View {
// into the same target more than once across multiple routes within a // into the same target more than once across multiple routes within a
// single "Mirror Now" click. // single "Mirror Now" click.
@State private var sessionGuard = Set<String>() @State private var sessionGuard = Set<String>()
@State private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders @AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
@State private var placeholderTitle: String = "Busy" // global customizable placeholder title @AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
@State private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists @AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
// Mirrors can run either by manual selection (source + at least one target)
// or using predefined routes. This derived flag controls the Mirror Now button.
private var canRunMirrorNow: Bool {
// Enable Mirror Now whenever calendars are available and permission is granted.
// The action itself chooses between routes or manual selection.
return hasAccess && !isRunning && !calendars.isEmpty
}
private static let intFormatter: NumberFormatter = { private static let intFormatter: NumberFormatter = {
let f = NumberFormatter() let f = NumberFormatter()
@@ -144,6 +166,14 @@ struct ContentView: View {
return f return f
}() }()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .none
f.minimum = 0
f.maximum = 24
return f
}()
// 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
@@ -153,6 +183,17 @@ struct ContentView: View {
} }
} }
private var excludedTitleFilterList: [String] {
excludedTitleFiltersRaw
.split { $0 == "\n" || $0 == "," }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private var excludedTitleFilterTerms: [String] {
excludedTitleFilterList.map { $0.lowercased() }
}
private func rebuildSelectionsFromIDs() { private func rebuildSelectionsFromIDs() {
// Map IDs -> indices in current calendars // Map IDs -> indices in current calendars
var idToIndex: [String:Int] = [:] var idToIndex: [String:Int] = [:]
@@ -241,92 +282,119 @@ struct ContentView: View {
overlap: overlapMode, overlap: overlapMode,
allDay: mirrorAllDay) allDay: mirrorAllDay)
routes.append(r) routes.append(r)
}.disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) }
Button("Clear") { routes.removeAll() }.disabled(isRunning || routes.isEmpty) .disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty)
Button("Clear") { routes.removeAll() }
.disabled(isRunning || routes.isEmpty)
} }
if routes.isEmpty { if routes.isEmpty {
Text("No routes yet. Pick a Source and Targets above, then click Add from current selection.") Text("No routes yet. Pick a Source and Targets above, then click Add from current selection.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(Array(routes.enumerated()), id: \.element.id) { idx, route in ForEach($routes, id: \.id) { routeBinding in
VStack(alignment: .leading, spacing: 4) { routeCard(for: routeBinding)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Source:")
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal)).bold()
}
} else {
Text(labelForCalendar(id: route.sourceID)).bold()
}
Text("→ Targets:")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 10, height: 10)
Text(calLabel(tCal))
}
} else {
Text(labelForCalendar(id: tid))
}
}
}
}
.frame(maxWidth: 420)
Spacer()
Toggle("Private", isOn: Binding(get: { routes[idx].privacy }, set: { routes[idx].privacy = $0 }))
.help("If ON, mirror as \(titlePrefix)\(placeholderTitle) with no notes. If OFF, mirror source title (and optionally notes).")
Text("·").foregroundStyle(.secondary)
HStack(spacing: 6) {
Text("Merge gap:")
TextField("0", value: Binding(
get: { routes[idx].mergeGapHours },
set: { routes[idx].mergeGapHours = max(0, $0) }
), formatter: Self.intFormatter)
.frame(width: 48)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
Toggle("Copy desc", isOn: Binding(
get: { routes[idx].copyNotes },
set: { routes[idx].copyNotes = $0 }
))
.disabled(isRunning || routes[idx].privacy)
.help("If ON and Private is OFF, copy the source events notes/description into the placeholder.")
HStack(spacing: 6) {
Text("Overlap:")
Picker("Overlap", selection: Binding(
get: { routes[idx].overlap },
set: { routes[idx].overlap = $0 }
)) {
ForEach(OverlapMode.allCases) { m in Text(m.rawValue).tag(m) }
}
.frame(width: 160)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
.disabled(isRunning)
Toggle("Allday", isOn: Binding(
get: { routes[idx].allDay },
set: { routes[idx].allDay = $0 }
))
.disabled(isRunning)
.help("Mirror allday events for this source.")
Text("·").foregroundStyle(.secondary)
Button(role: .destructive) { routes.remove(at: idx) } label: { Text("Remove") }
}
}
.padding(6)
.background(.quaternary.opacity(0.1))
.cornerRadius(6)
} }
} }
} }
} }
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
sourceSummaryView(for: route)
Text("→ Targets:")
targetSummaryView(for: route)
Spacer()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as \(titlePrefix)\(placeholderTitle) with no notes. If OFF, mirror source title (and optionally notes).")
separatorDot()
mergeGapField(for: routeBinding)
Toggle("Copy desc", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source events notes/description into the placeholder.")
overlapPicker(for: routeBinding)
.disabled(isRunning)
Toggle("All-day", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
separatorDot()
Button(role: .destructive) { removeRoute(id: route.id) } label: { Text("Remove") }
}
}
.padding(6)
.background(.quaternary.opacity(0.1))
.cornerRadius(6)
}
@ViewBuilder
private func sourceSummaryView(for route: Route) -> some View {
Text("Source:")
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal)).bold()
}
} else {
Text(labelForCalendar(id: route.sourceID)).bold()
}
}
@ViewBuilder
private func targetSummaryView(for route: Route) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 10, height: 10)
Text(calLabel(tCal))
}
} else {
Text(labelForCalendar(id: tid))
}
}
}
}
.frame(maxWidth: 420)
}
@ViewBuilder
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 6) {
Text("Merge gap:")
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
.frame(width: 48)
.disabled(isRunning)
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
Text("h").foregroundStyle(.secondary)
}
}
@ViewBuilder
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
HStack(spacing: 6) {
Text("Overlap:")
Picker("Overlap", selection: routeBinding.overlap) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.frame(width: 160)
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
}
}
@ViewBuilder
private func separatorDot() -> some View {
Text("·").foregroundStyle(.secondary)
}
private func removeRoute(id: UUID) {
routes.removeAll { $0.id == id }
}
@ViewBuilder @ViewBuilder
private func optionsSection() -> some View { private func optionsSection() -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -340,8 +408,8 @@ struct ContentView: View {
.frame(width: 60) .frame(width: 60)
.disabled(isRunning) .disabled(isRunning)
} }
.onChange(of: daysBack) { _, v in daysBack = max(0, v) } .onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { _, v in daysForward = max(0, v) } .onChange(of: daysForward) { v in daysForward = max(0, v) }
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Default merge gap:") Text("Default merge gap:")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter) TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
@@ -349,7 +417,7 @@ struct ContentView: View {
.disabled(isRunning) .disabled(isRunning)
Text("hours").foregroundStyle(.secondary) Text("hours").foregroundStyle(.secondary)
} }
.onChange(of: mergeGapHours) { _, newVal in .onChange(of: mergeGapHours) { newVal in
mergeGapMin = max(0, newVal * 60) mergeGapMin = max(0, newVal * 60)
} }
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails) Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
@@ -359,9 +427,11 @@ struct ContentView: View {
.disabled(isRunning || hideDetails) .disabled(isRunning || hideDetails)
Toggle("Mirror all-day events", isOn: $mirrorAllDay) Toggle("Mirror all-day events", isOn: $mirrorAllDay)
.disabled(isRunning) .disabled(isRunning)
Picker("Overlap mode", selection: $overlapMode) { Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
.disabled(isRunning)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode) Text(mode.rawValue).tag(mode.rawValue)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -382,12 +452,64 @@ struct ContentView: View {
.disabled(isRunning) .disabled(isRunning)
} }
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
.disabled(isRunning)
.onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() }
if filterByWorkHours {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("Start hour:")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 48)
.disabled(isRunning)
Text("End hour:")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 48)
.disabled(isRunning)
Text("(local time)").foregroundStyle(.secondary)
}
Text("Events starting outside this range are skipped; end hour is exclusive.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: workHoursStart) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
.onChange(of: workHoursEnd) { _ in
clampWorkHours()
saveSettingsToDefaults()
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 80)
.disabled(isRunning)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3))
)
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() }
Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled) Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled)
.disabled(isRunning) .disabled(isRunning)
// Insert auto-delete toggle after writeEnabled // Insert auto-delete toggle after writeEnabled
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing) Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
.disabled(isRunning) .disabled(isRunning)
HStack(spacing: 12) {
Button("Export Settings…") { exportSettings() }
Button("Import Settings…") { importSettings() }
}
.padding(.vertical, 4)
HStack { HStack {
Button(isRunning ? "Running…" : "Mirror Now") { Button(isRunning ? "Running…" : "Mirror Now") {
@@ -400,11 +522,6 @@ struct ContentView: View {
for r in routes { for r in routes {
// Resolve source index and target IDs for this route // Resolve source index and target IDs for this route
if let sIdx = indexForCalendar(id: r.sourceID) { if let sIdx = indexForCalendar(id: r.sourceID) {
sourceIndex = sIdx
sourceID = r.sourceID
targetIDs = r.targetIDs
// Belt-and-suspenders: ensure source is not in targets even if UI state is stale
targetIDs.remove(r.sourceID)
// Save globals // Save globals
let prevPrivacy = hideDetails let prevPrivacy = hideDetails
let prevCopy = copyDescription let prevCopy = copyDescription
@@ -412,27 +529,36 @@ struct ContentView: View {
let prevGapM = mergeGapMin let prevGapM = mergeGapMin
let prevOverlap = overlapMode let prevOverlap = overlapMode
let prevAllDay = mirrorAllDay let prevAllDay = mirrorAllDay
// Apply per-route // Apply per-route state changes on MainActor
hideDetails = r.privacy await MainActor.run {
copyDescription = r.copyNotes sourceIndex = sIdx
mergeGapHours = max(0, r.mergeGapHours) sourceID = r.sourceID
mergeGapMin = mergeGapHours * 60 targetIDs = r.targetIDs
overlapMode = r.overlap // Belt-and-suspenders: ensure source is not in targets even if UI state is stale
mirrorAllDay = r.allDay targetIDs.remove(r.sourceID)
hideDetails = r.privacy
copyDescription = r.copyNotes
mergeGapHours = max(0, r.mergeGapHours)
mergeGapMin = mergeGapHours * 60
overlapModeRaw = r.overlap.rawValue
mirrorAllDay = r.allDay
}
await runMirror() await runMirror()
// Restore globals await MainActor.run {
hideDetails = prevPrivacy // Restore globals
copyDescription = prevCopy hideDetails = prevPrivacy
mergeGapHours = prevGapH copyDescription = prevCopy
mergeGapMin = prevGapM mergeGapHours = prevGapH
overlapMode = prevOverlap mergeGapMin = prevGapM
mirrorAllDay = prevAllDay overlapModeRaw = prevOverlap.rawValue
mirrorAllDay = prevAllDay
}
} }
} }
} }
} }
} }
.disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty) .disabled(!canRunMirrorNow)
Button("Cleanup Placeholders") { Button("Cleanup Placeholders") {
if writeEnabled { if writeEnabled {
@@ -446,9 +572,11 @@ struct ContentView: View {
} else { } else {
for r in routes { for r in routes {
if let sIdx = indexForCalendar(id: r.sourceID) { if let sIdx = indexForCalendar(id: r.sourceID) {
sourceIndex = sIdx await MainActor.run {
sourceID = r.sourceID sourceIndex = sIdx
targetIDs = r.targetIDs sourceID = r.sourceID
targetIDs = r.targetIDs
}
await runCleanup() await runCleanup()
} }
} }
@@ -514,9 +642,11 @@ struct ContentView: View {
} else { } else {
for r in routes { for r in routes {
if let sIdx = indexForCalendar(id: r.sourceID) { if let sIdx = indexForCalendar(id: r.sourceID) {
sourceIndex = sIdx await MainActor.run {
sourceID = r.sourceID sourceIndex = sIdx
targetIDs = r.targetIDs sourceID = r.sourceID
targetIDs = r.targetIDs
}
await runCleanup() await runCleanup()
} }
} }
@@ -529,22 +659,41 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
requestAccess() requestAccess()
mergeGapHours = mergeGapMin / 60 loadSettingsFromDefaults()
mergeGapMin = max(0, mergeGapHours * 60)
tryRunCLIIfPresent() tryRunCLIIfPresent()
enforceNoSourceInTargets() enforceNoSourceInTargets()
} }
.onChange(of: sourceIndex) { oldValue, newValue in // 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: 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 }
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
} }
.onChange(of: targetSelections) { _, _ in .onChange(of: targetSelections) { _ in
// If the new source is accidentally included, drop it // If the new source is accidentally included, drop it
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
} }
.onChange(of: targetIDs) { _, _ in .onChange(of: targetIDs) { _ in
// If IDs contain the sources ID, drop it // If IDs contain the sources ID, drop it
enforceNoSourceInTargets() enforceNoSourceInTargets()
saveSettingsToDefaults()
}
.onChange(of: routes) { _ in
saveSettingsToDefaults()
} }
} }
@@ -581,9 +730,9 @@ struct ContentView: View {
mergeGapMin = max(0, mergeGapHours * 60) mergeGapMin = max(0, mergeGapHours * 60)
if let modeStr = strArg("--mode")?.lowercased() { if let modeStr = strArg("--mode")?.lowercased() {
switch modeStr { switch modeStr {
case "allow": overlapMode = .allow case "allow": overlapModeRaw = OverlapMode.allow.rawValue
case "skipcovered", "skip": overlapMode = .skipCovered case "skipcovered", "skip": overlapModeRaw = OverlapMode.skipCovered.rawValue
case "fillgaps", "gaps": overlapMode = .fillGaps case "fillgaps", "gaps": overlapModeRaw = OverlapMode.fillGaps.rawValue
default: break default: break
} }
} }
@@ -610,10 +759,12 @@ struct ContentView: View {
let srcIdx0 = max(0, s1 - 1) let srcIdx0 = max(0, s1 - 1)
let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 } let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 }
if srcIdx0 >= calendars.count { continue } if srcIdx0 >= calendars.count { continue }
sourceIndex = srcIdx0 await MainActor.run {
sourceID = calendars[srcIdx0].calendarIdentifier sourceIndex = srcIdx0
targetSelections = Set(tgtIdxs0) sourceID = calendars[srcIdx0].calendarIdentifier
targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }) targetSelections = Set(tgtIdxs0)
targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil })
}
if boolArg("--cleanup-only", default: false) { if boolArg("--cleanup-only", default: false) {
log("CLI: cleanup route \(part)") log("CLI: cleanup route \(part)")
@@ -630,13 +781,18 @@ struct ContentView: View {
} }
// MARK: - Permissions & Calendars // MARK: - Permissions & Calendars
@MainActor
func requestAccess() { func requestAccess() {
log("Requesting calendar access…") log("Requesting calendar access…")
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
store.requestFullAccessToEvents { granted, _ in store.requestFullAccessToEvents { granted, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
hasAccess = granted hasAccess = granted
if granted { reloadCalendars() } if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
}
log(granted ? "Access granted." : "Access denied.") log(granted ? "Access granted." : "Access denied.")
} }
} }
@@ -644,13 +800,18 @@ struct ContentView: View {
store.requestAccess(to: .event) { granted, _ in store.requestAccess(to: .event) { granted, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
hasAccess = granted hasAccess = granted
if granted { reloadCalendars() } if granted {
// Reinitialize the store after permission changes to ensure sources load
store = EKEventStore()
reloadCalendars()
}
log(granted ? "Access granted." : "Access denied.") log(granted ? "Access granted." : "Access denied.")
} }
} }
} }
} }
@MainActor
func reloadCalendars() { func reloadCalendars() {
let fetched = store.calendars(for: .event) let fetched = store.calendars(for: .event)
calendars = sortedCalendars(fetched) calendars = sortedCalendars(fetched)
@@ -704,7 +865,37 @@ struct ContentView: View {
var srcBlocks: [Block] = [] var srcBlocks: [Block] = []
var skippedMirrors = 0 var skippedMirrors = 0
let titleFilters = excludedTitleFilterTerms
let enforceWorkHours = filterByWorkHours && workHoursEnd > workHoursStart
let allowedStartMinutes = workHoursStart * 60
let allowedEndMinutes = workHoursEnd * 60
var skippedWorkHours = 0
var skippedTitles = 0
var skippedStatus = 0
for ev in srcEvents { for ev in srcEvents {
if mirrorAcceptedOnly, ev.hasAttendees {
// Only include events where the current user's attendee status is Accepted
let attendees = ev.attendees ?? []
if let me = attendees.first(where: { $0.isCurrentUser }) {
if me.participantStatus != .accepted {
skippedStatus += 1
continue
}
} else {
// If we cannot determine a self attendee, treat as not accepted
skippedStatus += 1
continue
}
}
if enforceWorkHours, !ev.isAllDay, let start = ev.startDate,
isOutsideWorkHours(start, calendar: cal, startMinutes: allowedStartMinutes, endMinutes: allowedEndMinutes) {
skippedWorkHours += 1
continue
}
if shouldSkip(event: ev, filters: titleFilters) {
skippedTitles += 1
continue
}
if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue } if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue }
if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) { if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) {
// Aggregate skip count for mirrored-on-source // Aggregate skip count for mirrored-on-source
@@ -720,6 +911,15 @@ struct ContentView: View {
if skippedMirrors > 0 { if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
} }
if skippedWorkHours > 0 {
log("- SKIP outside work hours: \(skippedWorkHours) event(s)")
}
if skippedTitles > 0 {
log("- SKIP title filter: \(skippedTitles) event(s)")
}
if skippedStatus > 0 {
log("- SKIP non-accepted status: \(skippedStatus) event(s)")
}
// Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races) // Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races)
srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0) srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0)
@@ -970,6 +1170,188 @@ struct ContentView: View {
} }
} }
// MARK: - Export / Import Settings
private struct SettingsPayload: Codable {
var daysBack: Int
var daysForward: Int
var mergeGapHours: Int
var hideDetails: Bool
var copyDescription: Bool
var mirrorAllDay: Bool
var filterByWorkHours: Bool = false
var workHoursStart: Int = 9
var workHoursEnd: Int = 17
var excludedTitleFilters: [String] = []
var mirrorAcceptedOnly: Bool = false
var overlapMode: String
var titlePrefix: String
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()
}
private func makeSnapshot() -> SettingsPayload {
SettingsPayload(
daysBack: daysBack,
daysForward: daysForward,
mergeGapHours: mergeGapHours,
hideDetails: hideDetails,
copyDescription: copyDescription,
mirrorAllDay: mirrorAllDay,
filterByWorkHours: filterByWorkHours,
workHoursStart: workHoursStart,
workHoursEnd: workHoursEnd,
excludedTitleFilters: excludedTitleFilterList,
mirrorAcceptedOnly: mirrorAcceptedOnly,
overlapMode: overlapMode.rawValue,
titlePrefix: titlePrefix,
placeholderTitle: placeholderTitle,
autoDeleteMissing: autoDeleteMissing,
routes: routes,
selectedSourceID: sourceID,
selectedTargetIDs: Array(targetIDs).sorted(),
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
exportedAt: Date()
)
}
private func applySnapshot(_ s: SettingsPayload) {
daysBack = s.daysBack
daysForward = s.daysForward
mergeGapHours = s.mergeGapHours
mergeGapMin = max(0, s.mergeGapHours * 60)
hideDetails = s.hideDetails
copyDescription = s.copyDescription
mirrorAllDay = s.mirrorAllDay
filterByWorkHours = s.filterByWorkHours
workHoursStart = s.workHoursStart
workHoursEnd = s.workHoursEnd
excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n")
mirrorAcceptedOnly = s.mirrorAcceptedOnly
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
titlePrefix = s.titlePrefix
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() {
let panel = NSSavePanel()
panel.allowedFileTypes = ["json"]
panel.nameFieldStringValue = "BusyMirror-Settings.json"
panel.canCreateDirectories = true
panel.isExtensionHidden = false
if panel.runModal() == .OK, let url = panel.url {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(makeSnapshot())
try data.write(to: url, options: Data.WritingOptions.atomic)
log("✓ Exported settings to \(url.path)")
} catch {
log("✗ Export failed: \(error.localizedDescription)")
}
}
}
private func importSettings() {
let panel = NSOpenPanel()
panel.allowedFileTypes = ["json"]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
if panel.runModal() == .OK, let url = panel.url {
do {
let data = try Data(contentsOf: url)
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
applySnapshot(snap)
saveSettingsToDefaults()
log("✓ Imported settings from \(url.lastPathComponent)")
} catch {
log("✗ Import failed: \(error.localizedDescription)")
}
}
}
// MARK: - Settings persistence (UserDefaults)
private let settingsDefaultsKey = "settings.v2"
private let legacyRoutesDefaultsKey = "routes.v1"
private func saveSettingsToDefaults() {
do {
let data = try JSONEncoder().encode(makeSnapshot())
UserDefaults.standard.set(data, forKey: settingsDefaultsKey)
} catch {
log("✗ Failed to save settings: \(error.localizedDescription)")
}
}
private func loadSettingsFromDefaults() {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: settingsDefaultsKey) {
do {
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
applySnapshot(snap)
} catch {
log("✗ Failed to load settings: \(error.localizedDescription)")
}
return
}
// Legacy fallback: routes-only payload
guard let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey) else { return }
do {
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
routes = decodedRoutes
clampWorkHours()
saveSettingsToDefaults() // upgrade stored format
} catch {
log("✗ Failed to load routes: \(error.localizedDescription)")
}
}
// MARK: - Filters
private func isOutsideWorkHours(_ startDate: Date, calendar: Calendar, startMinutes: Int, endMinutes: Int) -> Bool {
guard endMinutes > startMinutes else { return false }
let comps = calendar.dateComponents([.hour, .minute], from: startDate)
guard let hour = comps.hour else { return false }
let minute = comps.minute ?? 0
let start = hour * 60 + minute
return start < startMinutes || start >= endMinutes
}
private func shouldSkip(event: EKEvent, filters: [String]) -> Bool {
guard !filters.isEmpty else { return false }
let rawTitle = (event.title ?? "").lowercased()
let strippedTitle = stripPrefix(event.title, prefix: titlePrefix).lowercased()
return filters.contains { token in
rawTitle.contains(token) || strippedTitle.contains(token)
}
}
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
// MARK: - Logging // MARK: - Logging
func log(_ s: String) { func log(_ s: String) {
logText.append("\n" + s) logText.append("\n" + s)

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
# Changelog
All notable changes to BusyMirror will be documented in this file.
## [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, acceptedonly 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 users 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.

46
Makefile Normal file
View File

@@ -0,0 +1,46 @@
# Simple build and package helpers for BusyMirror
SCHEME ?= BusyMirror
PROJECT ?= BusyMirror.xcodeproj
DERIVED ?= build/DerivedData
DEST := platform=macOS
# Extract marketing version from project settings
VERSION := $(shell sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' $(PROJECT)/project.pbxproj | head -n1)
.PHONY: all clean build-debug build-release open app package
all: build-release
clean:
@echo "Cleaning derived data…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -derivedDataPath $(DERIVED) -destination '$(DEST)' CODE_SIGNING_ALLOWED=NO clean >/dev/null
@echo "Done."
build-debug:
@echo "Building Debug…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Debug -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
build-release:
@echo "Building Release…"
xcodebuild -scheme $(SCHEME) -project $(PROJECT) -configuration Release -destination '$(DEST)' -derivedDataPath $(DERIVED) CODE_SIGNING_ALLOWED=NO build
# Convenience to open the built app in Finder
open: app
@open "$<"
# Path to built app (Release)
APP_PATH := $(DERIVED)/Build/Products/Release/BusyMirror.app
app: build-release
@# Ensure the app exists
@test -d "$(APP_PATH)" && echo "Built: $(APP_PATH)" || (echo "App not found at $(APP_PATH)" && exit 1)
@echo "Version: $(VERSION)"
@echo "OK"
package: app
@echo "Packaging BusyMirror $(VERSION)"
@zip -qry "BusyMirror-$(VERSION)-macOS.zip" "$(APP_PATH)"
@shasum -a 256 "BusyMirror-$(VERSION)-macOS.zip" | awk '{print $$1}' > "BusyMirror-$(VERSION)-macOS.zip.sha256"
@echo "Created BusyMirror-$(VERSION)-macOS.zip and .sha256"

View File

@@ -14,11 +14,19 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
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).
## Build (macOS) ## Build (macOS)
Option A — Xcode
1. Open `BusyMirror.xcodeproj` in Xcode. 1. Open `BusyMirror.xcodeproj` in Xcode.
2. Select the BusyMirror scheme → My Mac. 2. Select the BusyMirror scheme → My Mac.
3. Product → Build. 3. Product → Build.
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing). 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.
## Roadmap ## Roadmap
See [ROADMAP.md](ROADMAP.md) See [ROADMAP.md](ROADMAP.md)

24
ReleaseNotes-1.2.3.md Normal file
View 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
View 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
View File

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