This commit is contained in:
2025-09-29 13:30:45 +02:00
parent 37328eeae2
commit 53f21492da
2 changed files with 211 additions and 78 deletions

1
.gitignore vendored
View File

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

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 }
} }
@@ -94,7 +94,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 +115,18 @@ 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
var overlapMode: OverlapMode {
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
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 +136,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()
@@ -248,22 +252,23 @@ struct ContentView: View {
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) { $route in
let routeVal = route.wrappedValue
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Source:") Text("Source:")
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) { if let sCal = calendars.first(where: { $0.calendarIdentifier == routeVal.sourceID }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Circle().fill(calColor(sCal)).frame(width: 10, height: 10) Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
Text(calLabel(sCal)).bold() Text(calLabel(sCal)).bold()
} }
} else { } else {
Text(labelForCalendar(id: route.sourceID)).bold() Text(labelForCalendar(id: routeVal.sourceID)).bold()
} }
Text("→ Targets:") Text("→ Targets:")
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in ForEach(routeVal.targetIDs.sorted(by: <), id: \.self) { tid in
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) { if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Circle().fill(calColor(tCal)).frame(width: 10, height: 10) Circle().fill(calColor(tCal)).frame(width: 10, height: 10)
@@ -277,46 +282,44 @@ struct ContentView: View {
} }
.frame(maxWidth: 420) .frame(maxWidth: 420)
Spacer() Spacer()
Toggle("Private", isOn: Binding(get: { routes[idx].privacy }, set: { routes[idx].privacy = $0 }))
Toggle("Private", isOn: $route.privacy)
.help("If ON, mirror as \(titlePrefix)\(placeholderTitle) with no notes. If OFF, mirror source title (and optionally notes).") .help("If ON, mirror as \(titlePrefix)\(placeholderTitle) with no notes. If OFF, mirror source title (and optionally notes).")
Text("·").foregroundStyle(.secondary) Text("·").foregroundStyle(.secondary)
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Merge gap:") Text("Merge gap:")
TextField("0", value: Binding( TextField("0", value: $route.mergeGapHours, formatter: Self.intFormatter)
get: { routes[idx].mergeGapHours }, .frame(width: 48)
set: { routes[idx].mergeGapHours = max(0, $0) } .disabled(isRunning)
), formatter: Self.intFormatter) .help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
.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) Text("h").foregroundStyle(.secondary)
} }
Toggle("Copy desc", isOn: Binding(
get: { routes[idx].copyNotes }, Toggle("Copy desc", isOn: $route.copyNotes)
set: { routes[idx].copyNotes = $0 } .disabled(isRunning || route.wrappedValue.privacy)
)) .help("If ON and Private is OFF, copy the source events notes/description into the placeholder.")
.disabled(isRunning || routes[idx].privacy)
.help("If ON and Private is OFF, copy the source events notes/description into the placeholder.")
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Overlap:") Text("Overlap:")
Picker("Overlap", selection: Binding( Picker("Overlap", selection: $route.overlap) {
get: { routes[idx].overlap },
set: { routes[idx].overlap = $0 }
)) {
ForEach(OverlapMode.allCases) { m in Text(m.rawValue).tag(m) } ForEach(OverlapMode.allCases) { m in Text(m.rawValue).tag(m) }
} }
.frame(width: 160) .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.") .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) .disabled(isRunning)
Toggle("Allday", isOn: Binding(
get: { routes[idx].allDay }, Toggle("Allday", isOn: $route.allDay)
set: { routes[idx].allDay = $0 } .disabled(isRunning)
)) .help("Mirror allday events for this source.")
.disabled(isRunning)
.help("Mirror allday events for this source.")
Text("·").foregroundStyle(.secondary) Text("·").foregroundStyle(.secondary)
Button(role: .destructive) { routes.remove(at: idx) } label: { Text("Remove") }
Button(role: .destructive) {
routes.removeAll { $0.id == routeVal.id }
} label: { Text("Remove") }
} }
} }
.padding(6) .padding(6)
@@ -340,8 +343,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 +352,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,7 +362,7 @@ 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) { Picker("Overlap mode", selection: Binding(get: { overlapMode }, set: { overlapMode = $0 })) {
ForEach(OverlapMode.allCases) { mode in ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode) Text(mode.rawValue).tag(mode)
} }
@@ -388,6 +391,11 @@ struct ContentView: View {
// 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 +408,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 +415,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
overlapMode = r.overlap
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 overlapMode = prevOverlap
mirrorAllDay = prevAllDay
}
} }
} }
} }
@@ -446,9 +458,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 +528,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 +545,27 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
requestAccess() requestAccess()
mergeGapHours = mergeGapMin / 60 loadRoutesFromDefaults()
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
saveRoutesToDefaults()
}
} }
// MARK: - CLI support // MARK: - CLI support
@@ -610,10 +630,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)")
@@ -970,6 +992,115 @@ 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 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,
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
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
titlePrefix = s.titlePrefix
placeholderTitle = s.placeholderTitle
autoDeleteMissing = s.autoDeleteMissing
routes = s.routes
}
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)
log("✓ Imported settings from \(url.lastPathComponent)")
} catch {
log("✗ Import failed: \(error.localizedDescription)")
}
}
}
// MARK: - Routes persistence (UserDefaults)
private let routesDefaultsKey = "routes.v1"
private func saveRoutesToDefaults() {
do {
let data = try JSONEncoder().encode(routes)
UserDefaults.standard.set(data, forKey: routesDefaultsKey)
} catch {
log("✗ Failed to save routes: \(error.localizedDescription)")
}
}
private func loadRoutesFromDefaults() {
guard let data = UserDefaults.standard.data(forKey: routesDefaultsKey) else { return }
do {
let decoded = try JSONDecoder().decode([Route].self, from: data)
routes = decoded
} catch {
log("✗ Failed to load routes: \(error.localizedDescription)")
}
}
// MARK: - Logging // MARK: - Logging
func log(_ s: String) { func log(_ s: String) {
logText.append("\n" + s) logText.append("\n" + s)
@@ -1057,3 +1188,4 @@ struct ContentView: View {
} }
} }
} }