diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 7fc0bb0..f4dc326 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index a35217a..a907dd0 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -1006,6 +1006,7 @@ struct ContentView: View { var created = 0 var skipped = 0 var updated = 0 + var warnedPrivateUnsupported = false // Cross-route loop guard: unique key generator for (source, occurrence/time, target) func guardKey(for blk: Block, targetID: String) -> String { @@ -1052,7 +1053,11 @@ struct ContentView: View { 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)") - _ = 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 { try store.save(existingByTime, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") @@ -1095,7 +1100,11 @@ struct ContentView: View { existing.notes = nil } 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 { try store.save(existing, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") @@ -1136,7 +1145,11 @@ struct ContentView: View { 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 - _ = 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 { try store.save(newEv, span: .thisEvent, commit: true) created += 1 @@ -1177,34 +1190,55 @@ struct ContentView: View { } } 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 { - let validKeys: Set = Set(baseBlocks.compactMap { blk in - if (mergeGapMin == 0), let sid = blk.srcEventID { - let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 - return "\(sid)|\(occTS)" - } - return nil + let trackByID = (mergeGapMin == 0) + // Build valid occurrence keys from current source blocks (when trackByID) + let validOccKeys: Set = 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)" }) - if !validKeys.isEmpty { - var removed = 0 - for (_, ev) in placeholdersByOccurrenceID { - if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue } - let parsed = parseMirrorURL(ev.url) - if let sid = parsed.srcEventID, let occ = parsed.occ { - let k = "\(sid)|\(occ.timeIntervalSince1970)" - if !validKeys.contains(k) { - 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)") } - } - } + // Also build valid time keys for legacy/fallback cleanup + let validTimeKeys: Set = 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] = [:] + for tv in placeholdersByTime.values { + guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } + if let eid = tv.eventIdentifier { byID[eid] = tv } + } + + 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)) 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 return false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8a7ec..b7fdd62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ 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 - 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. diff --git a/ReleaseNotes-1.3.1.md b/ReleaseNotes-1.3.1.md new file mode 100644 index 0000000..6ebae5b --- /dev/null +++ b/ReleaseNotes-1.3.1.md @@ -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. +