Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2912d2f52a | |||
| a838e021a1 |
@@ -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 = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
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.3.6;
|
MARKETING_VERSION = 1.3.7;
|
||||||
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 = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
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.3.6;
|
MARKETING_VERSION = 1.3.7;
|
||||||
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;
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
|
|||||||
var out: [Block] = []
|
var out: [Block] = []
|
||||||
for b in blocks {
|
for b in blocks {
|
||||||
let key: String
|
let key: String
|
||||||
if trackByID, let sid = b.srcEventID {
|
if trackByID, let sid = b.srcStableID {
|
||||||
let occ = b.occurrence?.timeIntervalSince1970 ?? b.start.timeIntervalSince1970
|
let occ = b.occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
key = "id|\(sid)|\(occ)"
|
key = "id|\(sid)|\(occ)"
|
||||||
} else {
|
} else {
|
||||||
key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)"
|
key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)"
|
||||||
@@ -120,8 +120,62 @@ func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse mirror URL: mirror://<tgtID>|<srcCalID>|<srcEventID>|<occTS>|<startTS>|<endTS>
|
private let mirrorURLAllowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
||||||
private func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID: String?, srcEventID: String?, occ: Date?, start: Date?, end: Date?) {
|
|
||||||
|
private func mirrorURLComponentEncode(_ raw: String) -> String {
|
||||||
|
raw.addingPercentEncoding(withAllowedCharacters: mirrorURLAllowedCharacters) ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mirrorURLComponentDecode(_ raw: Substring) -> String {
|
||||||
|
let value = String(raw)
|
||||||
|
return value.removingPercentEncoding ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stableSourceIdentifier(for event: EKEvent) -> String? {
|
||||||
|
if let external = event.calendarItemExternalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!external.isEmpty {
|
||||||
|
return "ext:\(external)"
|
||||||
|
}
|
||||||
|
let local = event.calendarItemIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !local.isEmpty {
|
||||||
|
return "loc:\(local)"
|
||||||
|
}
|
||||||
|
if let legacy = event.eventIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!legacy.isEmpty {
|
||||||
|
return "evt:\(legacy)"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceOccurrenceKey(sourceCalID: String, sourceStableID: String, occurrence: Date?) -> String {
|
||||||
|
let occPart = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
|
return "\(sourceCalID)|\(sourceStableID)|\(occPart)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mirrorRecordKey(targetCalID: String, sourceKey: String) -> String {
|
||||||
|
"\(targetCalID)|\(sourceKey)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mirrorTimeKey(start: Date, end: Date) -> String {
|
||||||
|
"\(start.timeIntervalSince1970)|\(end.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildMirrorURL(targetCalID: String, sourceCalID: String, sourceStableID: String?, occurrence: Date?, start: Date, end: Date) -> URL? {
|
||||||
|
let sourceID = sourceStableID ?? ""
|
||||||
|
let occ = occurrence.map { String($0.timeIntervalSince1970) } ?? "-"
|
||||||
|
let parts = [
|
||||||
|
mirrorURLComponentEncode(targetCalID),
|
||||||
|
mirrorURLComponentEncode(sourceCalID),
|
||||||
|
mirrorURLComponentEncode(sourceID),
|
||||||
|
occ,
|
||||||
|
String(start.timeIntervalSince1970),
|
||||||
|
String(end.timeIntervalSince1970)
|
||||||
|
]
|
||||||
|
return URL(string: "mirror://\(parts.joined(separator: "|"))")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse mirror URL: mirror://<tgtID>|<srcCalID>|<srcStableID>|<occTS>|<startTS>|<endTS>
|
||||||
|
private func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID: String?, sourceStableID: String?, occ: Date?, start: Date?, end: Date?) {
|
||||||
guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil, nil, nil) }
|
guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil, nil, nil) }
|
||||||
let body = abs.dropFirst("mirror://".count)
|
let body = abs.dropFirst("mirror://".count)
|
||||||
let parts = body.split(separator: "|")
|
let parts = body.split(separator: "|")
|
||||||
@@ -131,10 +185,14 @@ private func parseMirrorURL(_ url: URL?) -> (targetCalID: String?, sourceCalID:
|
|||||||
var occDate: Date? = nil
|
var occDate: Date? = nil
|
||||||
var sDate: Date? = nil
|
var sDate: Date? = nil
|
||||||
var eDate: Date? = nil
|
var eDate: Date? = nil
|
||||||
if parts.count >= 1 { targetCalID = String(parts[0]) }
|
if parts.count >= 1 { targetCalID = mirrorURLComponentDecode(parts[0]) }
|
||||||
if parts.count >= 2 { sourceCalID = String(parts[1]) }
|
if parts.count >= 2 { sourceCalID = mirrorURLComponentDecode(parts[1]) }
|
||||||
if parts.count >= 3 { srcID = String(parts[2]) }
|
if parts.count >= 3 {
|
||||||
|
let decoded = mirrorURLComponentDecode(parts[2])
|
||||||
|
srcID = decoded.isEmpty ? nil : decoded
|
||||||
|
}
|
||||||
if parts.count >= 4,
|
if parts.count >= 4,
|
||||||
|
String(parts[3]) != "-",
|
||||||
let ts = TimeInterval(String(parts[3])) {
|
let ts = TimeInterval(String(parts[3])) {
|
||||||
occDate = Date(timeIntervalSince1970: ts)
|
occDate = Date(timeIntervalSince1970: ts)
|
||||||
}
|
}
|
||||||
@@ -159,12 +217,35 @@ private func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -
|
|||||||
struct Block: Hashable {
|
struct Block: Hashable {
|
||||||
let start: Date
|
let start: Date
|
||||||
let end: Date
|
let end: Date
|
||||||
let srcEventID: String? // for reschedule tracking
|
let srcStableID: String? // stable source item ID for reschedule tracking
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MirrorRecord: Hashable, Codable {
|
||||||
|
var targetCalendarID: String
|
||||||
|
var sourceCalendarID: String
|
||||||
|
var sourceStableID: String
|
||||||
|
var occurrenceTimestamp: TimeInterval?
|
||||||
|
var targetEventIdentifier: String?
|
||||||
|
var lastKnownStartTimestamp: TimeInterval
|
||||||
|
var lastKnownEndTimestamp: TimeInterval
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
|
||||||
|
var sourceKey: String {
|
||||||
|
sourceOccurrenceKey(
|
||||||
|
sourceCalID: sourceCalendarID,
|
||||||
|
sourceStableID: sourceStableID,
|
||||||
|
occurrence: occurrenceTimestamp.map { Date(timeIntervalSince1970: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeKey: String {
|
||||||
|
"\(lastKnownStartTimestamp)|\(lastKnownEndTimestamp)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Route: Identifiable, Hashable, Codable {
|
struct Route: Identifiable, Hashable, Codable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
var sourceID: String
|
var sourceID: String
|
||||||
@@ -248,6 +329,7 @@ struct ContentView: View {
|
|||||||
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
|
||||||
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title
|
||||||
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists
|
||||||
|
private let mirrorIndexDefaultsKey = "mirror-index.v1"
|
||||||
|
|
||||||
// Mirrors can run either by manual selection (source + at least one target)
|
// Mirrors can run either by manual selection (source + at least one target)
|
||||||
// or using predefined routes. This derived flag controls the Mirror Now button.
|
// or using predefined routes. This derived flag controls the Mirror Now button.
|
||||||
@@ -257,6 +339,25 @@ struct ContentView: View {
|
|||||||
return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets)
|
return hasAccess && !isRunning && !calendars.isEmpty && (hasManualTargets || hasRouteTargets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadMirrorIndex() -> [String: MirrorRecord] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: mirrorIndexDefaultsKey) else { return [:] }
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: MirrorRecord].self, from: data)
|
||||||
|
} catch {
|
||||||
|
log("✗ Failed to load mirror index: \(error.localizedDescription)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveMirrorIndex(_ index: [String: MirrorRecord]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(index)
|
||||||
|
UserDefaults.standard.set(data, forKey: mirrorIndexDefaultsKey)
|
||||||
|
} catch {
|
||||||
|
log("✗ Failed to save mirror index: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static let intFormatter: NumberFormatter = {
|
private static let intFormatter: NumberFormatter = {
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter()
|
||||||
f.numberStyle = .none
|
f.numberStyle = .none
|
||||||
@@ -1660,8 +1761,8 @@ struct ContentView: View {
|
|||||||
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 }
|
||||||
// Defensive: never treat events from another calendar as source
|
// Defensive: never treat events from another calendar as source
|
||||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||||
let srcID = ev.eventIdentifier // stable across launches unless event is deleted
|
let srcID = stableSourceIdentifier(for: ev)
|
||||||
srcBlocks.append(Block(start: s, end: e, srcEventID: 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))
|
||||||
}
|
}
|
||||||
if skippedMirrors > 0 {
|
if skippedMirrors > 0 {
|
||||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||||
@@ -1684,6 +1785,13 @@ struct ContentView: View {
|
|||||||
// Merge for Flights or similar
|
// Merge for Flights or similar
|
||||||
let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks
|
let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks
|
||||||
let trackByID = (mergeGapMin == 0)
|
let trackByID = (mergeGapMin == 0)
|
||||||
|
var mirrorIndex = loadMirrorIndex()
|
||||||
|
var mirrorIndexChanged = false
|
||||||
|
|
||||||
|
func sourceKey(for blk: Block) -> String? {
|
||||||
|
guard trackByID, let sid = blk.srcStableID else { return nil }
|
||||||
|
return sourceOccurrenceKey(sourceCalID: srcCal.calendarIdentifier, sourceStableID: sid, occurrence: blk.occurrence)
|
||||||
|
}
|
||||||
|
|
||||||
for tgt in targets {
|
for tgt in targets {
|
||||||
let tgtName = calLabel(tgt)
|
let tgtName = calLabel(tgt)
|
||||||
@@ -1704,23 +1812,43 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var placeholderSet = Set<String>()
|
var placeholderSet = Set<String>()
|
||||||
var occupied: [Block] = []
|
var occupied: [Block] = []
|
||||||
var placeholdersByOccurrenceID: [String: EKEvent] = [:]
|
var placeholdersBySourceKey: [String: EKEvent] = [:]
|
||||||
var placeholdersByTime: [String: EKEvent] = [:]
|
var placeholdersByTime: [String: EKEvent] = [:]
|
||||||
|
var targetEventsByIdentifier: [String: EKEvent] = [:]
|
||||||
for tv in tgtEvents {
|
for tv in tgtEvents {
|
||||||
// Defensive: should already be filtered, but double-check target identity
|
|
||||||
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
|
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
|
||||||
|
if let eid = tv.eventIdentifier {
|
||||||
|
targetEventsByIdentifier[eid] = tv
|
||||||
|
}
|
||||||
if let ts = tv.startDate, let te = tv.endDate {
|
if let ts = tv.startDate, let te = tv.endDate {
|
||||||
let timeKey = "\(ts.timeIntervalSince1970)|\(te.timeIntervalSince1970)"
|
let timeKey = mirrorTimeKey(start: ts, end: te)
|
||||||
if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) {
|
if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) {
|
||||||
placeholderSet.insert(timeKey)
|
placeholderSet.insert(timeKey)
|
||||||
placeholdersByTime[timeKey] = tv
|
placeholdersByTime[timeKey] = tv
|
||||||
let parsed = parseMirrorURL(tv.url)
|
let parsed = parseMirrorURL(tv.url)
|
||||||
if let sid = parsed.srcEventID, let occ = parsed.occ {
|
if let sourceCalID = parsed.sourceCalID,
|
||||||
let key = "\(sid)|\(occ.timeIntervalSince1970)"
|
let sourceStableID = parsed.sourceStableID,
|
||||||
placeholdersByOccurrenceID[key] = tv
|
!sourceCalID.isEmpty,
|
||||||
|
!sourceStableID.isEmpty {
|
||||||
|
let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ)
|
||||||
|
placeholdersBySourceKey[key] = tv
|
||||||
|
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
|
||||||
|
let record = MirrorRecord(
|
||||||
|
targetCalendarID: tgt.calendarIdentifier,
|
||||||
|
sourceCalendarID: sourceCalID,
|
||||||
|
sourceStableID: sourceStableID,
|
||||||
|
occurrenceTimestamp: parsed.occ?.timeIntervalSince1970,
|
||||||
|
targetEventIdentifier: tv.eventIdentifier,
|
||||||
|
lastKnownStartTimestamp: ts.timeIntervalSince1970,
|
||||||
|
lastKnownEndTimestamp: te.timeIntervalSince1970
|
||||||
|
)
|
||||||
|
if mirrorIndex[recordKey] != record {
|
||||||
|
mirrorIndex[recordKey] = record
|
||||||
|
mirrorIndexChanged = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
occupied.append(Block(start: ts, end: te, srcEventID: nil, label: nil, notes: nil, occurrence: nil))
|
}
|
||||||
|
occupied.append(Block(start: ts, end: te, srcStableID: nil, label: nil, notes: nil, occurrence: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
occupied = coalesce(occupied)
|
occupied = coalesce(occupied)
|
||||||
@@ -1730,102 +1858,129 @@ struct ContentView: View {
|
|||||||
var updated = 0
|
var updated = 0
|
||||||
var warnedPrivateUnsupported = false
|
var warnedPrivateUnsupported = false
|
||||||
|
|
||||||
// Cross-route loop guard: unique key generator for (source, occurrence/time, target)
|
|
||||||
func guardKey(for blk: Block, targetID: String) -> String {
|
func guardKey(for blk: Block, targetID: String) -> String {
|
||||||
if trackByID, let sid = blk.srcEventID {
|
if let key = sourceKey(for: blk) {
|
||||||
let occ = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
return "\(key)|\(targetID)"
|
||||||
return "\(srcCal.calendarIdentifier)|\(sid)|\(occ)|\(targetID)"
|
}
|
||||||
} else {
|
|
||||||
return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)"
|
return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desiredNotes(for blk: Block) -> String? {
|
||||||
|
(!hideDetails && copyDescription) ? blk.notes : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertMirrorRecord(for blk: Block, event: EKEvent) {
|
||||||
|
guard let sid = blk.srcStableID,
|
||||||
|
let key = sourceKey(for: blk),
|
||||||
|
let startDate = event.startDate,
|
||||||
|
let endDate = event.endDate else { return }
|
||||||
|
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
|
||||||
|
let record = MirrorRecord(
|
||||||
|
targetCalendarID: tgt.calendarIdentifier,
|
||||||
|
sourceCalendarID: srcCal.calendarIdentifier,
|
||||||
|
sourceStableID: sid,
|
||||||
|
occurrenceTimestamp: blk.occurrence?.timeIntervalSince1970,
|
||||||
|
targetEventIdentifier: event.eventIdentifier,
|
||||||
|
lastKnownStartTimestamp: startDate.timeIntervalSince1970,
|
||||||
|
lastKnownEndTimestamp: endDate.timeIntervalSince1970
|
||||||
|
)
|
||||||
|
if mirrorIndex[recordKey] != record {
|
||||||
|
mirrorIndex[recordKey] = record
|
||||||
|
mirrorIndexChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeMirrorRecord(for key: String) {
|
||||||
|
let recordKey = mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: key)
|
||||||
|
if mirrorIndex.removeValue(forKey: recordKey) != nil {
|
||||||
|
mirrorIndexChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveMappedEvent(for record: MirrorRecord) -> EKEvent? {
|
||||||
|
if let eid = record.targetEventIdentifier,
|
||||||
|
let event = targetEventsByIdentifier[eid],
|
||||||
|
event.calendar.calendarIdentifier == tgt.calendarIdentifier {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
return placeholdersByTime[record.timeKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
func rememberMirrorEvent(_ event: EKEvent, for blk: Block) {
|
||||||
|
if let startDate = event.startDate, let endDate = event.endDate {
|
||||||
|
let timeKey = mirrorTimeKey(start: startDate, end: endDate)
|
||||||
|
placeholderSet.insert(timeKey)
|
||||||
|
placeholdersByTime[timeKey] = event
|
||||||
|
}
|
||||||
|
if let key = sourceKey(for: blk) {
|
||||||
|
placeholdersBySourceKey[key] = event
|
||||||
|
}
|
||||||
|
if let eid = event.eventIdentifier {
|
||||||
|
targetEventsByIdentifier[eid] = event
|
||||||
|
}
|
||||||
|
upsertMirrorRecord(for: blk, event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsUpdate(existing: EKEvent, blk: Block, displayTitle: String, desiredNotes: String?, desiredURL: URL?) -> Bool {
|
||||||
|
let curS = existing.startDate ?? blk.start
|
||||||
|
let curE = existing.endDate ?? blk.end
|
||||||
|
if abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN * 60 { return true }
|
||||||
|
if abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN * 60 { return true }
|
||||||
|
if (existing.title ?? "") != displayTitle { return true }
|
||||||
|
if (existing.notes ?? "") != (desiredNotes ?? "") { return true }
|
||||||
|
if existing.isAllDay { return true }
|
||||||
|
if (existing.url?.absoluteString ?? "") != (desiredURL?.absoluteString ?? "") { return true }
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOrUpdateIfNeeded(_ blk: Block) async {
|
func createOrUpdateIfNeeded(_ blk: Block) async {
|
||||||
// Cross-route loop guard: skip if this (source occurrence -> target) was handled earlier this click
|
|
||||||
let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier)
|
let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier)
|
||||||
if sessionGuard.contains(gKey) {
|
if sessionGuard.contains(gKey) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Privacy-aware title/notes (strip our prefix so it never doubles up)
|
|
||||||
let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix)
|
let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix)
|
||||||
let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle)
|
let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle)
|
||||||
let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
|
let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
|
||||||
let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle
|
let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle
|
||||||
|
let notes = desiredNotes(for: blk)
|
||||||
|
let desiredURL = buildMirrorURL(
|
||||||
|
targetCalID: tgt.calendarIdentifier,
|
||||||
|
sourceCalID: srcCal.calendarIdentifier,
|
||||||
|
sourceStableID: blk.srcStableID,
|
||||||
|
occurrence: blk.occurrence,
|
||||||
|
start: blk.start,
|
||||||
|
end: blk.end
|
||||||
|
)
|
||||||
|
let exactTimeKey = mirrorTimeKey(start: blk.start, end: blk.end)
|
||||||
|
let blkSourceKey = sourceKey(for: blk)
|
||||||
|
|
||||||
// Fallback 0: if an existing mirrored event has the exact same time, update it
|
func updateExisting(_ existing: EKEvent, byTime: Bool) async {
|
||||||
let exactTimeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
|
|
||||||
if let existingByTime = placeholdersByTime[exactTimeKey] {
|
|
||||||
if !writeEnabled {
|
|
||||||
sessionGuard.insert(gKey)
|
|
||||||
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end) [title: \(displayTitle)]")
|
|
||||||
updated += 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existingByTime.title = displayTitle
|
|
||||||
existingByTime.startDate = blk.start
|
|
||||||
existingByTime.endDate = blk.end
|
|
||||||
existingByTime.isAllDay = false
|
|
||||||
if !hideDetails && copyDescription {
|
|
||||||
existingByTime.notes = blk.notes
|
|
||||||
} else {
|
|
||||||
existingByTime.notes = nil
|
|
||||||
}
|
|
||||||
let sid0 = blk.srcEventID ?? ""
|
|
||||||
let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
|
||||||
existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
|
|
||||||
let okTime = setEventPrivateIfSupported(existingByTime, markPrivate)
|
|
||||||
if markPrivate && !okTime && !warnedPrivateUnsupported {
|
|
||||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
|
||||||
warnedPrivateUnsupported = true
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try await MainActor.run {
|
|
||||||
try store.save(existingByTime, span: .thisEvent, commit: true)
|
|
||||||
}
|
|
||||||
log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)")
|
|
||||||
placeholderSet.insert(exactTimeKey)
|
|
||||||
placeholdersByTime[exactTimeKey] = existingByTime
|
|
||||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)])
|
|
||||||
sessionGuard.insert(gKey)
|
|
||||||
updated += 1
|
|
||||||
} catch {
|
|
||||||
log("Update failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we can track by source event ID and we have one, prefer updating the existing placeholder
|
|
||||||
if trackByID, let sid = blk.srcEventID {
|
|
||||||
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
|
||||||
let lookupKey = "\(sid)|\(occTS)"
|
|
||||||
if let existing = placeholdersByOccurrenceID[lookupKey] {
|
|
||||||
let curS = existing.startDate ?? blk.start
|
let curS = existing.startDate ?? blk.start
|
||||||
let curE = existing.endDate ?? blk.end
|
let curE = existing.endDate ?? blk.end
|
||||||
let needsUpdate = abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN*60 || abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN*60
|
rememberMirrorEvent(existing, for: blk)
|
||||||
if !needsUpdate {
|
if !needsUpdate(existing: existing, blk: blk, displayTitle: displayTitle, desiredNotes: notes, desiredURL: desiredURL) {
|
||||||
|
sessionGuard.insert(gKey)
|
||||||
skipped += 1
|
skipped += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let byTimeSuffix = byTime ? " (by time)" : ""
|
||||||
if !writeEnabled {
|
if !writeEnabled {
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
log("~ WOULD UPDATE [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
||||||
updated += 1
|
updated += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
existing.title = displayTitle
|
||||||
existing.startDate = blk.start
|
existing.startDate = blk.start
|
||||||
existing.endDate = blk.end
|
existing.endDate = blk.end
|
||||||
existing.title = displayTitle
|
|
||||||
existing.isAllDay = false
|
existing.isAllDay = false
|
||||||
if !hideDetails && copyDescription {
|
existing.notes = notes
|
||||||
existing.notes = blk.notes
|
existing.url = desiredURL
|
||||||
} else {
|
let ok = setEventPrivateIfSupported(existing, markPrivate)
|
||||||
existing.notes = nil
|
if markPrivate && !ok && !warnedPrivateUnsupported {
|
||||||
}
|
|
||||||
existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
|
|
||||||
let okOcc = setEventPrivateIfSupported(existing, markPrivate)
|
|
||||||
if markPrivate && !okOcc && !warnedPrivateUnsupported {
|
|
||||||
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
|
||||||
warnedPrivateUnsupported = true
|
warnedPrivateUnsupported = true
|
||||||
}
|
}
|
||||||
@@ -1833,43 +1988,55 @@ struct ContentView: View {
|
|||||||
try await MainActor.run {
|
try await MainActor.run {
|
||||||
try store.save(existing, span: .thisEvent, commit: true)
|
try store.save(existing, span: .thisEvent, commit: true)
|
||||||
}
|
}
|
||||||
log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
log("✓ UPDATED [\(srcName) -> \(tgtName)]\(byTimeSuffix) \(blk.start) -> \(blk.end)")
|
||||||
placeholderSet.insert("\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
|
rememberMirrorEvent(existing, for: blk)
|
||||||
let timeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
|
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
||||||
placeholdersByTime[timeKey] = existing
|
|
||||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)])
|
|
||||||
placeholdersByOccurrenceID[lookupKey] = existing
|
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
updated += 1
|
updated += 1
|
||||||
} catch {
|
} catch {
|
||||||
log("Update failed: \(error.localizedDescription)")
|
log("Update failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing placeholder for this block; dedupe by exact times
|
if let blkSourceKey,
|
||||||
let k = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
|
let record = mirrorIndex[mirrorRecordKey(targetCalID: tgt.calendarIdentifier, sourceKey: blkSourceKey)],
|
||||||
if placeholderSet.contains(k) { skipped += 1; return }
|
let existing = resolveMappedEvent(for: record) {
|
||||||
|
await updateExisting(existing, byTime: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let blkSourceKey, let existing = placeholdersBySourceKey[blkSourceKey] {
|
||||||
|
await updateExisting(existing, byTime: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existingByTime = placeholdersByTime[exactTimeKey] {
|
||||||
|
await updateExisting(existingByTime, byTime: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if placeholderSet.contains(exactTimeKey) {
|
||||||
|
skipped += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
if !writeEnabled {
|
if !writeEnabled {
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Invariant: never write to the source calendar by mistake
|
guard tgt.calendarIdentifier != srcCal.calendarIdentifier else {
|
||||||
guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { skipped += 1; log("- SKIP invariant: target is source [\(srcName)]"); return }
|
skipped += 1
|
||||||
|
log("- SKIP invariant: target is source [\(srcName)]")
|
||||||
|
return
|
||||||
|
}
|
||||||
let newEv = EKEvent(eventStore: store)
|
let newEv = EKEvent(eventStore: store)
|
||||||
newEv.calendar = tgt
|
newEv.calendar = tgt
|
||||||
newEv.title = displayTitle
|
newEv.title = displayTitle
|
||||||
newEv.startDate = blk.start
|
newEv.startDate = blk.start
|
||||||
newEv.endDate = blk.end
|
newEv.endDate = blk.end
|
||||||
newEv.isAllDay = false
|
newEv.isAllDay = false
|
||||||
if !hideDetails && copyDescription {
|
newEv.notes = notes
|
||||||
newEv.notes = blk.notes
|
newEv.url = desiredURL
|
||||||
}
|
|
||||||
let sid = blk.srcEventID ?? ""
|
|
||||||
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
|
||||||
newEv.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
|
|
||||||
newEv.availability = .busy
|
newEv.availability = .busy
|
||||||
let okNew = setEventPrivateIfSupported(newEv, markPrivate)
|
let okNew = setEventPrivateIfSupported(newEv, markPrivate)
|
||||||
if markPrivate && !okNew && !warnedPrivateUnsupported {
|
if markPrivate && !okNew && !warnedPrivateUnsupported {
|
||||||
@@ -1882,14 +2049,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
created += 1
|
created += 1
|
||||||
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)")
|
||||||
placeholderSet.insert(k)
|
rememberMirrorEvent(newEv, for: blk)
|
||||||
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)])
|
occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)])
|
||||||
if !sid.isEmpty {
|
|
||||||
let key = "\(sid)|\(occTS)"
|
|
||||||
placeholdersByOccurrenceID[key] = newEv
|
|
||||||
}
|
|
||||||
let timeKey = k
|
|
||||||
placeholdersByTime[timeKey] = newEv
|
|
||||||
sessionGuard.insert(gKey)
|
sessionGuard.insert(gKey)
|
||||||
} catch {
|
} catch {
|
||||||
log("Save failed: \(error.localizedDescription)")
|
log("Save failed: \(error.localizedDescription)")
|
||||||
@@ -1918,25 +2079,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)")
|
log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)")
|
||||||
// Auto-delete placeholders whose source instance no longer exists.
|
|
||||||
// Works even when no source events remain in the window, and
|
|
||||||
// also cleans up legacy mirrors that lack a mirror URL (by time).
|
|
||||||
if autoDeleteMissing {
|
if autoDeleteMissing {
|
||||||
let trackByID = (mergeGapMin == 0)
|
let activeSourceKeys = Set(baseBlocks.compactMap { sourceKey(for: $0) })
|
||||||
|
let validTimeKeys = Set(baseBlocks.map { mirrorTimeKey(start: $0.start, end: $0.end) })
|
||||||
let isMultiRouteRun = !routes.isEmpty
|
let isMultiRouteRun = !routes.isEmpty
|
||||||
// Build valid occurrence keys from current source blocks (when trackByID)
|
|
||||||
let validOccKeys: Set<String> = Set(baseBlocks.compactMap { blk in
|
|
||||||
guard trackByID, let sid = blk.srcEventID else { return nil }
|
|
||||||
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
|
|
||||||
return "\(sid)|\(occTS)"
|
|
||||||
})
|
|
||||||
// Also build valid time keys for legacy/fallback cleanup
|
|
||||||
let validTimeKeys: Set<String> = Set(baseBlocks.map { blk in
|
|
||||||
"\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
|
|
||||||
})
|
|
||||||
|
|
||||||
// Consider all known placeholders on target (URL or title-identified)
|
|
||||||
// Deduplicate by eventIdentifier to avoid double-processing updated items
|
|
||||||
var byID: [String: EKEvent] = [:]
|
var byID: [String: EKEvent] = [:]
|
||||||
for tv in placeholdersByTime.values {
|
for tv in placeholdersByTime.values {
|
||||||
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
|
guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
|
||||||
@@ -1946,35 +2093,77 @@ struct ContentView: View {
|
|||||||
var removed = 0
|
var removed = 0
|
||||||
var skippedOtherSource = 0
|
var skippedOtherSource = 0
|
||||||
var skippedLegacyNoURL = 0
|
var skippedLegacyNoURL = 0
|
||||||
|
var handledEventIDs = Set<String>()
|
||||||
|
|
||||||
|
let staleMirrorRecords = mirrorIndex.filter {
|
||||||
|
$0.value.targetCalendarID == tgt.calendarIdentifier &&
|
||||||
|
$0.value.sourceCalendarID == srcCal.calendarIdentifier &&
|
||||||
|
!activeSourceKeys.contains($0.value.sourceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (recordKey, record) in staleMirrorRecords {
|
||||||
|
let candidate = resolveMappedEvent(for: record)
|
||||||
|
if let candidate {
|
||||||
|
if !writeEnabled {
|
||||||
|
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(candidate.startDate ?? windowStart) -> \(candidate.endDate ?? windowEnd)")
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try await MainActor.run {
|
||||||
|
try store.remove(candidate, span: .thisEvent, commit: true)
|
||||||
|
}
|
||||||
|
removed += 1
|
||||||
|
} catch {
|
||||||
|
log("Delete failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let eid = candidate.eventIdentifier {
|
||||||
|
handledEventIDs.insert(eid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if writeEnabled || candidate == nil {
|
||||||
|
if mirrorIndex.removeValue(forKey: recordKey) != nil {
|
||||||
|
mirrorIndexChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for ev in byID.values {
|
for ev in byID.values {
|
||||||
|
if let eid = ev.eventIdentifier, handledEventIDs.contains(eid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
let parsed = parseMirrorURL(ev.url)
|
let parsed = parseMirrorURL(ev.url)
|
||||||
var shouldDelete = false
|
var shouldDelete = false
|
||||||
|
var parsedSourceKey: String? = nil
|
||||||
if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty {
|
if let sourceCalID = parsed.sourceCalID, !sourceCalID.isEmpty {
|
||||||
// Only clean up placeholders that belong to this route's source calendar.
|
|
||||||
if sourceCalID != srcCal.calendarIdentifier {
|
if sourceCalID != srcCal.calendarIdentifier {
|
||||||
skippedOtherSource += 1
|
skippedOtherSource += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if let sid = parsed.srcEventID, let occ = parsed.occ {
|
if let sourceStableID = parsed.sourceStableID, !sourceStableID.isEmpty {
|
||||||
let k = "\(sid)|\(occ.timeIntervalSince1970)"
|
let key = sourceOccurrenceKey(sourceCalID: sourceCalID, sourceStableID: sourceStableID, occurrence: parsed.occ)
|
||||||
// If key not present among current source instances, delete
|
parsedSourceKey = key
|
||||||
if !validOccKeys.contains(k) { shouldDelete = true }
|
if !activeSourceKeys.contains(key) { shouldDelete = true }
|
||||||
} else if trackByID {
|
} else if trackByID,
|
||||||
// Legacy-ish URL payload for this source: compare exact time window membership.
|
let s = ev.startDate,
|
||||||
if let s = ev.startDate, let e = ev.endDate {
|
let e = ev.endDate,
|
||||||
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)"
|
!validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) {
|
||||||
if !validTimeKeys.contains(tk) { shouldDelete = true }
|
shouldDelete = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if trackByID && !isMultiRouteRun {
|
} else if trackByID && !isMultiRouteRun {
|
||||||
// Legacy fallback without URL source only in single-source runs.
|
if let s = ev.startDate,
|
||||||
if let s = ev.startDate, let e = ev.endDate {
|
let e = ev.endDate,
|
||||||
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)"
|
!validTimeKeys.contains(mirrorTimeKey(start: s, end: e)) {
|
||||||
if !validTimeKeys.contains(tk) { shouldDelete = true }
|
shouldDelete = true
|
||||||
}
|
}
|
||||||
} else if trackByID && isMultiRouteRun {
|
} else if trackByID && isMultiRouteRun {
|
||||||
// In multi-route runs, avoid deleting URL-less placeholders that may belong to another source.
|
let hasMapping = mirrorIndex.values.contains {
|
||||||
|
$0.targetCalendarID == tgt.calendarIdentifier &&
|
||||||
|
$0.sourceCalendarID == srcCal.calendarIdentifier &&
|
||||||
|
$0.targetEventIdentifier == ev.eventIdentifier
|
||||||
|
}
|
||||||
|
if !hasMapping {
|
||||||
skippedLegacyNoURL += 1
|
skippedLegacyNoURL += 1
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shouldDelete {
|
if shouldDelete {
|
||||||
@@ -1986,8 +2175,12 @@ struct ContentView: View {
|
|||||||
try store.remove(ev, span: .thisEvent, commit: true)
|
try store.remove(ev, span: .thisEvent, commit: true)
|
||||||
}
|
}
|
||||||
removed += 1
|
removed += 1
|
||||||
|
} catch {
|
||||||
|
log("Delete failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
catch { log("Delete failed: \(error.localizedDescription)") }
|
}
|
||||||
|
if let key = parsedSourceKey {
|
||||||
|
removeMirrorRecord(for: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1996,10 +2189,13 @@ struct ContentView: View {
|
|||||||
log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)")
|
log("- INFO cleanup skipped \(skippedOtherSource) placeholders from other source routes on \(tgtName)")
|
||||||
}
|
}
|
||||||
if skippedLegacyNoURL > 0 {
|
if skippedLegacyNoURL > 0 {
|
||||||
log("- INFO cleanup skipped \(skippedLegacyNoURL) legacy placeholders without source URL on \(tgtName)")
|
log("- INFO cleanup skipped \(skippedLegacyNoURL) unmanaged legacy placeholders without source metadata on \(tgtName)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if mirrorIndexChanged {
|
||||||
|
saveMirrorIndex(mirrorIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Export / Import Settings
|
// MARK: - Export / Import Settings
|
||||||
@@ -2285,14 +2481,14 @@ private struct SettingsPayload: Codable {
|
|||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil)
|
var cur = Block(start: sorted[0].start, end: sorted[0].end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil) }
|
if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) }
|
||||||
} else {
|
} else {
|
||||||
out.append(cur)
|
out.append(cur)
|
||||||
cur = Block(start: b.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)
|
cur = Block(start: b.start, end: b.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.append(cur)
|
out.append(cur)
|
||||||
@@ -2307,22 +2503,22 @@ private struct SettingsPayload: Codable {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] }
|
if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if ee > ss { segs.append(Block(start: ss, end: ee, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||||
}
|
}
|
||||||
if segs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] }
|
if segs.isEmpty { return [Block(start: block.start, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)] }
|
||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||||
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, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) }
|
if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil)) }
|
||||||
return gaps
|
return gaps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
All notable changes to BusyMirror will be documented in this file.
|
||||||
|
|
||||||
|
## [1.3.7] - 2026-03-24
|
||||||
|
- Fix: mirror reconciliation now survives target providers that strip BusyMirror's custom event URL metadata.
|
||||||
|
- Fix: moved and deleted source events are tracked via stable EventKit identifiers and a persisted local mirror index, so target placeholders update reliably.
|
||||||
|
- Fix: mirror updates now detect title and notes changes, not just start/end time changes.
|
||||||
|
- Build: bump version to 1.3.7 (build 15).
|
||||||
|
|
||||||
## [1.3.6] - 2026-03-13
|
## [1.3.6] - 2026-03-13
|
||||||
- Scheduling: add in-app `Scheduled runs` controls to install or remove a user `launchd` LaunchAgent from BusyMirror itself.
|
- Scheduling: add in-app `Scheduled runs` controls to install or remove a user `launchd` LaunchAgent from BusyMirror itself.
|
||||||
- Scheduling: support `Hourly`, `Daily`, and `Weekdays` schedules; hourly mode runs saved routes via `StartInterval`.
|
- Scheduling: support `Hourly`, `Daily`, and `Weekdays` schedules; hourly mode runs saved routes via `StartInterval`.
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ BusyMirror mirrors meetings between your calendars so your availability stays co
|
|||||||
- Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy").
|
- Private (hide details): mirrors placeholders with prefix + placeholder title (e.g., "🪞 Busy").
|
||||||
- Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort).
|
- Mark Private: mirrors prefix + real title, but marks events Private on supported servers (best-effort).
|
||||||
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
- DRY-RUN mode: see what would be created/updated/deleted without writing.
|
||||||
|
- Activity Log in the app plus persistent file logging on disk.
|
||||||
|
- In-app scheduling: install or remove a `launchd` LaunchAgent from the `Scheduled runs` section.
|
||||||
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
- Overlap modes: `allow`, `skipCovered`, `fillGaps`.
|
||||||
- Merge adjacent events with a configurable gap.
|
- Merge adjacent events with a configurable gap.
|
||||||
- Time window controls (days back/forward) and Work Hours filter.
|
- Time window controls (days back/forward) and Work Hours filter.
|
||||||
- Accepted-only filter (mirror your accepted meetings only).
|
- Accepted-only filter (mirror your accepted meetings only).
|
||||||
- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared.
|
- Cleanup of placeholders, including auto-delete of mirrors whose source disappeared.
|
||||||
|
- Refresh Calendars prunes stale saved calendars and routes when calendars are removed from the system.
|
||||||
- Prefix-based tagging and loop guards to prevent re-mirroring mirrors.
|
- Prefix-based tagging and loop guards to prevent re-mirroring mirrors.
|
||||||
- Settings: autosave/restore, Import/Export JSON.
|
- Settings: autosave/restore, Import/Export JSON, saved routes for scheduled/headless runs.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
Use one calendar’s confirmed meetings to block time in other calendars (e.g., corporate iPad vs. personal devices).
|
||||||
@@ -48,6 +51,7 @@ See `CHANGELOG.md` for notable changes.
|
|||||||
## 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`.
|
||||||
- When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`.
|
- When the file grows large, the previous file is rotated to `~/Library/Logs/BusyMirror/BusyMirror.previous.log`.
|
||||||
|
- `launchd` stdout/stderr for scheduled runs are also written in the same folder.
|
||||||
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
- In the UI, use `Reveal Log File` to open the current log directly in Finder.
|
||||||
|
|
||||||
## Scheduling
|
## Scheduling
|
||||||
@@ -57,7 +61,7 @@ See `CHANGELOG.md` for notable changes.
|
|||||||
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
- `/Applications/BusyMirror.app/Contents/MacOS/BusyMirror --run-saved-routes --write 1 --exit`
|
||||||
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
- This is more stable than index-based `--routes`, because it uses the routes and per-route options you already configured in the UI.
|
||||||
- Hourly schedules use `launchd` `StartInterval`; daily and weekday schedules use `StartCalendarInterval`.
|
- Hourly schedules use `launchd` `StartInterval`; daily and weekday schedules use `StartCalendarInterval`.
|
||||||
- You can remove the job from the same UI with `Remove Schedule`.
|
- You can remove the job from the same UI with `Remove Schedule`, and inspect the generated plist with `Reveal LaunchAgent`.
|
||||||
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
- Note: scheduled headless runs depend on Calendar permission being granted to the installed app. Because these local builds are unsigned, macOS may require re-granting permission after replacing the app bundle with a new build.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
- Work Hours filter and title-based skip filters
|
- Work Hours filter and title-based skip filters
|
||||||
- Privacy: placeholders with prefix + customizable title
|
- Privacy: placeholders with prefix + customizable title
|
||||||
- 1.3.0: Mark Private option (global + per-route)
|
- 1.3.0: Mark Private option (global + per-route)
|
||||||
|
- 1.3.4: persistent file logging, stale-calendar pruning on refresh, clickable top-bar mode toggle
|
||||||
|
- 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
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
- Auto-refresh calendars on `EKEventStoreChanged` (live refresh button-less)
|
||||||
- Hint near "Mirror Now" indicating run mode (Routes vs Manual)
|
- Better scheduled-run diagnostics in the UI (last run / last error / next run)
|
||||||
- Better server-side privacy mapping (per-provider heuristics)
|
- Better server-side privacy mapping (per-provider heuristics)
|
||||||
|
|
||||||
## Then
|
## Then
|
||||||
|
|||||||
9
ReleaseNotes-1.3.7.md
Normal file
9
ReleaseNotes-1.3.7.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
BusyMirror 1.3.7 - 2026-03-24
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- Fix mirrored event tracking on providers that do not preserve BusyMirror's custom event URL metadata.
|
||||||
|
- Track source events using stable EventKit identifiers and a local mirror index so moved and deleted source events update target calendars reliably.
|
||||||
|
- Detect title and notes changes during reconciliation instead of only updating mirrors when times change.
|
||||||
|
|
||||||
|
Build
|
||||||
|
- Version bump to 1.3.7 (build 15).
|
||||||
Reference in New Issue
Block a user