Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
d1fbd4c81f | |||
6ef0feecc1 | |||
aac4de3fb3 | |||
8f80a5f672 | |||
ae40b42e6f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ ExportOptions.plist
|
|||||||
*.swp
|
*.swp
|
||||||
*.zip
|
*.zip
|
||||||
*.sha256
|
*.sha256
|
||||||
|
dist/
|
||||||
|
@@ -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 = 6;
|
||||||
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.4;
|
||||||
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 = 6;
|
||||||
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.4;
|
||||||
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;
|
||||||
|
@@ -128,6 +128,11 @@ struct ContentView: View {
|
|||||||
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
@AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
||||||
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
|
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
|
||||||
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
|
@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 {
|
var overlapMode: OverlapMode {
|
||||||
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
|
get { OverlapMode(rawValue: overlapModeRaw) ?? .allow }
|
||||||
nonmutating set { overlapModeRaw = newValue.rawValue }
|
nonmutating set { overlapModeRaw = newValue.rawValue }
|
||||||
@@ -153,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
|
||||||
@@ -162,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] = [:]
|
||||||
@@ -395,6 +419,8 @@ 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)
|
||||||
|
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
|
||||||
|
.disabled(isRunning)
|
||||||
Picker("Overlap mode", selection: $overlapModeRaw) {
|
Picker("Overlap mode", selection: $overlapModeRaw) {
|
||||||
ForEach(OverlapMode.allCases) { mode in
|
ForEach(OverlapMode.allCases) { mode in
|
||||||
Text(mode.rawValue).tag(mode.rawValue)
|
Text(mode.rawValue).tag(mode.rawValue)
|
||||||
@@ -418,6 +444,53 @@ 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)
|
||||||
|
|
||||||
@@ -477,7 +550,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty)
|
.disabled(isRunning || calendars.isEmpty || (routes.isEmpty && targetSelections.isEmpty))
|
||||||
|
|
||||||
Button("Cleanup Placeholders") {
|
Button("Cleanup Placeholders") {
|
||||||
if writeEnabled {
|
if writeEnabled {
|
||||||
@@ -583,18 +656,33 @@ struct ContentView: View {
|
|||||||
tryRunCLIIfPresent()
|
tryRunCLIIfPresent()
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
}
|
}
|
||||||
|
// 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
|
.onChange(of: sourceIndex) { newValue in
|
||||||
// Track selected source by persistent ID and ensure it is not a target
|
// Track selected source by persistent ID and ensure it is not a target
|
||||||
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier }
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
|
saveSettingsToDefaults()
|
||||||
}
|
}
|
||||||
.onChange(of: targetSelections) { _ in
|
.onChange(of: targetSelections) { _ in
|
||||||
// If the new source is accidentally included, drop it
|
// If the new source is accidentally included, drop it
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
|
saveSettingsToDefaults()
|
||||||
}
|
}
|
||||||
.onChange(of: targetIDs) { _ in
|
.onChange(of: targetIDs) { _ in
|
||||||
// If IDs contain the source’s ID, drop it
|
// If IDs contain the source’s ID, drop it
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
|
saveSettingsToDefaults()
|
||||||
}
|
}
|
||||||
.onChange(of: routes) { _ in
|
.onChange(of: routes) { _ in
|
||||||
saveSettingsToDefaults()
|
saveSettingsToDefaults()
|
||||||
@@ -685,13 +773,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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,13 +792,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)
|
||||||
@@ -759,7 +857,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
|
||||||
@@ -775,6 +903,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)
|
||||||
|
|
||||||
@@ -1033,11 +1170,19 @@ private struct SettingsPayload: Codable {
|
|||||||
var hideDetails: Bool
|
var hideDetails: Bool
|
||||||
var copyDescription: Bool
|
var copyDescription: Bool
|
||||||
var mirrorAllDay: 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 overlapMode: String
|
||||||
var titlePrefix: String
|
var titlePrefix: String
|
||||||
var placeholderTitle: String
|
var placeholderTitle: String
|
||||||
var autoDeleteMissing: Bool
|
var autoDeleteMissing: Bool
|
||||||
var routes: [Route]
|
var routes: [Route]
|
||||||
|
// UI selections (optional for backward compatibility)
|
||||||
|
var selectedSourceID: String? = nil
|
||||||
|
var selectedTargetIDs: [String]? = nil
|
||||||
// optional metadata
|
// optional metadata
|
||||||
var appVersion: String?
|
var appVersion: String?
|
||||||
var exportedAt: Date = Date()
|
var exportedAt: Date = Date()
|
||||||
@@ -1051,11 +1196,18 @@ private struct SettingsPayload: Codable {
|
|||||||
hideDetails: hideDetails,
|
hideDetails: hideDetails,
|
||||||
copyDescription: copyDescription,
|
copyDescription: copyDescription,
|
||||||
mirrorAllDay: mirrorAllDay,
|
mirrorAllDay: mirrorAllDay,
|
||||||
|
filterByWorkHours: filterByWorkHours,
|
||||||
|
workHoursStart: workHoursStart,
|
||||||
|
workHoursEnd: workHoursEnd,
|
||||||
|
excludedTitleFilters: excludedTitleFilterList,
|
||||||
|
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||||||
overlapMode: overlapMode.rawValue,
|
overlapMode: overlapMode.rawValue,
|
||||||
titlePrefix: titlePrefix,
|
titlePrefix: titlePrefix,
|
||||||
placeholderTitle: placeholderTitle,
|
placeholderTitle: placeholderTitle,
|
||||||
autoDeleteMissing: autoDeleteMissing,
|
autoDeleteMissing: autoDeleteMissing,
|
||||||
routes: routes,
|
routes: routes,
|
||||||
|
selectedSourceID: sourceID,
|
||||||
|
selectedTargetIDs: Array(targetIDs).sorted(),
|
||||||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||||
exportedAt: Date()
|
exportedAt: Date()
|
||||||
)
|
)
|
||||||
@@ -1069,11 +1221,22 @@ private struct SettingsPayload: Codable {
|
|||||||
hideDetails = s.hideDetails
|
hideDetails = s.hideDetails
|
||||||
copyDescription = s.copyDescription
|
copyDescription = s.copyDescription
|
||||||
mirrorAllDay = s.mirrorAllDay
|
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
|
overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow
|
||||||
titlePrefix = s.titlePrefix
|
titlePrefix = s.titlePrefix
|
||||||
placeholderTitle = s.placeholderTitle
|
placeholderTitle = s.placeholderTitle
|
||||||
autoDeleteMissing = s.autoDeleteMissing
|
autoDeleteMissing = s.autoDeleteMissing
|
||||||
routes = s.routes
|
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() {
|
private func exportSettings() {
|
||||||
@@ -1143,12 +1306,44 @@ private struct SettingsPayload: Codable {
|
|||||||
do {
|
do {
|
||||||
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
|
let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData)
|
||||||
routes = decodedRoutes
|
routes = decodedRoutes
|
||||||
|
clampWorkHours()
|
||||||
saveSettingsToDefaults() // upgrade stored format
|
saveSettingsToDefaults() // upgrade stored format
|
||||||
} catch {
|
} catch {
|
||||||
log("✗ Failed to load routes: \(error.localizedDescription)")
|
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)
|
||||||
|
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 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.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).
|
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
||||||
|
|
||||||
## Build (macOS)
|
## Build (macOS)
|
||||||
|
Option A — Xcode
|
||||||
1. Open `BusyMirror.xcodeproj` in Xcode.
|
1. Open `BusyMirror.xcodeproj` in Xcode.
|
||||||
2. Select the BusyMirror scheme → My Mac.
|
2. Select the BusyMirror scheme → My Mac.
|
||||||
3. Product → Build.
|
3. Product → Build.
|
||||||
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
|
4. Product → Archive → Distribute App → Copy App (no notarization) to export a `.app` (or ZIP it for sharing).
|
||||||
|
|
||||||
|
Option B — Makefile (reproducible)
|
||||||
|
- Build Release: `make build-release`
|
||||||
|
- Package ZIP: `make package` (creates `BusyMirror-<version>-macOS.zip` + `.sha256`)
|
||||||
|
- Built app: `build/DerivedData/Build/Products/Release/BusyMirror.app`
|
||||||
|
|
||||||
|
See `CHANGELOG.md` for notable changes.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
See [ROADMAP.md](ROADMAP.md)
|
See [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
|
24
ReleaseNotes-1.2.3.md
Normal file
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
|
||||||
|
|
Reference in New Issue
Block a user