Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45a7dbe81 | ||
|
|
9e3dd93939 | ||
|
|
622a85f4fd |
@@ -410,7 +410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||
@@ -451,7 +451,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.10.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -8,6 +8,8 @@ struct Block: Hashable {
|
||||
let notes: String? // source notes (for optional copy)
|
||||
let occurrence: Date? // occurrenceDate for recurring instances
|
||||
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
|
||||
var tentative: Bool = false // source "Maybe" RSVP; mirrored with a marker
|
||||
// ponytail: lost on the merge path (mergeGapMin > 0) along with the title; only tracked in per-event mode
|
||||
|
||||
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
|
||||
static func span(start: Date, end: Date) -> Block {
|
||||
@@ -22,7 +24,8 @@ struct Block: Hashable {
|
||||
lhs.srcStableID == rhs.srcStableID &&
|
||||
lhs.label == rhs.label &&
|
||||
lhs.notes == rhs.notes &&
|
||||
lhs.occurrence == rhs.occurrence
|
||||
lhs.occurrence == rhs.occurrence &&
|
||||
lhs.tentative == rhs.tentative
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -32,6 +35,7 @@ struct Block: Hashable {
|
||||
hasher.combine(label)
|
||||
hasher.combine(notes)
|
||||
hasher.combine(occurrence)
|
||||
hasher.combine(tentative)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -568,22 +568,25 @@ struct ContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebarView: some View {
|
||||
List(SidebarSection.allCases, selection: $selectedSection) { section in
|
||||
Label {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
if section == .routes && !routes.isEmpty {
|
||||
Spacer()
|
||||
Text("\(routes.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
List(selection: $selectedSection) {
|
||||
ForEach(SidebarSection.allCases) { section in
|
||||
Label {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
if section == .routes && !routes.isEmpty {
|
||||
Spacer()
|
||||
Text("\(routes.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: section.icon)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
.tag(section as SidebarSection?)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||||
}
|
||||
|
||||
@@ -727,6 +730,12 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSubtitle: String {
|
||||
if let progressText { return progressText }
|
||||
if isRunning { return "Running…" }
|
||||
return hasAccess ? "\(calendars.count) calendars" : "No calendar access"
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItemGroup {
|
||||
@@ -737,15 +746,7 @@ struct ContentView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 150)
|
||||
.disabled(isRunning)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(isRunning ? Color.orange : (appController.lastRunFailed ? Color.red : Color.secondary))
|
||||
.frame(width: 7, height: 7)
|
||||
Text(progressText ?? (isRunning ? "Running…" : (hasAccess ? "\(calendars.count) calendars" : "No access")))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
|
||||
|
||||
if isRunning {
|
||||
Button("Cancel") { cancelMirror() }
|
||||
@@ -754,6 +755,7 @@ struct ContentView: View {
|
||||
startMirrorNow()
|
||||
} label: {
|
||||
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.disabled(!canRunMirrorNow)
|
||||
}
|
||||
@@ -813,6 +815,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle((selectedSection ?? .routes).title)
|
||||
.navigationSubtitle(statusSubtitle)
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.confirmationDialog(
|
||||
|
||||
@@ -145,10 +145,12 @@ final class MirrorEngine {
|
||||
var skippedStatus = 0
|
||||
for ev in srcEvents {
|
||||
if Task.isCancelled { break }
|
||||
let myStatus = ev.attendees?.first(where: { $0.isCurrentUser })?.participantStatus
|
||||
let isTentative = (myStatus == .tentative)
|
||||
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
||||
let attendees = ev.attendees ?? []
|
||||
if let me = attendees.first(where: { $0.isCurrentUser }) {
|
||||
if me.participantStatus != .accepted {
|
||||
if let me = ev.attendees?.first(where: { $0.isCurrentUser }) {
|
||||
// "Maybe" replies still get mirrored, just marked (see displayTitle below)
|
||||
if me.participantStatus != .accepted && me.participantStatus != .tentative {
|
||||
skippedStatus += 1
|
||||
continue
|
||||
}
|
||||
@@ -178,7 +180,7 @@ final class MirrorEngine {
|
||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||
let srcID = stableSourceIdentifier(for: ev)
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
|
||||
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev), tentative: isTentative))
|
||||
}
|
||||
if skippedMirrors > 0 {
|
||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||
@@ -379,7 +381,8 @@ final class MirrorEngine {
|
||||
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
||||
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
||||
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
|
||||
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle
|
||||
let maybeMark = blk.tentative ? "Maybe: " : ""
|
||||
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + maybeMark + effectiveTitle
|
||||
let notes = desiredNotes(for: blk)
|
||||
let desiredURL = buildMirrorURL(
|
||||
targetCalID: tgt.calendarIdentifier,
|
||||
@@ -409,6 +412,7 @@ final class MirrorEngine {
|
||||
return
|
||||
}
|
||||
existing.title = displayTitle
|
||||
existing.availability = blk.tentative ? .tentative : .busy
|
||||
existing.startDate = blk.start
|
||||
existing.endDate = blk.end
|
||||
existing.isAllDay = false
|
||||
@@ -467,7 +471,7 @@ final class MirrorEngine {
|
||||
newEv.notes = notes
|
||||
newEv.url = desiredURL
|
||||
newEv.alarms = desiredAlarms(for: blk)
|
||||
newEv.availability = .busy
|
||||
newEv.availability = blk.tentative ? .tentative : .busy
|
||||
do {
|
||||
try store.save(newEv, span: .thisEvent, commit: true)
|
||||
created += 1
|
||||
|
||||
@@ -165,6 +165,14 @@ final class BlockMathTests: XCTestCase {
|
||||
XCTAssertEqual(result.count, 2)
|
||||
}
|
||||
|
||||
func testTentativeAffectsEqualityAndHash() {
|
||||
let base = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: "x", notes: nil, occurrence: d, alarmOffsets: nil)
|
||||
var maybe = base
|
||||
maybe.tentative = true
|
||||
XCTAssertNotEqual(base, maybe)
|
||||
XCTAssertNotEqual(base.hashValue, maybe.hashValue)
|
||||
}
|
||||
|
||||
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||
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), alarmOffsets: nil)
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to BusyMirror will be documented in this file.
|
||||
|
||||
## [1.10.0] - 2026-08-31
|
||||
|
||||
### Added
|
||||
- **"Maybe" events are now mirrored.** Previously, when "Mirror accepted events only" was on, events you'd RSVP'd tentative to were dropped entirely. They're now mirrored with a `Maybe: ` marker in the title (after the normal prefix, e.g. `🪞 Maybe: Standup`) and written with tentative availability so Calendar shows them hatched. Applies regardless of the accepted-only setting — any tentative RSVP gets the marker. A flip between accepted and tentative re-syncs the title. Not carried on the merge path (`mergeGapMin > 0`), which already discards per-event titles. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
|
||||
|
||||
## [1.9.2] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
- **"Sync Now" toolbar button showed only its icon**, no text — confirmed via screenshot: the Dry Run/Write toggle and toolbar layout were actually fine (1.9.1's fix worked, sidebar clicks too), but the circular-arrows icon next to it was unlabeled, easy to miss as "Sync Now" entirely. Forced `.labelStyle(.titleAndIcon)` so the text always shows regardless of toolbar width. ([ContentView.swift](BusyMirror/ContentView.swift))
|
||||
|
||||
## [1.9.1] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
- **Sidebar not clickable.** `sidebarView` used `List(SidebarSection.allCases, selection:)` (the data-driven initializer), which ties selection to `Identifiable`'s `id` — a `String` for `SidebarSection` — while the binding was typed `SidebarSection?`. Rebuilt with the standard `List(selection:) { ForEach(...) { ... .tag(section) } }` pattern, which binds selection directly to the (`Hashable`) section value with no ambiguity. Also added `.listStyle(.sidebar)`, the standard modifier for a `NavigationSplitView` sidebar (vibrancy, selection color) that was missing.
|
||||
- **"Write to calendars" hard to find.** It was still there as the Dry Run/Write segmented control in the toolbar, but likely lost to toolbar overflow alongside a status readout, Sync Now, and the overflow menu all crammed into one `ToolbarItemGroup`. Moved the calendar-count/status text out of the toolbar entirely into `.navigationSubtitle` (always visible, native, never subject to overflow), leaving the toolbar to just Dry Run/Write, Sync Now/Cancel, and the overflow menu.
|
||||
|
||||
Not independently confirmed by clicking through the UI (no GUI automation available in this environment) — the `List(selection:)` fix follows SwiftUI's standard, unambiguous sidebar-selection pattern.
|
||||
|
||||
## [1.9.0] - 2026-08-27
|
||||
|
||||
### Added
|
||||
|
||||
Reference in New Issue
Block a user