Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e9fe5325 | ||
|
|
61cea918a6 | ||
|
|
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.
|
||||||
@@ -126,7 +127,9 @@ BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit
|
|||||||
BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit
|
BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3" --write 1 --exit
|
||||||
```
|
```
|
||||||
|
|
||||||
Relevant flags: `--privacy`, `--copy-notes`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
|
Relevant flags: `--privacy`, `--copy-notes`, `--sync-reminders`, `--all-day`, `--days-forward`, `--days-back`, `--merge-gap-hours`, `--mode`, `--exclude-titles`, `--exclude-organizers`, `--cleanup-only`, `--exit`.
|
||||||
|
|
||||||
|
Diagnostic/query flags (no calendar write, exit immediately): `--help`/`-h`, `--list-calendars [--json]`, `--status [--json]`. `--status` reads `lastRunAtISO`/`lastRunOK`/`lastRunSummary` in `UserDefaults`, written by `recordRunResult(ok:summary:)` at the end of every `--routes`/`--run-saved-routes` invocation. Exit codes: `2` = no calendar access, `3` = `--run-saved-routes` with no saved routes.
|
||||||
|
|
||||||
Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click.
|
Scheduled runs are implemented by generating a `launchd` plist in `~/Library/LaunchAgents/com.cqrenet.BusyMirror.saved-routes.plist` and bootstrapping it with `launchctl`. The app removes and re-bootstraps the agent on every "Install Schedule" click.
|
||||||
|
|
||||||
@@ -142,6 +145,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 = 23;
|
||||||
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.7.0;
|
||||||
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 = 23;
|
||||||
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.7.0;
|
||||||
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-10
@@ -7,6 +7,32 @@ 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
|
||||||
|
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
|
||||||
|
|
||||||
|
/// 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, alarmOffsets: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarms are carried along for mirroring but do not affect time-based
|
||||||
|
// deduplication, merging, or overlap calculations.
|
||||||
|
static func == (lhs: Block, rhs: Block) -> Bool {
|
||||||
|
lhs.start == rhs.start &&
|
||||||
|
lhs.end == rhs.end &&
|
||||||
|
lhs.srcStableID == rhs.srcStableID &&
|
||||||
|
lhs.label == rhs.label &&
|
||||||
|
lhs.notes == rhs.notes &&
|
||||||
|
lhs.occurrence == rhs.occurrence
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(start)
|
||||||
|
hasher.combine(end)
|
||||||
|
hasher.combine(srcStableID)
|
||||||
|
hasher.combine(label)
|
||||||
|
hasher.combine(notes)
|
||||||
|
hasher.combine(occurrence)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-dup blocks by occurrence (preferred) or by time range
|
// De-dup blocks by occurrence (preferred) or by time range
|
||||||
@@ -30,17 +56,19 @@ 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)
|
||||||
|
var curAlarms = sorted[0].alarmOffsets
|
||||||
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(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
|
||||||
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)
|
||||||
|
curAlarms = b.alarmOffsets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.append(cur)
|
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +83,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
|
||||||
}
|
}
|
||||||
|
|||||||
+306
-64
@@ -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
|
||||||
@@ -93,16 +44,18 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
var targetIDs: Set<String>
|
var targetIDs: Set<String>
|
||||||
var privacy: Bool // true = hide details for this source
|
var privacy: Bool // true = hide details for this source
|
||||||
var copyNotes: Bool // copy description when privacy is OFF
|
var copyNotes: Bool // copy description when privacy is OFF
|
||||||
|
var syncReminders: Bool // copy source event alarms into placeholder
|
||||||
var mergeGapHours: Int // per-route merge gap (hours)
|
var mergeGapHours: Int // per-route merge gap (hours)
|
||||||
var overlap: OverlapMode // per-route overlap behavior
|
var overlap: OverlapMode // per-route overlap behavior
|
||||||
var allDay: Bool // per-route mirror all-day
|
var allDay: Bool // per-route mirror all-day
|
||||||
enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, mergeGapHours, overlap, allDay }
|
enum CodingKeys: String, CodingKey { case sourceID, targetIDs, privacy, copyNotes, syncReminders, mergeGapHours, overlap, allDay }
|
||||||
|
|
||||||
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
|
init(sourceID: String, targetIDs: Set<String>, privacy: Bool, copyNotes: Bool, syncReminders: Bool, mergeGapHours: Int, overlap: OverlapMode, allDay: Bool) {
|
||||||
self.sourceID = sourceID
|
self.sourceID = sourceID
|
||||||
self.targetIDs = targetIDs
|
self.targetIDs = targetIDs
|
||||||
self.privacy = privacy
|
self.privacy = privacy
|
||||||
self.copyNotes = copyNotes
|
self.copyNotes = copyNotes
|
||||||
|
self.syncReminders = syncReminders
|
||||||
self.mergeGapHours = mergeGapHours
|
self.mergeGapHours = mergeGapHours
|
||||||
self.overlap = overlap
|
self.overlap = overlap
|
||||||
self.allDay = allDay
|
self.allDay = allDay
|
||||||
@@ -114,6 +67,7 @@ struct Route: Identifiable, Hashable, Codable {
|
|||||||
self.targetIDs = try c.decode(Set<String>.self, forKey: .targetIDs)
|
self.targetIDs = try c.decode(Set<String>.self, forKey: .targetIDs)
|
||||||
self.privacy = try c.decode(Bool.self, forKey: .privacy)
|
self.privacy = try c.decode(Bool.self, forKey: .privacy)
|
||||||
self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes)
|
self.copyNotes = try c.decode(Bool.self, forKey: .copyNotes)
|
||||||
|
self.syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
|
||||||
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
|
self.mergeGapHours = try c.decode(Int.self, forKey: .mergeGapHours)
|
||||||
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
|
self.overlap = try c.decode(OverlapMode.self, forKey: .overlap)
|
||||||
self.allDay = try c.decode(Bool.self, forKey: .allDay)
|
self.allDay = try c.decode(Bool.self, forKey: .allDay)
|
||||||
@@ -138,6 +92,7 @@ struct ContentView: View {
|
|||||||
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
|
private var mergeGapMin: Int { max(0, mergeGapHours * 60) }
|
||||||
@AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy"
|
@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("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false
|
||||||
|
@AppStorage("syncReminders") private var syncReminders: Bool = false // Copy source alarms into mirrored placeholders
|
||||||
@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("filterByWorkHours") private var filterByWorkHours: Bool = false
|
||||||
@@ -159,9 +114,15 @@ struct ContentView: View {
|
|||||||
@State private var logText = "Ready."
|
@State private var logText = "Ready."
|
||||||
@State private var isRunning = false
|
@State private var isRunning = false
|
||||||
@State private var isCLIRun = false
|
@State private var isCLIRun = false
|
||||||
|
@State private var cliRunErrorCount = 0
|
||||||
|
@AppStorage("lastRunAtISO") private var lastRunAtISO: String = ""
|
||||||
|
@AppStorage("lastRunOK") private var lastRunOK: Bool = true
|
||||||
|
@AppStorage("lastRunSummary") private var lastRunSummary: String = ""
|
||||||
@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.
|
||||||
@@ -589,6 +550,7 @@ struct ContentView: View {
|
|||||||
targetIDs: targetIDs,
|
targetIDs: targetIDs,
|
||||||
privacy: hideDetails,
|
privacy: hideDetails,
|
||||||
copyNotes: copyDescription,
|
copyNotes: copyDescription,
|
||||||
|
syncReminders: syncReminders,
|
||||||
mergeGapHours: mergeGapHours,
|
mergeGapHours: mergeGapHours,
|
||||||
overlap: overlapMode,
|
overlap: overlapMode,
|
||||||
allDay: mirrorAllDay)
|
allDay: mirrorAllDay)
|
||||||
@@ -634,6 +596,9 @@ struct ContentView: View {
|
|||||||
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
||||||
.disabled(isRunning || route.privacy)
|
.disabled(isRunning || route.privacy)
|
||||||
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
|
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
|
||||||
|
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
|
||||||
|
.disabled(isRunning)
|
||||||
|
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
|
||||||
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
|
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
.help("Mirror all-day events for this source.")
|
.help("Mirror all-day events for this source.")
|
||||||
@@ -779,7 +744,8 @@ struct ContentView: View {
|
|||||||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||||||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||||||
autoDeleteMissing: autoDeleteMissing,
|
autoDeleteMissing: autoDeleteMissing,
|
||||||
writeEnabled: writeEnabled
|
writeEnabled: writeEnabled,
|
||||||
|
syncReminders: r.syncReminders
|
||||||
)
|
)
|
||||||
let srcCal = calendars[sIdx]
|
let srcCal = calendars[sIdx]
|
||||||
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
|
let targets = calendars.filter { validTargets.contains($0.calendarIdentifier) && $0.calendarIdentifier != srcCal.calendarIdentifier }
|
||||||
@@ -823,7 +789,8 @@ struct ContentView: View {
|
|||||||
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
excludedOrganizerFilterTerms: excludedOrganizerFilterTerms,
|
||||||
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
mirrorAcceptedOnly: mirrorAcceptedOnly,
|
||||||
autoDeleteMissing: autoDeleteMissing,
|
autoDeleteMissing: autoDeleteMissing,
|
||||||
writeEnabled: writeEnabled
|
writeEnabled: writeEnabled,
|
||||||
|
syncReminders: syncReminders
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +918,8 @@ struct ContentView: View {
|
|||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
Toggle("Copy description when mirroring", isOn: $copyDescription)
|
Toggle("Copy description when mirroring", isOn: $copyDescription)
|
||||||
.disabled(isRunning || hideDetails)
|
.disabled(isRunning || hideDetails)
|
||||||
|
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
|
||||||
|
.disabled(isRunning)
|
||||||
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)
|
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
|
||||||
@@ -1342,9 +1311,13 @@ struct ContentView: View {
|
|||||||
tryRunCLIIfPresent()
|
tryRunCLIIfPresent()
|
||||||
enforceNoSourceInTargets()
|
enforceNoSourceInTargets()
|
||||||
handlePendingMenuBarSyncIfNeeded()
|
handlePendingMenuBarSyncIfNeeded()
|
||||||
|
if !isCLIRun {
|
||||||
|
appController.bootstrapBackgroundSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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
|
||||||
@@ -1355,6 +1328,7 @@ struct ContentView: View {
|
|||||||
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
|
.onChange(of: mergeGapHours) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
|
.onChange(of: hideDetails) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
|
.onChange(of: copyDescription) { _ in saveSettingsToDefaults() }
|
||||||
|
.onChange(of: syncReminders) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
|
.onChange(of: mirrorAllDay) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
|
.onChange(of: mirrorAcceptedOnly) { _ in saveSettingsToDefaults() }
|
||||||
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
|
.onChange(of: overlapModeRaw) { _ in saveSettingsToDefaults() }
|
||||||
@@ -1381,16 +1355,171 @@ struct ContentView: View {
|
|||||||
.onChange(of: routes) { _ in
|
.onChange(of: routes) { _ in
|
||||||
saveSettingsToDefaults()
|
saveSettingsToDefaults()
|
||||||
handlePendingMenuBarSyncIfNeeded()
|
handlePendingMenuBarSyncIfNeeded()
|
||||||
|
appController.armAutoSyncIfPossible()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CLI support
|
// MARK: - CLI support
|
||||||
|
private static let cliHelpText = """
|
||||||
|
BusyMirror — mirror calendar events between EventKit calendars.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
BusyMirror --run-saved-routes [--write 1] [--exit]
|
||||||
|
BusyMirror --routes "1->2,3; 4->5" [--write 1] [--exit]
|
||||||
|
BusyMirror --list-calendars [--json]
|
||||||
|
BusyMirror --status [--json]
|
||||||
|
BusyMirror --help
|
||||||
|
|
||||||
|
Run modes:
|
||||||
|
--run-saved-routes Run the routes configured in the app's saved settings.
|
||||||
|
--routes SPEC Run ad-hoc routes by 1-based calendar index, e.g. "1->2,3".
|
||||||
|
--list-calendars Print available calendars (index, id, title, source) and exit.
|
||||||
|
--status Print last-run and schedule diagnostics and exit.
|
||||||
|
--help, -h Print this help and exit.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--json Machine-readable JSON output for --list-calendars / --status.
|
||||||
|
--write 1 Actually create/update/delete events (default: dry-run).
|
||||||
|
--exit Quit the app after the run completes.
|
||||||
|
--cleanup-only Only delete stale mirrored placeholders; don't mirror.
|
||||||
|
--privacy 1|0 Hide event details behind a placeholder title.
|
||||||
|
--copy-notes 1|0 Copy the source event's notes into the mirror.
|
||||||
|
--sync-reminders 1|0 Copy source event alarms into the mirror.
|
||||||
|
--all-day 1|0 Mirror all-day events.
|
||||||
|
--mode allow|skipCovered|fillGaps
|
||||||
|
--days-back N / --days-forward N
|
||||||
|
--merge-gap-hours N
|
||||||
|
--exclude-titles "token1, token2"
|
||||||
|
--exclude-organizers "alice@example.com, Example Org"
|
||||||
|
"""
|
||||||
|
|
||||||
|
private func recordRunResult(ok: Bool, summary: String) {
|
||||||
|
lastRunAtISO = ISO8601DateFormatter().string(from: Date())
|
||||||
|
lastRunOK = ok
|
||||||
|
lastRunSummary = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CLICalendarInfo: Codable {
|
||||||
|
let index: Int
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let source: String
|
||||||
|
let sourceType: String
|
||||||
|
let allowsModify: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CLIStatusInfo: Codable {
|
||||||
|
let lastRunAt: String?
|
||||||
|
let lastRunOK: Bool?
|
||||||
|
let lastRunSummary: String?
|
||||||
|
let scheduleInstalled: Bool
|
||||||
|
let scheduleSummary: String?
|
||||||
|
let routeCount: Int
|
||||||
|
let logFilePath: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceTypeLabel(_ type: EKSourceType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .local: return "local"
|
||||||
|
case .exchange: return "exchange"
|
||||||
|
case .calDAV: return "calDAV"
|
||||||
|
case .mobileMe: return "iCloud"
|
||||||
|
case .subscribed: return "subscribed"
|
||||||
|
case .birthdays: return "birthdays"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printCalendars(json: Bool) {
|
||||||
|
let infos = calendars.enumerated().map { idx, cal in
|
||||||
|
CLICalendarInfo(
|
||||||
|
index: idx + 1,
|
||||||
|
id: cal.calendarIdentifier,
|
||||||
|
title: cal.title,
|
||||||
|
source: cal.source.title,
|
||||||
|
sourceType: sourceTypeLabel(cal.source.sourceType),
|
||||||
|
allowsModify: cal.allowsContentModifications
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if json {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(infos), let s = String(data: data, encoding: .utf8) {
|
||||||
|
print(s)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for info in infos {
|
||||||
|
print("\(info.index): \(info.title) [\(info.source), \(info.sourceType)]\(info.allowsModify ? "" : " (read-only)") id=\(info.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printStatus(json: Bool) {
|
||||||
|
let info = CLIStatusInfo(
|
||||||
|
lastRunAt: lastRunAtISO.isEmpty ? nil : lastRunAtISO,
|
||||||
|
lastRunOK: lastRunAtISO.isEmpty ? nil : lastRunOK,
|
||||||
|
lastRunSummary: lastRunSummary.isEmpty ? nil : lastRunSummary,
|
||||||
|
scheduleInstalled: hasInstalledSchedule,
|
||||||
|
scheduleSummary: hasInstalledSchedule ? scheduleSummary : nil,
|
||||||
|
routeCount: routes.count,
|
||||||
|
logFilePath: AppLogStore.logFileURL.path
|
||||||
|
)
|
||||||
|
if json {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(info), let s = String(data: data, encoding: .utf8) {
|
||||||
|
print(s)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("Last run: \(info.lastRunAt ?? "never")\(info.lastRunAt != nil ? (info.lastRunOK == true ? " (ok)" : " (error)") : "")")
|
||||||
|
if let summary = info.lastRunSummary { print(" \(summary)") }
|
||||||
|
print("Schedule: \(info.scheduleInstalled ? (info.scheduleSummary ?? "installed") : "not installed")")
|
||||||
|
print("Saved routes: \(info.routeCount)")
|
||||||
|
print("Log file: \(info.logFilePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tryRunCLIIfPresent() {
|
func tryRunCLIIfPresent() {
|
||||||
let args = CommandLine.arguments
|
let args = CommandLine.arguments
|
||||||
|
let jsonOutput = args.contains("--json")
|
||||||
|
|
||||||
|
if args.contains("--help") || args.contains("-h") {
|
||||||
|
isCLIRun = true
|
||||||
|
print(Self.cliHelpText)
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.contains("--status") {
|
||||||
|
isCLIRun = true
|
||||||
|
printStatus(json: jsonOutput)
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.contains("--list-calendars") {
|
||||||
|
isCLIRun = true
|
||||||
|
Task {
|
||||||
|
if hasAccess { await MainActor.run { reloadCalendars() } }
|
||||||
|
for _ in 0..<50 {
|
||||||
|
if hasAccess && !calendars.isEmpty { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
guard hasAccess else {
|
||||||
|
FileHandle.standardError.write("No calendar access.\n".data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
await MainActor.run { printCalendars(json: jsonOutput) }
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let routesIdx = args.firstIndex(of: "--routes")
|
let routesIdx = args.firstIndex(of: "--routes")
|
||||||
let runSavedRoutes = args.contains("--run-saved-routes")
|
let runSavedRoutes = args.contains("--run-saved-routes")
|
||||||
guard routesIdx != nil || runSavedRoutes else { return }
|
guard routesIdx != nil || runSavedRoutes else { return }
|
||||||
isCLIRun = true
|
isCLIRun = true
|
||||||
|
cliRunErrorCount = 0
|
||||||
|
|
||||||
func boolArg(_ name: String, default def: Bool) -> Bool {
|
func boolArg(_ name: String, default def: Bool) -> Bool {
|
||||||
if let i = args.firstIndex(of: name), i+1 < args.count {
|
if let i = args.firstIndex(of: name), i+1 < args.count {
|
||||||
@@ -1411,6 +1540,7 @@ struct ContentView: View {
|
|||||||
// Configure options from CLI flags
|
// Configure options from CLI flags
|
||||||
hideDetails = boolArg("--privacy", default: hideDetails)
|
hideDetails = boolArg("--privacy", default: hideDetails)
|
||||||
copyDescription = boolArg("--copy-notes", default: copyDescription)
|
copyDescription = boolArg("--copy-notes", default: copyDescription)
|
||||||
|
syncReminders = boolArg("--sync-reminders", default: syncReminders)
|
||||||
writeEnabled = boolArg("--write", default: writeEnabled)
|
writeEnabled = boolArg("--write", default: writeEnabled)
|
||||||
mirrorAllDay = boolArg("--all-day", default: mirrorAllDay)
|
mirrorAllDay = boolArg("--all-day", default: mirrorAllDay)
|
||||||
daysForward = intArg("--days-forward", default: daysForward)
|
daysForward = intArg("--days-forward", default: daysForward)
|
||||||
@@ -1451,22 +1581,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
guard hasAccess, !calendars.isEmpty else {
|
guard hasAccess, !calendars.isEmpty else {
|
||||||
log("CLI: no calendar access; aborting")
|
log("CLI: no calendar access; aborting")
|
||||||
NSApp.terminate(nil)
|
recordRunResult(ok: false, summary: "no calendar access")
|
||||||
return
|
exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliConfig = makeMirrorConfig()
|
let cliConfig = makeMirrorConfig()
|
||||||
if runSavedRoutes {
|
if runSavedRoutes {
|
||||||
if routes.isEmpty {
|
if routes.isEmpty {
|
||||||
log("CLI: no saved routes; aborting")
|
log("CLI: no saved routes; aborting")
|
||||||
|
recordRunResult(ok: false, summary: "no saved routes")
|
||||||
|
exit(3)
|
||||||
} else if boolArg("--cleanup-only", default: false) {
|
} else if boolArg("--cleanup-only", default: false) {
|
||||||
for r in routes {
|
for r in routes {
|
||||||
log("CLI: cleanup saved route \(r.sourceID)")
|
log("CLI: cleanup saved route \(r.sourceID)")
|
||||||
await runCleanupForRoute(r)
|
await runCleanupForRoute(r)
|
||||||
}
|
}
|
||||||
|
recordRunResult(ok: cliRunErrorCount == 0, summary: "cleaned up \(routes.count) saved route(s)")
|
||||||
} else {
|
} else {
|
||||||
var sessionGuard = Set<String>()
|
var sessionGuard = Set<String>()
|
||||||
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
await runConfiguredRoutes(routes, sessionGuard: &sessionGuard)
|
||||||
|
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routes.count) saved route(s)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for part in routeParts where !part.isEmpty {
|
for part in routeParts where !part.isEmpty {
|
||||||
@@ -1496,8 +1630,12 @@ struct ContentView: View {
|
|||||||
await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
await engine.runMirror(store: store, config: cliConfig, sourceCalendar: srcCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recordRunResult(ok: cliRunErrorCount == 0, summary: "ran \(routeParts.count) route(s)")
|
||||||
}
|
}
|
||||||
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 +1680,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,16 +1697,39 @@ 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() {
|
||||||
|
if let token = storeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
storeObserver = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export / Import Settings
|
||||||
|
struct SettingsPayload: Codable {
|
||||||
var daysBack: Int
|
var daysBack: Int
|
||||||
var daysForward: Int
|
var daysForward: Int
|
||||||
var mergeGapHours: Int
|
var mergeGapHours: Int
|
||||||
var hideDetails: Bool
|
var hideDetails: Bool
|
||||||
var copyDescription: Bool
|
var copyDescription: Bool
|
||||||
|
var syncReminders: Bool = false
|
||||||
var mirrorAllDay: Bool
|
var mirrorAllDay: Bool
|
||||||
var filterByWorkHours: Bool = false
|
var filterByWorkHours: Bool = false
|
||||||
var workHoursStart: Int = 9
|
var workHoursStart: Int = 9
|
||||||
@@ -1584,7 +1748,68 @@ private struct SettingsPayload: Codable {
|
|||||||
// optional metadata
|
// optional metadata
|
||||||
var appVersion: String?
|
var appVersion: String?
|
||||||
var exportedAt: Date = Date()
|
var exportedAt: Date = Date()
|
||||||
}
|
|
||||||
|
init(daysBack: Int, daysForward: Int, mergeGapHours: Int, hideDetails: Bool, copyDescription: Bool,
|
||||||
|
syncReminders: Bool = false, mirrorAllDay: Bool, filterByWorkHours: Bool, workHoursStart: Int,
|
||||||
|
workHoursEnd: Int, excludedTitleFilters: [String], excludedOrganizerFilters: [String],
|
||||||
|
mirrorAcceptedOnly: Bool, overlapMode: String, titlePrefix: String, placeholderTitle: String,
|
||||||
|
autoDeleteMissing: Bool, routes: [Route], selectedSourceID: String? = nil,
|
||||||
|
selectedTargetIDs: [String]? = nil, appVersion: String? = nil, exportedAt: Date = Date()) {
|
||||||
|
self.daysBack = daysBack
|
||||||
|
self.daysForward = daysForward
|
||||||
|
self.mergeGapHours = mergeGapHours
|
||||||
|
self.hideDetails = hideDetails
|
||||||
|
self.copyDescription = copyDescription
|
||||||
|
self.syncReminders = syncReminders
|
||||||
|
self.mirrorAllDay = mirrorAllDay
|
||||||
|
self.filterByWorkHours = filterByWorkHours
|
||||||
|
self.workHoursStart = workHoursStart
|
||||||
|
self.workHoursEnd = workHoursEnd
|
||||||
|
self.excludedTitleFilters = excludedTitleFilters
|
||||||
|
self.excludedOrganizerFilters = excludedOrganizerFilters
|
||||||
|
self.mirrorAcceptedOnly = mirrorAcceptedOnly
|
||||||
|
self.overlapMode = overlapMode
|
||||||
|
self.titlePrefix = titlePrefix
|
||||||
|
self.placeholderTitle = placeholderTitle
|
||||||
|
self.autoDeleteMissing = autoDeleteMissing
|
||||||
|
self.routes = routes
|
||||||
|
self.selectedSourceID = selectedSourceID
|
||||||
|
self.selectedTargetIDs = selectedTargetIDs
|
||||||
|
self.appVersion = appVersion
|
||||||
|
self.exportedAt = exportedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom decode: every field added after the very first release must be
|
||||||
|
// read with decodeIfPresent so that a settings blob written by an older
|
||||||
|
// build (missing that key) doesn't fail the whole decode and silently
|
||||||
|
// wipe all saved routes/settings (see: settings.v2 losing data across
|
||||||
|
// the 1.5.1 -> 1.6.0 upgrade when `syncReminders` was added).
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
daysBack = try c.decodeIfPresent(Int.self, forKey: .daysBack) ?? 1
|
||||||
|
daysForward = try c.decodeIfPresent(Int.self, forKey: .daysForward) ?? 7
|
||||||
|
mergeGapHours = try c.decodeIfPresent(Int.self, forKey: .mergeGapHours) ?? 0
|
||||||
|
hideDetails = try c.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? true
|
||||||
|
copyDescription = try c.decodeIfPresent(Bool.self, forKey: .copyDescription) ?? false
|
||||||
|
syncReminders = try c.decodeIfPresent(Bool.self, forKey: .syncReminders) ?? false
|
||||||
|
mirrorAllDay = try c.decodeIfPresent(Bool.self, forKey: .mirrorAllDay) ?? false
|
||||||
|
filterByWorkHours = try c.decodeIfPresent(Bool.self, forKey: .filterByWorkHours) ?? false
|
||||||
|
workHoursStart = try c.decodeIfPresent(Int.self, forKey: .workHoursStart) ?? 9
|
||||||
|
workHoursEnd = try c.decodeIfPresent(Int.self, forKey: .workHoursEnd) ?? 17
|
||||||
|
excludedTitleFilters = try c.decodeIfPresent([String].self, forKey: .excludedTitleFilters) ?? []
|
||||||
|
excludedOrganizerFilters = try c.decodeIfPresent([String].self, forKey: .excludedOrganizerFilters) ?? []
|
||||||
|
mirrorAcceptedOnly = try c.decodeIfPresent(Bool.self, forKey: .mirrorAcceptedOnly) ?? false
|
||||||
|
overlapMode = try c.decodeIfPresent(String.self, forKey: .overlapMode) ?? OverlapMode.allow.rawValue
|
||||||
|
titlePrefix = try c.decodeIfPresent(String.self, forKey: .titlePrefix) ?? "🪞 "
|
||||||
|
placeholderTitle = try c.decodeIfPresent(String.self, forKey: .placeholderTitle) ?? "Busy"
|
||||||
|
autoDeleteMissing = try c.decodeIfPresent(Bool.self, forKey: .autoDeleteMissing) ?? true
|
||||||
|
routes = try c.decodeIfPresent([Route].self, forKey: .routes) ?? []
|
||||||
|
selectedSourceID = try c.decodeIfPresent(String.self, forKey: .selectedSourceID)
|
||||||
|
selectedTargetIDs = try c.decodeIfPresent([String].self, forKey: .selectedTargetIDs)
|
||||||
|
appVersion = try c.decodeIfPresent(String.self, forKey: .appVersion)
|
||||||
|
exportedAt = try c.decodeIfPresent(Date.self, forKey: .exportedAt) ?? Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeSnapshot() -> SettingsPayload {
|
private func makeSnapshot() -> SettingsPayload {
|
||||||
SettingsPayload(
|
SettingsPayload(
|
||||||
@@ -1618,6 +1843,7 @@ private struct SettingsPayload: Codable {
|
|||||||
mergeGapHours = s.mergeGapHours
|
mergeGapHours = s.mergeGapHours
|
||||||
hideDetails = s.hideDetails
|
hideDetails = s.hideDetails
|
||||||
copyDescription = s.copyDescription
|
copyDescription = s.copyDescription
|
||||||
|
syncReminders = s.syncReminders
|
||||||
mirrorAllDay = s.mirrorAllDay
|
mirrorAllDay = s.mirrorAllDay
|
||||||
filterByWorkHours = s.filterByWorkHours
|
filterByWorkHours = s.filterByWorkHours
|
||||||
workHoursStart = s.workHoursStart
|
workHoursStart = s.workHoursStart
|
||||||
@@ -1697,11 +1923,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1723,6 +1947,18 @@ private struct SettingsPayload: Codable {
|
|||||||
do {
|
do {
|
||||||
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
let snap = try JSONDecoder().decode(SettingsPayload.self, from: data)
|
||||||
applySnapshot(snap)
|
applySnapshot(snap)
|
||||||
|
// A build affected by an earlier settings.v2 decode failure (fixed
|
||||||
|
// in 1.6.0 — a newly added field with no decode fallback threw and
|
||||||
|
// wiped routes in memory, which a later autosave then persisted
|
||||||
|
// back as empty) can still be running with `routes: []` in
|
||||||
|
// settings.v2 while the untouched legacy `routes.v1` key still
|
||||||
|
// holds the real routes. Recover them if so.
|
||||||
|
if routes.isEmpty, let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
|
||||||
|
let recovered = try? JSONDecoder().decode([Route].self, from: legacyData), !recovered.isEmpty {
|
||||||
|
routes = recovered
|
||||||
|
log("Recovered \(recovered.count) route(s) from legacy backup (routes.v1).")
|
||||||
|
saveSettingsToDefaults()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
log("✗ Failed to load settings: \(error.localizedDescription)")
|
log("✗ Failed to load settings: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -1757,6 +1993,12 @@ private struct SettingsPayload: Codable {
|
|||||||
// MARK: - Logging
|
// MARK: - Logging
|
||||||
func log(_ s: String) {
|
func log(_ s: String) {
|
||||||
AppLogStore.append(s)
|
AppLogStore.append(s)
|
||||||
|
if isCLIRun {
|
||||||
|
let lower = s.lowercased()
|
||||||
|
if lower.contains("error") || lower.contains("fail") {
|
||||||
|
cliRunErrorCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
logText.append("\n" + s)
|
logText.append("\n" + s)
|
||||||
let maxLines = 2000
|
let maxLines = 2000
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import EventKit
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
enum BusyMirrorSceneID {
|
enum BusyMirrorSceneID {
|
||||||
static let mainWindow = "main-window"
|
static let mainWindow = "main-window"
|
||||||
@@ -33,6 +35,187 @@ final class BusyMirrorAppController: ObservableObject {
|
|||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
openWindow(id: BusyMirrorSceneID.mainWindow)
|
openWindow(id: BusyMirrorSceneID.mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Event-driven background sync
|
||||||
|
//
|
||||||
|
// Owned here (not by ContentView) because this controller lives for the
|
||||||
|
// whole process, independent of the main window's lifecycle. ContentView's
|
||||||
|
// own EKEventStoreChanged observer is torn down in .onDisappear when the
|
||||||
|
// window closes — fine for keeping the UI's calendar list fresh while
|
||||||
|
// open, but useless for background operation, which is the entire point
|
||||||
|
// of replacing the old hourly launchd poll. This runs regardless of
|
||||||
|
// whether the window is open, closed, or never opened this session.
|
||||||
|
|
||||||
|
private let backgroundStore = EKEventStore()
|
||||||
|
private var storeObserver: NSObjectProtocol?
|
||||||
|
private var wakeObserver: NSObjectProtocol?
|
||||||
|
private var debounceTask: Task<Void, Never>?
|
||||||
|
private var fallbackTask: Task<Void, Never>?
|
||||||
|
private var hasArmedAutoSync = false
|
||||||
|
|
||||||
|
private let settingsDefaultsKey = "settings.v2"
|
||||||
|
private let legacyRoutesDefaultsKey = "routes.v1"
|
||||||
|
private let launchAgentLabel = "com.cqrenet.BusyMirror.saved-routes"
|
||||||
|
|
||||||
|
private var launchAgentURL: URL {
|
||||||
|
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||||
|
return base.appendingPathComponent("LaunchAgents/\(launchAgentLabel).plist", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call once at app launch. Only activates if calendar access has already
|
||||||
|
/// been granted in a prior session — never prompts on its own, since a
|
||||||
|
/// permission dialog with no window open would be confusing. First-run
|
||||||
|
/// consent stays ContentView's job.
|
||||||
|
func bootstrapBackgroundSync() {
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
let hasAccess: Bool
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
hasAccess = status == .fullAccess
|
||||||
|
} else {
|
||||||
|
hasAccess = status == .authorized
|
||||||
|
}
|
||||||
|
guard hasAccess else { return }
|
||||||
|
armAutoSyncIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-check whether auto-sync should activate. Safe to call repeatedly
|
||||||
|
/// (e.g. from ContentView whenever the saved routes list changes) — it
|
||||||
|
/// only does anything the first time routes go from empty to non-empty.
|
||||||
|
func armAutoSyncIfPossible() {
|
||||||
|
guard !hasArmedAutoSync else { return }
|
||||||
|
guard !loadRoutesFromDefaults().isEmpty else { return }
|
||||||
|
hasArmedAutoSync = true
|
||||||
|
|
||||||
|
if SMAppService.mainApp.status != .enabled {
|
||||||
|
try? SMAppService.mainApp.register()
|
||||||
|
AppLogStore.append("[auto-sync] Registered as login item.")
|
||||||
|
}
|
||||||
|
if FileManager.default.fileExists(atPath: launchAgentURL.path) {
|
||||||
|
removeLegacyLaunchdSchedule()
|
||||||
|
}
|
||||||
|
if storeObserver == nil {
|
||||||
|
storeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .EKEventStoreChanged, object: backgroundStore, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.scheduleAutoSync(reason: "calendar changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wakeObserver == nil {
|
||||||
|
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.scheduleAutoSync(reason: "system woke from sleep")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallbackTask == nil {
|
||||||
|
fallbackTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 30 * 60 * 1_000_000_000)
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
self?.scheduleAutoSync(reason: "periodic fallback check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppLogStore.append("[auto-sync] Armed: watching for calendar changes.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeLegacyLaunchdSchedule() {
|
||||||
|
let domain = "gui/\(getuid())"
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||||
|
proc.arguments = ["bootout", domain, launchAgentURL.path]
|
||||||
|
proc.standardOutput = Pipe()
|
||||||
|
proc.standardError = Pipe()
|
||||||
|
try? proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
try? FileManager.default.removeItem(at: launchAgentURL)
|
||||||
|
AppLogStore.append("[auto-sync] Removed launchd schedule (superseded by change-driven sync).")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounces bursts of EKEventStoreChanged notifications into one sync.
|
||||||
|
private func scheduleAutoSync(reason: String) {
|
||||||
|
debounceTask?.cancel()
|
||||||
|
debounceTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.runAutoSync(reason: reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRoutesFromDefaults() -> [Route] {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let data = defaults.data(forKey: settingsDefaultsKey),
|
||||||
|
let snap = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: data) {
|
||||||
|
return snap.routes
|
||||||
|
}
|
||||||
|
if let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey),
|
||||||
|
let routes = try? JSONDecoder().decode([Route].self, from: legacyData) {
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAutoSync(reason: String) async {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
let routes = loadRoutesFromDefaults()
|
||||||
|
guard !routes.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let snap = UserDefaults.standard.data(forKey: settingsDefaultsKey),
|
||||||
|
let settings = try? JSONDecoder().decode(ContentView.SettingsPayload.self, from: snap) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncing(true)
|
||||||
|
AppLogStore.append("[auto-sync] Triggered by \(reason).")
|
||||||
|
var errorCount = 0
|
||||||
|
let engine = MirrorEngine(log: { s in
|
||||||
|
AppLogStore.append("[auto-sync] \(s)")
|
||||||
|
let lower = s.lowercased()
|
||||||
|
if lower.contains("error") || lower.contains("fail") { errorCount += 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
let calendars = backgroundStore.calendars(for: .event)
|
||||||
|
func calendar(id: String) -> EKCalendar? { calendars.first { $0.calendarIdentifier == id } }
|
||||||
|
|
||||||
|
var ranAnyRoute = false
|
||||||
|
var sessionGuard = Set<String>()
|
||||||
|
for route in routes {
|
||||||
|
guard let sourceCal = calendar(id: route.sourceID) else { continue }
|
||||||
|
let validTargetIDs = route.targetIDs.filter { $0 != route.sourceID }
|
||||||
|
let targets = validTargetIDs.compactMap(calendar(id:))
|
||||||
|
guard !targets.isEmpty else { continue }
|
||||||
|
ranAnyRoute = true
|
||||||
|
let config = MirrorConfig(
|
||||||
|
daysBack: settings.daysBack,
|
||||||
|
daysForward: settings.daysForward,
|
||||||
|
mergeGapMin: max(0, route.mergeGapHours * 60),
|
||||||
|
hideDetails: route.privacy,
|
||||||
|
copyDescription: route.copyNotes,
|
||||||
|
mirrorAllDay: route.allDay,
|
||||||
|
overlapMode: route.overlap,
|
||||||
|
titlePrefix: settings.titlePrefix,
|
||||||
|
placeholderTitle: settings.placeholderTitle,
|
||||||
|
filterByWorkHours: settings.filterByWorkHours,
|
||||||
|
workHoursStart: settings.workHoursStart,
|
||||||
|
workHoursEnd: settings.workHoursEnd,
|
||||||
|
excludedTitleFilterTerms: settings.excludedTitleFilters.map { $0.lowercased() },
|
||||||
|
excludedOrganizerFilterTerms: settings.excludedOrganizerFilters.map { $0.lowercased() },
|
||||||
|
mirrorAcceptedOnly: settings.mirrorAcceptedOnly,
|
||||||
|
autoDeleteMissing: settings.autoDeleteMissing,
|
||||||
|
writeEnabled: true,
|
||||||
|
syncReminders: route.syncReminders
|
||||||
|
)
|
||||||
|
await engine.runMirror(store: backgroundStore, config: config, sourceCalendar: sourceCal, targetCalendars: targets, sessionGuard: &sessionGuard, isMultiRouteRun: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = ranAnyRoute ? "auto-sync (\(reason)): ran \(routes.count) saved route(s)" : "auto-sync (\(reason)): no route had a valid source+target"
|
||||||
|
AppLogStore.append("[auto-sync] \(summary)")
|
||||||
|
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: "lastRunAtISO")
|
||||||
|
UserDefaults.standard.set(errorCount == 0, forKey: "lastRunOK")
|
||||||
|
UserDefaults.standard.set(summary, forKey: "lastRunSummary")
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BusyMirrorMenuBarView: View {
|
struct BusyMirrorMenuBarView: View {
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ struct MirrorConfig {
|
|||||||
let mirrorAcceptedOnly: Bool
|
let mirrorAcceptedOnly: Bool
|
||||||
let autoDeleteMissing: Bool
|
let autoDeleteMissing: Bool
|
||||||
let writeEnabled: Bool
|
let writeEnabled: Bool
|
||||||
|
let syncReminders: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ import EventKit
|
|||||||
|
|
||||||
private let SAME_TIME_TOL_MIN: Double = 5
|
private let SAME_TIME_TOL_MIN: Double = 5
|
||||||
|
|
||||||
|
private func alarmOffsets(for event: EKEvent) -> [TimeInterval]? {
|
||||||
|
guard let alarms = event.alarms, !alarms.isEmpty else { return nil }
|
||||||
|
let offsets = alarms.compactMap { alarm -> TimeInterval? in
|
||||||
|
if alarm.relativeOffset != 0 {
|
||||||
|
return alarm.relativeOffset
|
||||||
|
}
|
||||||
|
if let absoluteDate = alarm.absoluteDate, let start = event.startDate {
|
||||||
|
return absoluteDate.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return offsets.isEmpty ? nil : offsets
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alarmsFromOffsets(_ offsets: [TimeInterval]) -> [EKAlarm] {
|
||||||
|
offsets.map { EKAlarm(relativeOffset: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
struct MirrorRecord: Hashable, Codable {
|
struct MirrorRecord: Hashable, Codable {
|
||||||
var targetCalendarID: String
|
var targetCalendarID: String
|
||||||
var sourceCalendarID: String
|
var sourceCalendarID: String
|
||||||
@@ -13,6 +31,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,
|
||||||
@@ -137,7 +178,7 @@ final class MirrorEngine {
|
|||||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||||
let srcID = stableSourceIdentifier(for: ev)
|
let srcID = stableSourceIdentifier(for: ev)
|
||||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate))
|
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
|
||||||
}
|
}
|
||||||
if skippedMirrors > 0 {
|
if skippedMirrors > 0 {
|
||||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||||
@@ -229,7 +270,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)
|
||||||
@@ -301,6 +342,17 @@ final class MirrorEngine {
|
|||||||
upsertMirrorRecord(for: blk, event: event)
|
upsertMirrorRecord(for: blk, event: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desiredAlarms(for blk: Block) -> [EKAlarm] {
|
||||||
|
guard config.syncReminders, let offsets = blk.alarmOffsets else { return [] }
|
||||||
|
return alarmsFromOffsets(offsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alarmsEqual(_ a: [EKAlarm], _ b: [EKAlarm]) -> Bool {
|
||||||
|
let offsetsA = a.compactMap { $0.relativeOffset }.sorted()
|
||||||
|
let offsetsB = b.compactMap { $0.relativeOffset }.sorted()
|
||||||
|
return offsetsA == offsetsB
|
||||||
|
}
|
||||||
|
|
||||||
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
|
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
|
||||||
let curS = existing.startDate ?? blk.start
|
let curS = existing.startDate ?? blk.start
|
||||||
let curE = existing.endDate ?? blk.end
|
let curE = existing.endDate ?? blk.end
|
||||||
@@ -310,6 +362,9 @@ final class MirrorEngine {
|
|||||||
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
|
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
|
||||||
if existing.isAllDay { return true }
|
if existing.isAllDay { return true }
|
||||||
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
|
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
|
||||||
|
let newAlarms = desiredAlarms(for: blk)
|
||||||
|
let existingAlarms = existing.alarms ?? []
|
||||||
|
if !alarmsEqual(newAlarms, existingAlarms) { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,13 +414,12 @@ final class MirrorEngine {
|
|||||||
existing.isAllDay = false
|
existing.isAllDay = false
|
||||||
existing.notes = notes
|
existing.notes = notes
|
||||||
existing.url = desiredURL
|
existing.url = desiredURL
|
||||||
|
existing.alarms = desiredAlarms(for: blk)
|
||||||
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 {
|
||||||
@@ -412,15 +466,14 @@ final class MirrorEngine {
|
|||||||
newEv.isAllDay = false
|
newEv.isAllDay = false
|
||||||
newEv.notes = notes
|
newEv.notes = notes
|
||||||
newEv.url = desiredURL
|
newEv.url = desiredURL
|
||||||
|
newEv.alarms = desiredAlarms(for: blk)
|
||||||
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 +532,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 +594,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 +647,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ final class BlockMathTests: XCTestCase {
|
|||||||
|
|
||||||
private let d = Date(timeIntervalSince1970: 0)
|
private let d = Date(timeIntervalSince1970: 0)
|
||||||
|
|
||||||
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
|
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil, alarmOffsets: [TimeInterval]? = nil) -> Block {
|
||||||
Block(
|
Block(
|
||||||
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
|
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
|
||||||
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
|
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
|
||||||
srcStableID: id,
|
srcStableID: id,
|
||||||
label: nil,
|
label: nil,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
occurrence: nil
|
occurrence: nil,
|
||||||
|
alarmOffsets: alarmOffsets
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,21 @@ final class BlockMathTests: XCTestCase {
|
|||||||
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
|
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMergeBlocksPreservesFirstAlarms() {
|
||||||
|
let b1 = block(0, 10, alarmOffsets: [-900, -3600])
|
||||||
|
let b2 = block(10, 20, alarmOffsets: [-600])
|
||||||
|
let merged = mergeBlocks([b1, b2], gapMinutes: 0)
|
||||||
|
XCTAssertEqual(merged.count, 1)
|
||||||
|
XCTAssertEqual(merged[0].alarmOffsets, [-900, -3600])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockEqualityIgnoresAlarms() {
|
||||||
|
let b1 = block(0, 10, alarmOffsets: [-900])
|
||||||
|
let b2 = block(0, 10, alarmOffsets: [-1800])
|
||||||
|
XCTAssertEqual(b1, b2)
|
||||||
|
XCTAssertEqual(Set([b1, b2]).count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testMergeBlocksUnsortedInput() {
|
func testMergeBlocksUnsortedInput() {
|
||||||
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
|
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
|
||||||
let merged = mergeBlocks(blocks, gapMinutes: 0)
|
let merged = mergeBlocks(blocks, gapMinutes: 0)
|
||||||
@@ -150,8 +166,8 @@ final class BlockMathTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testUniqueBlocksByIDDifferentOccurrence() {
|
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||||
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
|
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
|
||||||
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
|
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
|
||||||
let result = uniqueBlocks([b1, b2], trackByID: true)
|
let result = uniqueBlocks([b1, b2], trackByID: true)
|
||||||
XCTAssertEqual(result.count, 2)
|
XCTAssertEqual(result.count, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import BusyMirror
|
||||||
|
|
||||||
|
final class SettingsPayloadTests: XCTestCase {
|
||||||
|
|
||||||
|
// Only the fields present in the very first schema. Every key added since
|
||||||
|
// (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters,
|
||||||
|
// excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs,
|
||||||
|
// appVersion, exportedAt) is deliberately missing here, simulating a
|
||||||
|
// settings.v2 blob written by an older build.
|
||||||
|
private let oldSchemaJSON = """
|
||||||
|
{
|
||||||
|
"daysBack": 3,
|
||||||
|
"daysForward": 10,
|
||||||
|
"mergeGapHours": 1,
|
||||||
|
"hideDetails": false,
|
||||||
|
"copyDescription": true,
|
||||||
|
"mirrorAllDay": true,
|
||||||
|
"overlapMode": "skipCovered",
|
||||||
|
"titlePrefix": "🪞 ",
|
||||||
|
"placeholderTitle": "Busy",
|
||||||
|
"autoDeleteMissing": true,
|
||||||
|
"routes": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws {
|
||||||
|
let data = oldSchemaJSON.data(using: .utf8)!
|
||||||
|
let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
||||||
|
|
||||||
|
// Fields present in the old blob are preserved.
|
||||||
|
XCTAssertEqual(payload.daysBack, 3)
|
||||||
|
XCTAssertEqual(payload.daysForward, 10)
|
||||||
|
XCTAssertEqual(payload.overlapMode, "skipCovered")
|
||||||
|
|
||||||
|
// Fields missing from the old blob fall back to their defaults instead
|
||||||
|
// of failing the whole decode.
|
||||||
|
XCTAssertEqual(payload.syncReminders, false)
|
||||||
|
XCTAssertEqual(payload.filterByWorkHours, false)
|
||||||
|
XCTAssertEqual(payload.workHoursStart, 9)
|
||||||
|
XCTAssertEqual(payload.workHoursEnd, 17)
|
||||||
|
XCTAssertEqual(payload.excludedTitleFilters, [])
|
||||||
|
XCTAssertEqual(payload.mirrorAcceptedOnly, false)
|
||||||
|
XCTAssertNil(payload.selectedSourceID)
|
||||||
|
XCTAssertNil(payload.selectedTargetIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncodeDecodeRoundTrip() throws {
|
||||||
|
let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false)
|
||||||
|
let original = ContentView.SettingsPayload(
|
||||||
|
daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false,
|
||||||
|
mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18,
|
||||||
|
excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true,
|
||||||
|
overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true,
|
||||||
|
routes: [route]
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(original)
|
||||||
|
let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
||||||
|
XCTAssertEqual(decoded.daysBack, original.daysBack)
|
||||||
|
XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters)
|
||||||
|
XCTAssertEqual(decoded.routes.count, 1)
|
||||||
|
XCTAssertEqual(decoded.routes[0].sourceID, "a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
All notable changes to BusyMirror will be documented in this file.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-08-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll (which only fired if the Mac happened to be awake at that instant, and got throttled/coalesced by macOS). Once saved routes exist, the app registers as a login item (`SMAppService.mainApp`), removes any previously-installed `launchd` schedule, and reacts to `EKEventStoreChanged` (debounced ~3s), `NSWorkspace.didWakeNotification` (resync after sleep), and a 30-minute fallback timer as a safety net for a missed notification. Auto-sync always writes (`writeEnabled: true`) regardless of the interactive dry-run toggle, matching what the old scheduled `--write 1` runs did.
|
||||||
|
- This logic lives in `BusyMirrorAppController`, not `ContentView` — the controller is owned by the `App` struct for the whole process lifetime, whereas `ContentView`'s own `EKEventStoreChanged` observer is torn down in `.onDisappear` when the main window closes (confirmed: `MenuBarSupport.swift`'s "Sync Now" already reopens the window before syncing, which only makes sense if the window's state is discarded on close). Auto-sync needs to work with the window closed, so it can't live there. ([MenuBarSupport.swift](BusyMirror/MenuBarSupport.swift))
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-08-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sync reminders**: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms/relative offsets into mirrored placeholders. This lets calendars that are synced to a phone ring for mirrored events. When merging is enabled, only the first event's alarms are preserved for a merged block. ([ContentView.swift](BusyMirror/ContentView.swift), [MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
|
||||||
|
- CLI flag `--sync-reminders` to enable reminder syncing from scripted/headless runs.
|
||||||
|
- CLI polish: `--help`/`-h`, `--list-calendars`, and `--status` (all support `--json` for machine-readable output). Scheduled/headless runs now record last-run time, success/failure, and a summary so `--status` can report real diagnostics instead of just log-file grepping. Failure paths (`no calendar access`, `no saved routes`) now exit with distinct nonzero codes instead of always exiting 0. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Settings silently wiped on upgrade**: `SettingsPayload` used fully auto-synthesized `Codable`, so decoding a settings blob written by an older build (missing a field added since, e.g. `syncReminders`) threw `keyNotFound` and failed the *entire* decode — not just that one field. The app then ran with in-code defaults (empty routes, filters, etc.), and the next autosave persisted that empty state back over the real data. `SettingsPayload` now has a custom `init(from:)` that reads every field added after the first release with `decodeIfPresent` + its existing default, matching the pattern `Route` already used. `loadSettingsFromDefaults()` also now recovers routes from the legacy `routes.v1` backup key if `settings.v2` decodes successfully but with an empty `routes` array — repairing installs that already hit this bug before upgrading. ([ContentView.swift](BusyMirror/ContentView.swift), [SettingsPayloadTests.swift](BusyMirrorTests/SettingsPayloadTests.swift))
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -45,11 +45,15 @@ See `CHANGELOG.md` for notable changes.
|
|||||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
|
- `BusyMirror.app/Contents/MacOS/BusyMirror --routes "1->2,3; 4->5" --write 1 --days-forward 7 --mode allow --exit`
|
||||||
- Run the routes already saved in the app settings:
|
- Run the routes already saved in the app settings:
|
||||||
- `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
- `BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||||
|
- `--help` prints full flag documentation.
|
||||||
|
- `--list-calendars [--json]` prints available calendars (index, id, title, source/type) and exits.
|
||||||
|
- `--status [--json]` prints last-run time/result, schedule state, and saved-route count and exits.
|
||||||
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters.
|
- Flags exist for privacy, all-day, merge gap, days window, overlap mode, cleanup, and filters.
|
||||||
- Filters:
|
- Filters:
|
||||||
- `--exclude-titles "token1, token2"`
|
- `--exclude-titles "token1, token2"`
|
||||||
- `--exclude-organizers "alice@example.com, Example Org"`
|
- `--exclude-organizers "alice@example.com, Example Org"`
|
||||||
- Tokens are comma or newline separated; matching is case-insensitive.
|
- Tokens are comma or newline separated; matching is case-insensitive.
|
||||||
|
- Exit codes: `0` success, `2` no calendar access, `3` no saved routes (with `--run-saved-routes`).
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
|
- BusyMirror now writes a persistent log file to `~/Library/Logs/BusyMirror/BusyMirror.log`.
|
||||||
|
|||||||
+16
-11
@@ -12,19 +12,24 @@
|
|||||||
- 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes
|
- 1.3.6: in-app scheduling via `launchd` with hourly/daily/weekday modes
|
||||||
- 1.3.6: generated macOS app icon set and packaged release assets
|
- 1.3.6: generated macOS app icon set and packaged release assets
|
||||||
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
|
- 1.4.0: unit-test suite (45 tests), Cancel button, progress indicator, sandbox LaunchAgent fix, mirror URL fix, engine refactor into `MirrorConfig`
|
||||||
|
- CLI diagnostics: `--help`, `--list-calendars [--json]`, `--status [--json]`, real exit codes (2 = no access, 3 = no saved routes), last-run tracking (time/ok/summary)
|
||||||
|
- 1.7.0: **Event-driven background sync**, replacing the hourly `launchd StartInterval` poll. Auto-sync logic lives in `BusyMirrorAppController` (an app-lifetime object, not tied to `ContentView`'s window lifecycle — closing the main window used to tear down the `EKEventStoreChanged` observer along with it, which would have made auto-sync a no-op whenever the window was closed). Once saved routes exist: registers as a login item (`SMAppService.mainApp`), removes any old `launchd` schedule, watches `EKEventStoreChanged` (debounced ~3s) and `NSWorkspace.didWakeNotification`, plus a 30-min fallback timer as a safety net. Auto-sync always writes (`writeEnabled: true`), independent of the interactive dry-run toggle.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently.
|
||||||
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
|
|
||||||
- Better server-side privacy mapping (per-provider heuristics)
|
|
||||||
|
|
||||||
## Then
|
## Then — V2 UI polish
|
||||||
- Signed/notarized binaries and release pipeline
|
- Finish the `ContentView.swift` split into per-section view models (Routes, Schedule, Privacy, Log) — 1.7.0 already pulled the calendar-access/auto-sync state out into `BusyMirrorAppController`; the rest (UI/settings/CLI) is still one ~1900-line file.
|
||||||
- CLI quality: friendlier `--routes` parsing and help flag
|
- Real `Settings { }` scene instead of the main window doubling as preferences.
|
||||||
- “Dry-run by default” preference
|
- Menu bar icon reflects state (idle / syncing / error) instead of a static icon.
|
||||||
|
- Surface last-sync-time / last-error / next-check in the menu bar UI — the data now exists (`--status`'s `lastRunAtISO`/`lastRunOK`/`lastRunSummary`), this is just wiring it into the menu.
|
||||||
|
- Better server-side privacy mapping (per-provider heuristics).
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
- Background monitoring (macOS)
|
- Signed/notarized binaries and release pipeline.
|
||||||
- Smarter cleanup & conflict resolution
|
- Smarter cleanup & conflict resolution.
|
||||||
- iOS/iPadOS helper (Shortcuts integration)
|
- iOS/iPadOS helper (Shortcuts integration).
|
||||||
- Profiles & MDM/Managed Config support
|
- Profiles & MDM/Managed Config support.
|
||||||
|
|
||||||
|
## Decided against
|
||||||
|
- **Direct Google/CalDAV/Exchange API integration** (OAuth flows, token storage, per-provider clients so BusyMirror can mirror a non-local calendar without it being added to macOS). EventKit already surfaces any calendar added via System Settings → Internet Accounts — macOS does the sync, auth, and refresh. Building a parallel integration would duplicate the OS for the narrow case of an account that can't or won't be added system-wide (e.g. MDM-restricted work accounts). Not worth the OAuth/Keychain/per-provider-quirk surface for that.
|
||||||
|
|||||||
@@ -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