1.3.1: fix auto-delete of missing-source mirrors; bump version; add release notes

This commit is contained in:
2025-10-13 11:43:01 +02:00
parent eb643ac74d
commit 3ecf29f499
4 changed files with 87 additions and 28 deletions

View File

@@ -421,7 +421,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -451,7 +451,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;

View File

@@ -1006,6 +1006,7 @@ struct ContentView: View {
var created = 0 var created = 0
var skipped = 0 var skipped = 0
var updated = 0 var updated = 0
var warnedPrivateUnsupported = false
// Cross-route loop guard: unique key generator for (source, occurrence/time, target) // 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 {
@@ -1052,7 +1053,11 @@ struct ContentView: View {
let sid0 = blk.srcEventID ?? "" let sid0 = blk.srcEventID ?? ""
let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 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)") existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
_ = setEventPrivateIfSupported(existingByTime, markPrivate) let okTime = setEventPrivateIfSupported(existingByTime, markPrivate)
if markPrivate && !okTime && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
try store.save(existingByTime, span: .thisEvent, commit: true) try store.save(existingByTime, span: .thisEvent, commit: true)
log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)")
@@ -1095,7 +1100,11 @@ struct ContentView: View {
existing.notes = nil existing.notes = nil
} }
existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
_ = setEventPrivateIfSupported(existing, markPrivate) let okOcc = setEventPrivateIfSupported(existing, markPrivate)
if markPrivate && !okOcc && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
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)] \(blk.start) -> \(blk.end)")
@@ -1136,7 +1145,11 @@ struct ContentView: View {
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 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.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)")
newEv.availability = .busy newEv.availability = .busy
_ = setEventPrivateIfSupported(newEv, markPrivate) let okNew = setEventPrivateIfSupported(newEv, markPrivate)
if markPrivate && !okNew && !warnedPrivateUnsupported {
log("- INFO: Mark Private not supported for \(tgtName) [source: \(tgt.source.title)]")
warnedPrivateUnsupported = true
}
do { do {
try store.save(newEv, span: .thisEvent, commit: true) try store.save(newEv, span: .thisEvent, commit: true)
created += 1 created += 1
@@ -1177,34 +1190,55 @@ 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 // 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 validKeys: Set<String> = Set(baseBlocks.compactMap { blk in let trackByID = (mergeGapMin == 0)
if (mergeGapMin == 0), let sid = blk.srcEventID { // Build valid occurrence keys from current source blocks (when trackByID)
let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 let validOccKeys: Set<String> = Set(baseBlocks.compactMap { blk in
return "\(sid)|\(occTS)" guard trackByID, let sid = blk.srcEventID else { return nil }
} let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970
return nil return "\(sid)|\(occTS)"
}) })
if !validKeys.isEmpty { // Also build valid time keys for legacy/fallback cleanup
var removed = 0 let validTimeKeys: Set<String> = Set(baseBlocks.map { blk in
for (_, ev) in placeholdersByOccurrenceID { "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)"
if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue } })
let parsed = parseMirrorURL(ev.url)
if let sid = parsed.srcEventID, let occ = parsed.occ { // Consider all known placeholders on target (URL or title-identified)
let k = "\(sid)|\(occ.timeIntervalSince1970)" // Deduplicate by eventIdentifier to avoid double-processing updated items
if !validKeys.contains(k) { var byID: [String: EKEvent] = [:]
if !writeEnabled { for tv in placeholdersByTime.values {
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue }
} else { if let eid = tv.eventIdentifier { byID[eid] = tv }
do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 } }
catch { log("Delete failed: \(error.localizedDescription)") }
} var removed = 0
} for ev in byID.values {
let parsed = parseMirrorURL(ev.url)
var shouldDelete = false
if let sid = parsed.srcEventID, let occ = parsed.occ {
let k = "\(sid)|\(occ.timeIntervalSince1970)"
// If key not present among current source instances, delete
if !validOccKeys.contains(k) { shouldDelete = true }
} else if trackByID {
// Legacy fallback: no URL -> compare exact time window membership
if let s = ev.startDate, let e = ev.endDate {
let tk = "\(s.timeIntervalSince1970)|\(e.timeIntervalSince1970)"
if !validTimeKeys.contains(tk) { shouldDelete = true }
}
}
if shouldDelete {
if !writeEnabled {
log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)")
} else {
do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 }
catch { log("Delete failed: \(error.localizedDescription)") }
} }
} }
if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") }
} }
if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") }
} }
} }
} }
@@ -1390,6 +1424,22 @@ private struct SettingsPayload: Codable {
_ = ev.perform(sel2, with: NSNumber(value: 1)) _ = ev.perform(sel2, with: NSNumber(value: 1))
return true return true
} }
// Try common KVC keys seen in some providers (best-effort, may be no-ops)
let kvPairs: [(String, Any)] = [
("private", true),
("isPrivate", true),
("privacy", 1),
("sensitivity", 1), // Exchange often: 1=personal, 2=private; varies
("classification", 1) // iCalendar CLASS: 1 might map to PRIVATE
]
for (key, val) in kvPairs {
do {
ev.setValue(val, forKey: key)
return true
} catch {
// ignore and try next
}
}
// Not supported; no-op // Not supported; no-op
return false return false
} }

View File

@@ -3,6 +3,9 @@
All notable changes to BusyMirror will be documented in this file. All notable changes to BusyMirror will be documented in this file.
## [1.3.1] - 2025-10-13
- Fix: auto-delete of mirrored placeholders when the source is removed now works even if no source instances remain in the window. Also cleans legacy mirrors without URLs by matching exact times.
## [1.2.4] - 2025-10-10 ## [1.2.4] - 2025-10-10
- Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present. - Fix: enable “Mirror Now” when Routes are defined even if no Source/Targets are checked in the main window. Button now enables if either routes exist or a manual selection is present.

6
ReleaseNotes-1.3.1.md Normal file
View File

@@ -0,0 +1,6 @@
BusyMirror 1.3.1 — Bugfix Release
- Fix: Auto-delete mirrored placeholders when the source event is removed.
- Triggers even if no source instances remain in the selected window.
- Also cleans legacy mirrors without mirror URLs by matching exact times.