diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 3733bba..c54c730 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.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 = 2; + CURRENT_PROJECT_VERSION = 3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index c753981..1b0424a 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -128,6 +128,10 @@ struct ContentView: View { @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 = "" var overlapMode: OverlapMode { get { OverlapMode(rawValue: overlapModeRaw) ?? .allow } nonmutating set { overlapModeRaw = newValue.rawValue } @@ -153,6 +157,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 @@ -162,6 +174,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] = [:] @@ -418,6 +441,53 @@ 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) @@ -759,7 +829,22 @@ 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 for ev in srcEvents { + 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 @@ -775,6 +860,12 @@ 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)") + } // Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races) srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0) @@ -1033,6 +1124,10 @@ private struct SettingsPayload: Codable { 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 overlapMode: String var titlePrefix: String var placeholderTitle: String @@ -1051,6 +1146,10 @@ private struct SettingsPayload: Codable { hideDetails: hideDetails, copyDescription: copyDescription, mirrorAllDay: mirrorAllDay, + filterByWorkHours: filterByWorkHours, + workHoursStart: workHoursStart, + workHoursEnd: workHoursEnd, + excludedTitleFilters: excludedTitleFilterList, overlapMode: overlapMode.rawValue, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, @@ -1069,11 +1168,16 @@ private struct SettingsPayload: Codable { hideDetails = s.hideDetails copyDescription = s.copyDescription mirrorAllDay = s.mirrorAllDay + filterByWorkHours = s.filterByWorkHours + workHoursStart = s.workHoursStart + workHoursEnd = s.workHoursEnd + excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n") overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow titlePrefix = s.titlePrefix placeholderTitle = s.placeholderTitle autoDeleteMissing = s.autoDeleteMissing routes = s.routes + clampWorkHours() } private func exportSettings() { @@ -1143,12 +1247,44 @@ private struct SettingsPayload: Codable { 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)