Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
df06564434 | |||
74b9949610 | |||
6676e62889 | |||
d1fbd4c81f | |||
6ef0feecc1 | |||
aac4de3fb3 | |||
8f80a5f672 | |||
ae40b42e6f | |||
691575c554 | |||
b931f3ba2c | |||
53f21492da |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ ExportOptions.plist
|
||||
# Misc
|
||||
*.swp
|
||||
*.zip
|
||||
*.sha256
|
||||
dist/
|
||||
|
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -465,7 +465,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -482,7 +482,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -498,7 +498,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
|
||||
@@ -513,7 +513,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirrorUITests;
|
||||
|
@@ -6,7 +6,7 @@ import AppKit
|
||||
private let SAME_TIME_TOL_MIN: Double = 5
|
||||
private let SKIP_ALL_DAY_DEFAULT = true
|
||||
|
||||
enum OverlapMode: String, CaseIterable, Identifiable {
|
||||
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
||||
case allow, skipCovered, fillGaps
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -68,8 +68,13 @@ private func parseMirrorURL(_ url: URL?) -> (srcEventID: String?, occ: Date?, st
|
||||
var sDate: Date? = nil
|
||||
var eDate: Date? = nil
|
||||
if parts.count >= 3 { srcID = String(parts[2]) }
|
||||
if parts.count >= 4, let ts = TimeInterval(parts[3]) { occDate = Date(timeIntervalSince1970: ts) }
|
||||
if parts.count >= 6, let sTS = TimeInterval(parts[4]), let eTS = TimeInterval(parts[5]) {
|
||||
if parts.count >= 4,
|
||||
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)
|
||||
eDate = Date(timeIntervalSince1970: eTS)
|
||||
}
|
||||
@@ -94,7 +99,7 @@ struct Block: Hashable {
|
||||
let occurrence: Date? // occurrenceDate for recurring instances
|
||||
}
|
||||
|
||||
struct Route: Identifiable, Hashable {
|
||||
struct Route: Identifiable, Hashable, Codable {
|
||||
let id = UUID()
|
||||
var sourceID: String
|
||||
var targetIDs: Set<String>
|
||||
@@ -115,14 +120,23 @@ struct ContentView: View {
|
||||
@State private var sourceID: String? = nil
|
||||
@State private var targetIDs = Set<String>()
|
||||
@State private var routes: [Route] = []
|
||||
@State private var daysForward: Int = 7
|
||||
@State private var daysBack: Int = 1
|
||||
@AppStorage("daysForward") private var daysForward: Int = 7
|
||||
@AppStorage("daysBack") private var daysBack: Int = 1
|
||||
@State private var mergeGapMin: Int = 0
|
||||
@State private var mergeGapHours: Int = 0
|
||||
@State private var hideDetails = true // Privacy ON by default -> use "Busy"
|
||||
@State private var copyDescription = false // Only applies when hideDetails == false
|
||||
@State private var overlapMode: OverlapMode = .allow
|
||||
@State private var mirrorAllDay = false
|
||||
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
|
||||
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
||||
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
||||
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = 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 logText = "Ready."
|
||||
@State private var isRunning = false
|
||||
@@ -132,9 +146,17 @@ struct ContentView: View {
|
||||
// into the same target more than once across multiple routes within a
|
||||
// single "Mirror Now" click.
|
||||
@State private var sessionGuard = Set<String>()
|
||||
@State private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||||
@State private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||||
@State private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||||
|
||||
// Mirrors can run either by manual selection (source + at least one target)
|
||||
// or using predefined routes. This derived flag controls the Mirror Now button.
|
||||
private var canRunMirrorNow: Bool {
|
||||
// Enable Mirror Now whenever calendars are available and permission is granted.
|
||||
// The action itself chooses between routes or manual selection.
|
||||
return hasAccess && !isRunning && !calendars.isEmpty
|
||||
}
|
||||
|
||||
private static let intFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
@@ -144,6 +166,14 @@ struct ContentView: View {
|
||||
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
|
||||
private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] {
|
||||
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() {
|
||||
// Map IDs -> indices in current calendars
|
||||
var idToIndex: [String:Int] = [:]
|
||||
@@ -241,92 +282,119 @@ struct ContentView: View {
|
||||
overlap: overlapMode,
|
||||
allDay: mirrorAllDay)
|
||||
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 {
|
||||
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(routes.enumerated()), id: \.element.id) { idx, route in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
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 event’s 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("All‑day", isOn: Binding(
|
||||
get: { routes[idx].allDay },
|
||||
set: { routes[idx].allDay = $0 }
|
||||
))
|
||||
.disabled(isRunning)
|
||||
.help("Mirror all‑day 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)
|
||||
ForEach($routes, id: \.id) { routeBinding in
|
||||
routeCard(for: routeBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 event’s 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
|
||||
private func optionsSection() -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -340,8 +408,8 @@ struct ContentView: View {
|
||||
.frame(width: 60)
|
||||
.disabled(isRunning)
|
||||
}
|
||||
.onChange(of: daysBack) { _, v in daysBack = max(0, v) }
|
||||
.onChange(of: daysForward) { _, v in daysForward = max(0, v) }
|
||||
.onChange(of: daysBack) { v in daysBack = max(0, v) }
|
||||
.onChange(of: daysForward) { v in daysForward = max(0, v) }
|
||||
HStack(spacing: 8) {
|
||||
Text("Default merge gap:")
|
||||
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
|
||||
@@ -349,7 +417,7 @@ struct ContentView: View {
|
||||
.disabled(isRunning)
|
||||
Text("hours").foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: mergeGapHours) { _, newVal in
|
||||
.onChange(of: mergeGapHours) { newVal in
|
||||
mergeGapMin = max(0, newVal * 60)
|
||||
}
|
||||
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
|
||||
@@ -359,9 +427,11 @@ struct ContentView: View {
|
||||
.disabled(isRunning || hideDetails)
|
||||
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
|
||||
.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
|
||||
Text(mode.rawValue).tag(mode)
|
||||
Text(mode.rawValue).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
@@ -382,12 +452,64 @@ struct ContentView: View {
|
||||
.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)
|
||||
.disabled(isRunning)
|
||||
|
||||
// Insert auto-delete toggle after writeEnabled
|
||||
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
|
||||
.disabled(isRunning)
|
||||
HStack(spacing: 12) {
|
||||
Button("Export Settings…") { exportSettings() }
|
||||
Button("Import Settings…") { importSettings() }
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Button(isRunning ? "Running…" : "Mirror Now") {
|
||||
@@ -400,11 +522,6 @@ struct ContentView: View {
|
||||
for r in routes {
|
||||
// Resolve source index and target IDs for this route
|
||||
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
|
||||
let prevPrivacy = hideDetails
|
||||
let prevCopy = copyDescription
|
||||
@@ -412,27 +529,36 @@ struct ContentView: View {
|
||||
let prevGapM = mergeGapMin
|
||||
let prevOverlap = overlapMode
|
||||
let prevAllDay = mirrorAllDay
|
||||
// Apply per-route
|
||||
hideDetails = r.privacy
|
||||
copyDescription = r.copyNotes
|
||||
mergeGapHours = max(0, r.mergeGapHours)
|
||||
mergeGapMin = mergeGapHours * 60
|
||||
overlapMode = r.overlap
|
||||
mirrorAllDay = r.allDay
|
||||
// Apply per-route state changes on MainActor
|
||||
await MainActor.run {
|
||||
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)
|
||||
hideDetails = r.privacy
|
||||
copyDescription = r.copyNotes
|
||||
mergeGapHours = max(0, r.mergeGapHours)
|
||||
mergeGapMin = mergeGapHours * 60
|
||||
overlapModeRaw = r.overlap.rawValue
|
||||
mirrorAllDay = r.allDay
|
||||
}
|
||||
await runMirror()
|
||||
// Restore globals
|
||||
hideDetails = prevPrivacy
|
||||
copyDescription = prevCopy
|
||||
mergeGapHours = prevGapH
|
||||
mergeGapMin = prevGapM
|
||||
overlapMode = prevOverlap
|
||||
mirrorAllDay = prevAllDay
|
||||
await MainActor.run {
|
||||
// Restore globals
|
||||
hideDetails = prevPrivacy
|
||||
copyDescription = prevCopy
|
||||
mergeGapHours = prevGapH
|
||||
mergeGapMin = prevGapM
|
||||
overlapModeRaw = prevOverlap.rawValue
|
||||
mirrorAllDay = prevAllDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty)
|
||||
.disabled(!canRunMirrorNow)
|
||||
|
||||
Button("Cleanup Placeholders") {
|
||||
if writeEnabled {
|
||||
@@ -446,9 +572,11 @@ struct ContentView: View {
|
||||
} else {
|
||||
for r in routes {
|
||||
if let sIdx = indexForCalendar(id: r.sourceID) {
|
||||
sourceIndex = sIdx
|
||||
sourceID = r.sourceID
|
||||
targetIDs = r.targetIDs
|
||||
await MainActor.run {
|
||||
sourceIndex = sIdx
|
||||
sourceID = r.sourceID
|
||||
targetIDs = r.targetIDs
|
||||
}
|
||||
await runCleanup()
|
||||
}
|
||||
}
|
||||
@@ -514,9 +642,11 @@ struct ContentView: View {
|
||||
} else {
|
||||
for r in routes {
|
||||
if let sIdx = indexForCalendar(id: r.sourceID) {
|
||||
sourceIndex = sIdx
|
||||
sourceID = r.sourceID
|
||||
targetIDs = r.targetIDs
|
||||
await MainActor.run {
|
||||
sourceIndex = sIdx
|
||||
sourceID = r.sourceID
|
||||
targetIDs = r.targetIDs
|
||||
}
|
||||
await runCleanup()
|
||||
}
|
||||
}
|
||||
@@ -529,22 +659,41 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
requestAccess()
|
||||
mergeGapHours = mergeGapMin / 60
|
||||
loadSettingsFromDefaults()
|
||||
mergeGapMin = max(0, mergeGapHours * 60)
|
||||
tryRunCLIIfPresent()
|
||||
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
|
||||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: targetSelections) { _, _ in
|
||||
.onChange(of: targetSelections) { _ in
|
||||
// If the new source is accidentally included, drop it
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: targetIDs) { _, _ in
|
||||
.onChange(of: targetIDs) { _ in
|
||||
// If IDs contain the source’s ID, drop it
|
||||
enforceNoSourceInTargets()
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
.onChange(of: routes) { _ in
|
||||
saveSettingsToDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,9 +730,9 @@ struct ContentView: View {
|
||||
mergeGapMin = max(0, mergeGapHours * 60)
|
||||
if let modeStr = strArg("--mode")?.lowercased() {
|
||||
switch modeStr {
|
||||
case "allow": overlapMode = .allow
|
||||
case "skipcovered", "skip": overlapMode = .skipCovered
|
||||
case "fillgaps", "gaps": overlapMode = .fillGaps
|
||||
case "allow": overlapModeRaw = OverlapMode.allow.rawValue
|
||||
case "skipcovered", "skip": overlapModeRaw = OverlapMode.skipCovered.rawValue
|
||||
case "fillgaps", "gaps": overlapModeRaw = OverlapMode.fillGaps.rawValue
|
||||
default: break
|
||||
}
|
||||
}
|
||||
@@ -610,10 +759,12 @@ struct ContentView: View {
|
||||
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 }
|
||||
if srcIdx0 >= calendars.count { continue }
|
||||
sourceIndex = srcIdx0
|
||||
sourceID = calendars[srcIdx0].calendarIdentifier
|
||||
targetSelections = Set(tgtIdxs0)
|
||||
targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil })
|
||||
await MainActor.run {
|
||||
sourceIndex = srcIdx0
|
||||
sourceID = calendars[srcIdx0].calendarIdentifier
|
||||
targetSelections = Set(tgtIdxs0)
|
||||
targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil })
|
||||
}
|
||||
|
||||
if boolArg("--cleanup-only", default: false) {
|
||||
log("CLI: cleanup route \(part)")
|
||||
@@ -630,13 +781,18 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// MARK: - Permissions & Calendars
|
||||
@MainActor
|
||||
func requestAccess() {
|
||||
log("Requesting calendar access…")
|
||||
if #available(macOS 14.0, *) {
|
||||
store.requestFullAccessToEvents { granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
@@ -644,13 +800,18 @@ struct ContentView: View {
|
||||
store.requestAccess(to: .event) { granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reloadCalendars() {
|
||||
let fetched = store.calendars(for: .event)
|
||||
calendars = sortedCalendars(fetched)
|
||||
@@ -704,7 +865,37 @@ struct ContentView: View {
|
||||
|
||||
var srcBlocks: [Block] = []
|
||||
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 {
|
||||
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 isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) {
|
||||
// Aggregate skip count for mirrored-on-source
|
||||
@@ -720,6 +911,15 @@ struct ContentView: View {
|
||||
if skippedMirrors > 0 {
|
||||
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)
|
||||
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
|
||||
func log(_ s: String) {
|
||||
logText.append("\n" + s)
|
||||
|
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal 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, accepted‑only filter, settings autosave/restore, Mirror Now enablement.
|
||||
|
||||
## [1.2.3] - 2025-10-10
|
||||
- Fix: reliably save and restore settings between runs via autosave of key options and restoration of source/target selections by persistent IDs.
|
||||
- UX: persist Source and Target selections; rebuild indices on launch so UI matches saved IDs.
|
||||
- Build: bump version to 1.2.3 (build 5).
|
||||
|
||||
## [1.2.1] - 2025-10-10
|
||||
- Fix: reinitialize EKEventStore after permission grant to avoid “Loaded 0 calendars” right after approval.
|
||||
- Fix: attendee status filter uses current user’s attendee `participantStatus == .accepted` instead of unavailable APIs.
|
||||
- Concurrency: mark `requestAccess()` and `reloadCalendars()` as `@MainActor` to satisfy strict concurrency checks.
|
||||
- Dev: add Makefile with `build-debug`, `build-release`, and `package` targets; produce versioned ZIP + SHA-256.
|
||||
|
||||
## [1.2.0] - 2024-09-29
|
||||
- Feature: multi-route mirroring, overlap modes, merge gaps, work hours filter, CLI support, export/import settings.
|
46
Makefile
Normal file
46
Makefile
Normal 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"
|
||||
|
@@ -14,11 +14,19 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
|
||||
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
||||
|
||||
## Build (macOS)
|
||||
Option A — Xcode
|
||||
1. Open `BusyMirror.xcodeproj` in Xcode.
|
||||
2. Select the BusyMirror scheme → My Mac.
|
||||
3. Product → Build.
|
||||
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
|
||||
|
||||
Option B — Makefile (reproducible)
|
||||
- Build Release: `make build-release`
|
||||
- Package ZIP: `make package` (creates `BusyMirror-<version>-macOS.zip` + `.sha256`)
|
||||
- Built app: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||||
|
||||
See `CHANGELOG.md` for notable changes.
|
||||
|
||||
## Roadmap
|
||||
See [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
|
24
ReleaseNotes-1.2.3.md
Normal file
24
ReleaseNotes-1.2.3.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# BusyMirror 1.2.3 — 2025-10-10
|
||||
|
||||
This release focuses on reliable settings persistence and quality-of-life fixes from the 1.2.1 hotfix.
|
||||
|
||||
Highlights
|
||||
- Settings persist between runs: autosave key options on change; restore on launch.
|
||||
- Source/Target selection is remembered using calendar IDs and rehydrated into UI indices.
|
||||
|
||||
Fixes and improvements
|
||||
- Save on change for: days back/forward, default merge gap, privacy/copy notes, all-day, accepted-only, overlap mode, title/placeholder prefixes, auto-delete.
|
||||
- Restore saved `selectedSourceID` and `selectedTargetIDs` and rebuild index selections.
|
||||
- Keep backward compatibility with older saved payloads.
|
||||
- Version bump to 1.2.3 (build 5).
|
||||
|
||||
Included from 1.2.1
|
||||
- Reinitialize `EKEventStore` after permission grant to avoid “Loaded 0 calendars”.
|
||||
- Use attendee `participantStatus == .accepted` for accepted-only filter.
|
||||
- Mark `requestAccess()` and `reloadCalendars()` as `@MainActor`.
|
||||
- Makefile for reproducible builds and packaging.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.3-macOS.zip and .sha256
|
||||
|
11
ReleaseNotes-1.2.4.md
Normal file
11
ReleaseNotes-1.2.4.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# BusyMirror 1.2.4 — 2025-10-10
|
||||
|
||||
Bugfix release improving route-driven mirroring.
|
||||
|
||||
Fixes
|
||||
- Mirror Now is enabled when routes are defined, even if nothing is checked in the main window. This allows fully route-driven runs without requiring a temporary manual selection.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.2.4-macOS.zip and .sha256
|
||||
|
17
ReleaseNotes-1.3.0.md
Normal file
17
ReleaseNotes-1.3.0.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# BusyMirror 1.3.0 — 2025-10-10
|
||||
|
||||
New
|
||||
- Mark Private option: mirror events with your prefix + real title while marking them Private on supported servers (e.g., Exchange). Co‑workers see the time block but not the details.
|
||||
- Per-route and global toggles for Mark Private; persists in settings and export/import.
|
||||
|
||||
Fixes & improvements
|
||||
- More reliable calendar loading after permission grant (reinit EKEventStore).
|
||||
- Concurrency: `@MainActor` on permission/refresh methods.
|
||||
- Accepted‑only filter via current user attendee `participantStatus`.
|
||||
- Settings autosave and restore (including source/target selections by IDs).
|
||||
- Mirror Now enabled when calendars available; routes or manual selection used as appropriate.
|
||||
|
||||
Build
|
||||
- `make build-release`
|
||||
- `make package` → BusyMirror-1.3.0-macOS.zip and .sha256
|
||||
|
Reference in New Issue
Block a user