6 Commits

4 changed files with 537 additions and 141 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ ExportOptions.plist
# Misc # Misc
*.swp *.swp
*.zip *.zip
*.sha256

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 = 4;
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.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 4;
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.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -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,9 @@ 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
private static let intFormatter: NumberFormatter = { private static let intFormatter: NumberFormatter = {
let f = NumberFormatter() let f = NumberFormatter()
@@ -144,6 +158,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 +175,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 +274,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 +400,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 +409,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 +419,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 +444,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 +514,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,21 +521,30 @@ 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
}
} }
} }
} }
@@ -446,9 +564,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 +634,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,23 +651,27 @@ 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 .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()
} }
.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()
} }
.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()
} }
.onChange(of: routes) { _ in
saveSettingsToDefaults()
}
} }
// MARK: - CLI support // MARK: - CLI support
@@ -581,9 +707,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 +736,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 +758,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 +777,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 +842,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 +888,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 +1147,178 @@ 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]
// 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,
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
clampWorkHours()
}
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)

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"