Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ae396da |
@@ -40,16 +40,17 @@ BusyMirror/
|
|||||||
├── MirrorEngine.swift # EventKit mirror engine (read, deduplicate, merge, create/update/delete)
|
├── MirrorEngine.swift # EventKit mirror engine (read, deduplicate, merge, create/update/delete)
|
||||||
├── MirrorConfig.swift # Configuration struct passed to the engine
|
├── MirrorConfig.swift # Configuration struct passed to the engine
|
||||||
├── MirrorUtils.swift # URL builders, mirror detection, calendar labels
|
├── MirrorUtils.swift # URL builders, mirror detection, calendar labels
|
||||||
├── BlockMath.swift # Block merging, gap calculation, overlap logic
|
├── BlockMath.swift # Block merging, gap calculation, overlap logic (Block.span factory)
|
||||||
├── EventFilters.swift # Work-hours, title, and organizer filters
|
├── EventFilters.swift # Work-hours, title, and organizer filters
|
||||||
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
├── MenuBarSupport.swift # `BusyMirrorAppController` (state coordinator) + menu bar view
|
||||||
|
├── AppLogStore.swift # File-backed log store with rotation (AppLogStore enum)
|
||||||
├── Info.plist # LSUIElement, calendar usage descriptions
|
├── Info.plist # LSUIElement, calendar usage descriptions
|
||||||
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
├── BusyMirror.entitlements # App sandbox + calendar access entitlement
|
||||||
└── Assets.xcassets/ # AppIcon set and accent color
|
└── Assets.xcassets/ # AppIcon set and accent color
|
||||||
|
|
||||||
BusyMirror.xcodeproj/ # Xcode project
|
BusyMirror.xcodeproj/ # Xcode project (PBXFileSystemSynchronizedRootGroup — new .swift files are auto-included)
|
||||||
BusyMirrorTests/ # Empty (no tests implemented)
|
BusyMirrorTests/ # Unit tests: BlockMathTests, EventFiltersTests, MirrorUtilsTests (45 tests)
|
||||||
BusyMirrorUITests/ # Empty (no tests implemented)
|
BusyMirrorUITests/ # UI tests (empty)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability.
|
**Architecture note:** `ContentView.swift` handles the SwiftUI view hierarchy, settings serialization, CLI argument parsing, `launchd` scheduling, and logging. The EventKit mirror engine lives in `MirrorEngine.swift` and is invoked from `ContentView` via `makeEngine()`. Pure helper logic (block math, filters, URL utilities) has been extracted into standalone files for testability.
|
||||||
@@ -142,6 +143,7 @@ Scheduled runs are implemented by generating a `launchd` plist in `~/Library/Lau
|
|||||||
| `BusyMirror/EventFilters.swift` | Work-hours, title, and organizer filters |
|
| `BusyMirror/EventFilters.swift` | Work-hours, title, and organizer filters |
|
||||||
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
| `BusyMirror/BusyMirrorApp.swift` | App struct, window scene, menu-bar extra |
|
||||||
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
| `BusyMirror/MenuBarSupport.swift` | `@MainActor` app controller + menu bar SwiftUI view |
|
||||||
|
| `BusyMirror/AppLogStore.swift` | File-backed log with rotation (`~/Library/Logs/BusyMirror/`) |
|
||||||
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
|
| `BusyMirror/Info.plist` | `LSUIElement`, calendar usage descriptions |
|
||||||
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
|
| `BusyMirror/BusyMirror.entitlements` | Sandbox + calendar entitlement |
|
||||||
| `Makefile` | Reproducible build, sign, and package targets |
|
| `Makefile` | Reproducible build, sign, and package targets |
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
9fd864e05f5091cbc23864ff226e7d909119a22e019584279a95d206b935cf15
|
||||||
@@ -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 = 19;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
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.5.0;
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
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.5.0;
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppLogStore {
|
||||||
|
private static let queue = DispatchQueue(label: "BusyMirror.log.store")
|
||||||
|
private static let maxLogSizeBytes: UInt64 = 1_000_000
|
||||||
|
|
||||||
|
static let logDirectoryURL: URL = {
|
||||||
|
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||||
|
return base.appendingPathComponent("Logs/BusyMirror", isDirectory: true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
|
||||||
|
private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false)
|
||||||
|
static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false)
|
||||||
|
static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false)
|
||||||
|
|
||||||
|
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func append(_ message: String) {
|
||||||
|
let line = "[\(timestampFormatter.string(from: Date()))] \(message)\n"
|
||||||
|
queue.async {
|
||||||
|
let fm = FileManager.default
|
||||||
|
do {
|
||||||
|
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
if let attrs = try? fm.attributesOfItem(atPath: logFileURL.path),
|
||||||
|
let size = attrs[.size] as? NSNumber,
|
||||||
|
size.uint64Value >= maxLogSizeBytes {
|
||||||
|
try? fm.removeItem(at: archivedLogFileURL)
|
||||||
|
try? fm.moveItem(at: logFileURL, to: archivedLogFileURL)
|
||||||
|
}
|
||||||
|
if !fm.fileExists(atPath: logFileURL.path) {
|
||||||
|
fm.createFile(atPath: logFileURL.path, contents: nil)
|
||||||
|
}
|
||||||
|
guard let data = line.data(using: .utf8) else { return }
|
||||||
|
// Use the throwing initialiser so we don't silently swallow
|
||||||
|
// an inaccessible file — the outer catch handles it.
|
||||||
|
let handle = try FileHandle(forWritingTo: logFileURL)
|
||||||
|
defer { try? handle.close() }
|
||||||
|
try handle.seekToEnd()
|
||||||
|
try handle.write(contentsOf: data)
|
||||||
|
} catch {
|
||||||
|
// Logging must never break the app's main behavior.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ struct Block: Hashable {
|
|||||||
let label: String? // source title (for dry-run / non-private)
|
let label: String? // source title (for dry-run / non-private)
|
||||||
let notes: String? // source notes (for optional copy)
|
let notes: String? // source notes (for optional copy)
|
||||||
let occurrence: Date? // occurrenceDate for recurring instances
|
let occurrence: Date? // occurrenceDate for recurring instances
|
||||||
|
|
||||||
|
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
|
||||||
|
static func span(start: Date, end: Date) -> Block {
|
||||||
|
Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-dup blocks by occurrence (preferred) or by time range
|
// De-dup blocks by occurrence (preferred) or by time range
|
||||||
@@ -30,14 +35,14 @@ func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] {
|
|||||||
guard !blocks.isEmpty else { return [] }
|
guard !blocks.isEmpty else { return [] }
|
||||||
let sorted = blocks.sorted { $0.start < $1.start }
|
let sorted = blocks.sorted { $0.start < $1.start }
|
||||||
var out: [Block] = []
|
var out: [Block] = []
|
||||||
var cur = Block(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
var cur = Block.span(start: sorted[0].start, end: sorted[0].end)
|
||||||
for b in sorted.dropFirst() {
|
for b in sorted.dropFirst() {
|
||||||
let gap = b.start.timeIntervalSince(cur.end) / 60.0
|
let gap = b.start.timeIntervalSince(cur.end) / 60.0
|
||||||
if gap <= Double(gapMinutes) {
|
if gap <= Double(gapMinutes) {
|
||||||
if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) }
|
if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) }
|
||||||
} else {
|
} else {
|
||||||
out.append(cur)
|
out.append(cur)
|
||||||
cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
cur = Block.span(start: b.start, end: b.end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.append(cur)
|
out.append(cur)
|
||||||
@@ -55,21 +60,21 @@ func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] {
|
func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] {
|
||||||
if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
if mergedSegs.isEmpty { return [Block.span(start: block.start, end: block.end)] }
|
||||||
var segs: [Block] = []
|
var segs: [Block] = []
|
||||||
for s in mergedSegs where s.end > block.start && s.start < block.end {
|
for s in mergedSegs where s.end > block.start && s.start < block.end {
|
||||||
let ss = max(s.start, block.start)
|
let ss = max(s.start, block.start)
|
||||||
let ee = min(s.end, block.end)
|
let ee = min(s.end, block.end)
|
||||||
if ee > ss { segs.append(Block(start: ss, end: ee, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if ee > ss { segs.append(Block.span(start: ss, end: ee)) }
|
||||||
}
|
}
|
||||||
if segs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
if segs.isEmpty { return [Block.span(start: block.start, end: block.end)] }
|
||||||
let merged = coalesce(segs)
|
let merged = coalesce(segs)
|
||||||
var gaps: [Block] = []
|
var gaps: [Block] = []
|
||||||
var prevEnd = block.start
|
var prevEnd = block.start
|
||||||
for s in merged {
|
for s in merged {
|
||||||
if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if s.start > prevEnd { gaps.append(Block.span(start: prevEnd, end: s.start)) }
|
||||||
if s.end > prevEnd { prevEnd = s.end }
|
if s.end > prevEnd { prevEnd = s.end }
|
||||||
}
|
}
|
||||||
if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if prevEnd < block.end { gaps.append(Block.span(start: prevEnd, end: block.end)) }
|
||||||
return gaps
|
return gaps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,55 +2,6 @@ import SwiftUI
|
|||||||
import EventKit
|
import EventKit
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
private let SKIP_ALL_DAY_DEFAULT = true
|
|
||||||
|
|
||||||
private enum AppLogStore {
|
|
||||||
private static let queue = DispatchQueue(label: "BusyMirror.log.store")
|
|
||||||
private static let maxLogSizeBytes: UInt64 = 1_000_000
|
|
||||||
|
|
||||||
static let logDirectoryURL: URL = {
|
|
||||||
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
|
||||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
|
||||||
return base.appendingPathComponent("Logs/BusyMirror", isDirectory: true)
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let logFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.log", isDirectory: false)
|
|
||||||
private static let archivedLogFileURL = logDirectoryURL.appendingPathComponent("BusyMirror.previous.log", isDirectory: false)
|
|
||||||
static let launchdStdoutURL = logDirectoryURL.appendingPathComponent("launchd.stdout.log", isDirectory: false)
|
|
||||||
static let launchdStderrURL = logDirectoryURL.appendingPathComponent("launchd.stderr.log", isDirectory: false)
|
|
||||||
|
|
||||||
private static let timestampFormatter: ISO8601DateFormatter = {
|
|
||||||
let f = ISO8601DateFormatter()
|
|
||||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func append(_ message: String) {
|
|
||||||
let line = "[\(timestampFormatter.string(from: Date()))] \(message)\n"
|
|
||||||
queue.async {
|
|
||||||
let fm = FileManager.default
|
|
||||||
do {
|
|
||||||
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
|
|
||||||
if let attrs = try? fm.attributesOfItem(atPath: logFileURL.path),
|
|
||||||
let size = attrs[.size] as? NSNumber,
|
|
||||||
size.uint64Value >= maxLogSizeBytes {
|
|
||||||
try? fm.removeItem(at: archivedLogFileURL)
|
|
||||||
try? fm.moveItem(at: logFileURL, to: archivedLogFileURL)
|
|
||||||
}
|
|
||||||
if !fm.fileExists(atPath: logFileURL.path) {
|
|
||||||
fm.createFile(atPath: logFileURL.path, contents: nil)
|
|
||||||
}
|
|
||||||
guard let data = line.data(using: .utf8),
|
|
||||||
let handle = FileHandle(forWritingAtPath: logFileURL.path) else { return }
|
|
||||||
defer { handle.closeFile() }
|
|
||||||
handle.seekToEndOfFile()
|
|
||||||
handle.write(data)
|
|
||||||
} catch {
|
|
||||||
// Logging must never break the app's main behavior.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
enum OverlapMode: String, CaseIterable, Identifiable, Codable {
|
||||||
case allow, skipCovered, fillGaps
|
case allow, skipCovered, fillGaps
|
||||||
@@ -162,6 +113,8 @@ struct ContentView: View {
|
|||||||
@State private var confirmCleanup = false
|
@State private var confirmCleanup = false
|
||||||
@State private var mirrorTask: Task<Void, Never>? = nil
|
@State private var mirrorTask: Task<Void, Never>? = nil
|
||||||
@State private var progressText: String? = nil
|
@State private var progressText: String? = nil
|
||||||
|
/// Token for the EKEventStoreChanged observer; nil until calendar access is granted.
|
||||||
|
@State private var storeObserver: NSObjectProtocol? = nil
|
||||||
// Run-session guard: prevents the same source event from being mirrored
|
// Run-session guard: prevents the same source event from being mirrored
|
||||||
// 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.
|
||||||
@@ -1345,6 +1298,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
appController.setMainWindowVisible(false)
|
appController.setMainWindowVisible(false)
|
||||||
|
unregisterStoreObserver()
|
||||||
}
|
}
|
||||||
// Persist key settings whenever they change, to ensure restore between runs
|
// Persist key settings whenever they change, to ensure restore between runs
|
||||||
.onChange(of: appController.syncRequestToken) { _ in
|
.onChange(of: appController.syncRequestToken) { _ in
|
||||||
@@ -1497,7 +1451,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if CommandLine.arguments.contains("--exit") || isCLIRun {
|
// Exit only when --exit is explicitly passed. isCLIRun alone does
|
||||||
|
// not force termination so that advanced users can open the UI with
|
||||||
|
// --routes to pre-populate a run without auto-quitting.
|
||||||
|
if CommandLine.arguments.contains("--exit") {
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1499,9 @@ struct ContentView: View {
|
|||||||
func reloadCalendars(forceResetStore: Bool = false) {
|
func reloadCalendars(forceResetStore: Bool = false) {
|
||||||
if forceResetStore {
|
if forceResetStore {
|
||||||
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
// EventKit can cache stale/inactive calendars; recreate store for a hard refresh.
|
||||||
|
// Unregister the existing EKEventStoreChanged observer first — it targets the
|
||||||
|
// old store object and would never fire again after the store is replaced.
|
||||||
|
unregisterStoreObserver()
|
||||||
store = EKEventStore()
|
store = EKEventStore()
|
||||||
}
|
}
|
||||||
let fetched = store.calendars(for: .event)
|
let fetched = store.calendars(for: .event)
|
||||||
@@ -1556,35 +1516,57 @@ struct ContentView: View {
|
|||||||
saveSettingsToDefaults()
|
saveSettingsToDefaults()
|
||||||
}
|
}
|
||||||
log("Loaded \(calendars.count) calendars.")
|
log("Loaded \(calendars.count) calendars.")
|
||||||
|
// Register for live calendar-store changes the first time we have access,
|
||||||
|
// so the calendar list stays up-to-date without pressing "Refresh".
|
||||||
|
if storeObserver == nil {
|
||||||
|
storeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .EKEventStoreChanged,
|
||||||
|
object: store,
|
||||||
|
queue: .main
|
||||||
|
) { [self] _ in
|
||||||
|
// Skip silent background refreshes while a sync is running to
|
||||||
|
// avoid interfering with an in-progress mirror operation.
|
||||||
|
guard !isRunning else { return }
|
||||||
|
reloadCalendars()
|
||||||
|
}
|
||||||
|
}
|
||||||
handlePendingMenuBarSyncIfNeeded()
|
handlePendingMenuBarSyncIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Export / Import Settings
|
@MainActor
|
||||||
private struct SettingsPayload: Codable {
|
private func unregisterStoreObserver() {
|
||||||
var daysBack: Int
|
if let token = storeObserver {
|
||||||
var daysForward: Int
|
NotificationCenter.default.removeObserver(token)
|
||||||
var mergeGapHours: Int
|
storeObserver = nil
|
||||||
var hideDetails: Bool
|
}
|
||||||
var copyDescription: Bool
|
}
|
||||||
var mirrorAllDay: Bool
|
|
||||||
var filterByWorkHours: Bool = false
|
// MARK: - Export / Import Settings
|
||||||
var workHoursStart: Int = 9
|
private struct SettingsPayload: Codable {
|
||||||
var workHoursEnd: Int = 17
|
var daysBack: Int
|
||||||
var excludedTitleFilters: [String] = []
|
var daysForward: Int
|
||||||
var excludedOrganizerFilters: [String] = []
|
var mergeGapHours: Int
|
||||||
var mirrorAcceptedOnly: Bool = false
|
var hideDetails: Bool
|
||||||
var overlapMode: String
|
var copyDescription: Bool
|
||||||
var titlePrefix: String
|
var mirrorAllDay: Bool
|
||||||
var placeholderTitle: String
|
var filterByWorkHours: Bool = false
|
||||||
var autoDeleteMissing: Bool
|
var workHoursStart: Int = 9
|
||||||
var routes: [Route]
|
var workHoursEnd: Int = 17
|
||||||
// UI selections (optional for backward compatibility)
|
var excludedTitleFilters: [String] = []
|
||||||
var selectedSourceID: String? = nil
|
var excludedOrganizerFilters: [String] = []
|
||||||
var selectedTargetIDs: [String]? = nil
|
var mirrorAcceptedOnly: Bool = false
|
||||||
// optional metadata
|
var overlapMode: String
|
||||||
var appVersion: String?
|
var titlePrefix: String
|
||||||
var exportedAt: Date = Date()
|
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 {
|
private func makeSnapshot() -> SettingsPayload {
|
||||||
SettingsPayload(
|
SettingsPayload(
|
||||||
@@ -1697,11 +1679,9 @@ private struct SettingsPayload: Codable {
|
|||||||
let srcCal = calendars[sIdx]
|
let srcCal = calendars[sIdx]
|
||||||
let targetSet = route.targetIDs.subtracting([srcCal.calendarIdentifier])
|
let targetSet = route.targetIDs.subtracting([srcCal.calendarIdentifier])
|
||||||
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) }
|
||||||
await MainActor.run {
|
// Do NOT mutate sourceIndex / sourceID / targetIDs here: cleanup does
|
||||||
sourceIndex = sIdx
|
// not need to reflect route selections in the UI and doing so causes
|
||||||
sourceID = route.sourceID
|
// jarring picker jumps when iterating over multiple routes.
|
||||||
targetIDs = route.targetIDs
|
|
||||||
}
|
|
||||||
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
await makeEngine().runCleanup(store: store, daysBack: daysBack, daysForward: daysForward, sourceCalendar: srcCal, targetCalendars: targets, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, writeEnabled: writeEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ struct MirrorRecord: Hashable, Codable {
|
|||||||
var lastKnownEndTimestamp: TimeInterval
|
var lastKnownEndTimestamp: TimeInterval
|
||||||
var updatedAt: Date = Date()
|
var updatedAt: Date = Date()
|
||||||
|
|
||||||
|
// updatedAt is intentionally excluded from equality and hashing: it is a
|
||||||
|
// bookkeeping timestamp that changes on every write and should not cause
|
||||||
|
// the mirror index to be marked dirty when the meaningful fields are equal.
|
||||||
|
static func == (lhs: MirrorRecord, rhs: MirrorRecord) -> Bool {
|
||||||
|
lhs.targetCalendarID == rhs.targetCalendarID &&
|
||||||
|
lhs.sourceCalendarID == rhs.sourceCalendarID &&
|
||||||
|
lhs.sourceStableID == rhs.sourceStableID &&
|
||||||
|
lhs.occurrenceTimestamp == rhs.occurrenceTimestamp &&
|
||||||
|
lhs.targetEventIdentifier == rhs.targetEventIdentifier &&
|
||||||
|
lhs.lastKnownStartTimestamp == rhs.lastKnownStartTimestamp &&
|
||||||
|
lhs.lastKnownEndTimestamp == rhs.lastKnownEndTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(targetCalendarID)
|
||||||
|
hasher.combine(sourceCalendarID)
|
||||||
|
hasher.combine(sourceStableID)
|
||||||
|
hasher.combine(occurrenceTimestamp)
|
||||||
|
hasher.combine(targetEventIdentifier)
|
||||||
|
hasher.combine(lastKnownStartTimestamp)
|
||||||
|
hasher.combine(lastKnownEndTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
var sourceKey: String {
|
var sourceKey: String {
|
||||||
sourceOccurrenceKey(
|
sourceOccurrenceKey(
|
||||||
sourceCalID: sourceCalendarID,
|
sourceCalID: sourceCalendarID,
|
||||||
@@ -229,7 +252,7 @@ final class MirrorEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil))
|
occupied.append(Block.span(start: ts, end: te))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
occupied = coalesce(occupied)
|
occupied = coalesce(occupied)
|
||||||
@@ -360,12 +383,10 @@ final class MirrorEngine {
|
|||||||
existing.notes = notes
|
existing.notes = notes
|
||||||
existing.url = desiredURL
|
existing.url = desiredURL
|
||||||
do {
|
do {
|
||||||
try await MainActor.run {
|
try store.save(existing, span: .thisEvent, commit: true)
|
||||||
try store.save(existing, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
||||||
rememberMirrorEvent(existing, for: blk)
|
rememberMirrorEvent(existing, for: blk)
|
||||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)])
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
updated += 1
|
updated += 1
|
||||||
} catch {
|
} catch {
|
||||||
@@ -414,13 +435,11 @@ final class MirrorEngine {
|
|||||||
newEv.url = desiredURL
|
newEv.url = desiredURL
|
||||||
newEv.availability = .busy
|
newEv.availability = .busy
|
||||||
do {
|
do {
|
||||||
try await MainActor.run {
|
try store.save(newEv, span: .thisEvent, commit: true)
|
||||||
try store.save(newEv, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
created += 1
|
created += 1
|
||||||
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
||||||
rememberMirrorEvent(newEv, for: blk)
|
rememberMirrorEvent(newEv, for: blk)
|
||||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
occupied = coalesce(occupied + [Block.span(start: blk.start, end: blk.end)])
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
} catch {
|
} catch {
|
||||||
log("Save failed: \(error.localizedDescription)")
|
log("Save failed: \(error.localizedDescription)")
|
||||||
@@ -479,9 +498,7 @@ final class MirrorEngine {
|
|||||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
try await MainActor.run {
|
try store.remove(candidate, span: .thisEvent, commit: true)
|
||||||
try store.remove(candidate, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
removed += 1
|
removed += 1
|
||||||
} catch {
|
} catch {
|
||||||
log("Delete failed: \(error.localizedDescription)")
|
log("Delete failed: \(error.localizedDescription)")
|
||||||
@@ -543,9 +560,7 @@ final class MirrorEngine {
|
|||||||
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
|
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
try await MainActor.run {
|
try store.remove(ev, span: .thisEvent, commit: true)
|
||||||
try store.remove(ev, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
removed += 1
|
removed += 1
|
||||||
} catch {
|
} catch {
|
||||||
log("Delete failed: \(error.localizedDescription)")
|
log("Delete failed: \(error.localizedDescription)")
|
||||||
@@ -598,12 +613,11 @@ final class MirrorEngine {
|
|||||||
log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)")
|
log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)")
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
try await MainActor.run {
|
try store.remove(ev, span: .thisEvent, commit: true)
|
||||||
try store.remove(ev, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
delCount += 1
|
delCount += 1
|
||||||
|
} catch {
|
||||||
|
log("Delete failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
catch { log("Delete failed: \(error.localizedDescription)") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log("[Cleanup \(tgt.title)] deleted=\(delCount)")
|
log("[Cleanup \(tgt.title)] deleted=\(delCount)")
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ func mirrorTimeKey(start: Date, end: Date) -> String {
|
|||||||
func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: String?, occurrence: Date?, start: Date, end: Date) -> URL? {
|
func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: String?, occurrence: Date?, start: Date, end: Date) -> URL? {
|
||||||
let sourceID = sourceStableID ?? ""
|
let sourceID = sourceStableID ?? ""
|
||||||
let occ = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
let occ = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
|
// Percent-encode IDs so that any embedded ";" doesn't corrupt the
|
||||||
|
// semicolon-delimited path when the URL is later parsed.
|
||||||
let parts = [
|
let parts = [
|
||||||
targetCalID,
|
mirrorURLComponentEncode(targetCalID),
|
||||||
sourceCalID,
|
mirrorURLComponentEncode(sourceCalID),
|
||||||
sourceID,
|
mirrorURLComponentEncode(sourceID),
|
||||||
occ,
|
occ,
|
||||||
String(start.timeIntervalSince1970),
|
String(start.timeIntervalSince1970),
|
||||||
String(end.timeIntervalSince1970)
|
String(end.timeIntervalSince1970)
|
||||||
@@ -68,7 +70,9 @@ func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: St
|
|||||||
var components = URLComponents()
|
var components = URLComponents()
|
||||||
components.scheme = "mirror"
|
components.scheme = "mirror"
|
||||||
components.host = "x"
|
components.host = "x"
|
||||||
components.path = "/" + parts.joined(separator: ";")
|
// Use percentEncodedPath so URLComponents does not re-encode the already
|
||||||
|
// percent-encoded IDs (double-encoding would break round-trip parsing).
|
||||||
|
components.percentEncodedPath = "/" + parts.joined(separator: ";")
|
||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
All notable changes to BusyMirror will be documented in this file.
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Mirror index dirtied on every sync**: `MirrorRecord` used synthesized `Equatable` which included `updatedAt: Date = Date()`. Because `updatedAt` is set to the current time whenever a record is constructed, the comparison used to detect changes always returned "not equal", causing `UserDefaults` to be written on every sync run even when nothing changed. A custom `==` / `hash(into:)` now excludes `updatedAt`. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift))
|
||||||
|
- **Mirror URL corruption with special characters in calendar IDs**: `buildMirrorURL` placed raw calendar and source IDs into the URL path without percent-encoding them. If any ID contained the `;` separator character the resulting URL would be mis-parsed on the next sync. `mirrorURLComponentEncode` (which already existed and was tested) is now called on all ID fields before they are joined. The path is set via `percentEncodedPath` to prevent `URLComponents` from double-encoding the already-encoded values. ([MirrorUtils.swift](BusyMirror/MirrorUtils.swift))
|
||||||
|
- **Dead constant**: removed unused `SKIP_ALL_DAY_DEFAULT = true` from `ContentView.swift`.
|
||||||
|
- **Deprecated `FileHandle` API**: replaced `FileHandle(forWritingAtPath:)` + `handle.closeFile()` with the modern throwing `FileHandle(forWritingTo:)`, `handle.seekToEnd()`, and `handle.write(contentsOf:)` in `AppLogStore`. ([AppLogStore.swift](BusyMirror/AppLogStore.swift))
|
||||||
|
- **Cleanup jumps calendar picker**: `runCleanupForRoute` was mutating `sourceIndex`, `sourceID`, and `targetIDs` during route cleanup, visibly shifting the picker in the UI. Cleanup does not need to update the UI selection; those mutations are removed.
|
||||||
|
- **`--exit` flag redundancy**: `NSApp.terminate` was called whenever `isCLIRun` was true, making `--exit` a no-op. The app now exits only when `--exit` is explicitly passed, so `--routes` / `--run-saved-routes` can be used without forcing termination.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Live calendar refresh**: the calendar list now updates automatically when the system calendar database changes (`EKEventStoreChanged` notification), removing the need to press "Refresh Calendars" after adding or removing a calendar. The observer is unregistered on view disappear and re-registered when the `EKEventStore` is recreated. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`AppLogStore` extracted**: moved from an inline private enum in `ContentView.swift` to its own file `AppLogStore.swift` for easier navigation. ([AppLogStore.swift](BusyMirror/AppLogStore.swift))
|
||||||
|
- **`Block.span` factory**: added `Block.span(start:end:)` to replace the repetitive `Block(start:end:srcStableID:nil:label:nil:notes:nil:occurrence:nil)` construction pattern throughout `BlockMath.swift` and `MirrorEngine.swift`. ([BlockMath.swift](BusyMirror/BlockMath.swift))
|
||||||
|
- **Removed redundant `MainActor.run` wrappers**: `MirrorEngine` is `@MainActor`; wrapping `store.save` / `store.remove` in `try await MainActor.run { }` was unnecessary and added overhead. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift))
|
||||||
|
- **`SettingsPayload` indentation**: the nested struct was de-dented to column 0 inside `ContentView`, making it look like a top-level type. Indentation is now consistent with the surrounding members.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- Bump version to **1.5.1** (build **20**).
|
||||||
|
|
||||||
## [1.5.0] - 2026-05-27
|
## [1.5.0] - 2026-05-27
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# BusyMirror 1.5.1
|
||||||
|
|
||||||
|
## Bug fixes
|
||||||
|
|
||||||
|
- **Mirror index written on every sync** — `MirrorRecord`'s synthesized equality check included an `updatedAt` timestamp that is always set to the current date when a record is constructed. This meant every sync run marked the index as dirty and rewrote it to `UserDefaults`, even when no mirror events changed. Fixed with a custom `==` that ignores `updatedAt`.
|
||||||
|
|
||||||
|
- **Mirror URLs corrupted by special characters in calendar IDs** — Calendar and source IDs placed into the `mirror://` URL were not percent-encoded before joining with `;`. An ID containing `;` would cause the URL to be mis-parsed on the next sync, potentially losing the link between a placeholder and its source event. IDs are now encoded with `mirrorURLComponentEncode` (already present and tested since 1.4.0) and the URL path is assigned via `percentEncodedPath` to prevent double-encoding.
|
||||||
|
|
||||||
|
- **Calendar picker jumped during route cleanup** — Running "Cleanup Placeholders" over saved routes changed the source/target picker selection for each route. Cleanup no longer mutates the UI selection.
|
||||||
|
|
||||||
|
- **`--exit` flag was always implied** — Using `--routes` or `--run-saved-routes` always terminated the app, making `--exit` redundant. The app now exits only when `--exit` is explicitly passed.
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Live calendar refresh** — The calendar list now updates automatically when the system calendar database changes (new account added, calendar renamed, etc.), without requiring a manual "Refresh Calendars" press.
|
||||||
|
|
||||||
|
- `AppLogStore` extracted into its own file; deprecated `FileHandle` API replaced with the modern throwing variant.
|
||||||
|
|
||||||
|
- `Block.span(start:end:)` convenience factory added to `BlockMath`, eliminating repetitive nil-field construction.
|
||||||
|
|
||||||
|
- Redundant `MainActor.run {}` wrappers removed from `MirrorEngine` (already running on `@MainActor`).
|
||||||
Reference in New Issue
Block a user