Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45a7dbe81 | ||
|
|
9e3dd93939 | ||
|
|
622a85f4fd |
@@ -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 = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
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.9.0;
|
MARKETING_VERSION = 1.10.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 = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
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.9.0;
|
MARKETING_VERSION = 1.10.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;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ struct Block: Hashable {
|
|||||||
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
|
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).
|
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
|
||||||
static func span(start: Date, end: Date) -> Block {
|
static func span(start: Date, end: Date) -> Block {
|
||||||
@@ -22,7 +24,8 @@ struct Block: Hashable {
|
|||||||
lhs.srcStableID == rhs.srcStableID &&
|
lhs.srcStableID == rhs.srcStableID &&
|
||||||
lhs.label == rhs.label &&
|
lhs.label == rhs.label &&
|
||||||
lhs.notes == rhs.notes &&
|
lhs.notes == rhs.notes &&
|
||||||
lhs.occurrence == rhs.occurrence
|
lhs.occurrence == rhs.occurrence &&
|
||||||
|
lhs.tentative == rhs.tentative
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
@@ -32,6 +35,7 @@ struct Block: Hashable {
|
|||||||
hasher.combine(label)
|
hasher.combine(label)
|
||||||
hasher.combine(notes)
|
hasher.combine(notes)
|
||||||
hasher.combine(occurrence)
|
hasher.combine(occurrence)
|
||||||
|
hasher.combine(tentative)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -568,22 +568,25 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var sidebarView: some View {
|
private var sidebarView: some View {
|
||||||
List(SidebarSection.allCases, selection: $selectedSection) { section in
|
List(selection: $selectedSection) {
|
||||||
Label {
|
ForEach(SidebarSection.allCases) { section in
|
||||||
HStack {
|
Label {
|
||||||
Text(section.title)
|
HStack {
|
||||||
if section == .routes && !routes.isEmpty {
|
Text(section.title)
|
||||||
Spacer()
|
if section == .routes && !routes.isEmpty {
|
||||||
Text("\(routes.count)")
|
Spacer()
|
||||||
.font(.caption)
|
Text("\(routes.count)")
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
}
|
}
|
||||||
} icon: {
|
.tag(section)
|
||||||
Image(systemName: section.icon)
|
|
||||||
}
|
}
|
||||||
.tag(section as SidebarSection?)
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
.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
|
@ToolbarContentBuilder
|
||||||
private var toolbarContent: some ToolbarContent {
|
private var toolbarContent: some ToolbarContent {
|
||||||
ToolbarItemGroup {
|
ToolbarItemGroup {
|
||||||
@@ -737,15 +746,7 @@ struct ContentView: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 150)
|
.frame(width: 150)
|
||||||
.disabled(isRunning)
|
.disabled(isRunning)
|
||||||
|
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isRunning {
|
if isRunning {
|
||||||
Button("Cancel") { cancelMirror() }
|
Button("Cancel") { cancelMirror() }
|
||||||
@@ -754,6 +755,7 @@ struct ContentView: View {
|
|||||||
startMirrorNow()
|
startMirrorNow()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
}
|
}
|
||||||
.disabled(!canRunMirrorNow)
|
.disabled(!canRunMirrorNow)
|
||||||
}
|
}
|
||||||
@@ -813,6 +815,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle((selectedSection ?? .routes).title)
|
.navigationTitle((selectedSection ?? .routes).title)
|
||||||
|
.navigationSubtitle(statusSubtitle)
|
||||||
.toolbar { toolbarContent }
|
.toolbar { toolbarContent }
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
|||||||
@@ -145,10 +145,12 @@ final class MirrorEngine {
|
|||||||
var skippedStatus = 0
|
var skippedStatus = 0
|
||||||
for ev in srcEvents {
|
for ev in srcEvents {
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
|
let myStatus = ev.attendees?.first(where: { $0.isCurrentUser })?.participantStatus
|
||||||
|
let isTentative = (myStatus == .tentative)
|
||||||
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
||||||
let attendees = ev.attendees ?? []
|
if let me = ev.attendees?.first(where: { $0.isCurrentUser }) {
|
||||||
if let me = attendees.first(where: { $0.isCurrentUser }) {
|
// "Maybe" replies still get mirrored, just marked (see displayTitle below)
|
||||||
if me.participantStatus != .accepted {
|
if me.participantStatus != .accepted && me.participantStatus != .tentative {
|
||||||
skippedStatus += 1
|
skippedStatus += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -178,7 +180,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, 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 {
|
if skippedMirrors > 0 {
|
||||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||||
@@ -379,7 +381,8 @@ final class MirrorEngine {
|
|||||||
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
||||||
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
||||||
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(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 notes = desiredNotes(for: blk)
|
||||||
let desiredURL = buildMirrorURL(
|
let desiredURL = buildMirrorURL(
|
||||||
targetCalID: tgt.calendarIdentifier,
|
targetCalID: tgt.calendarIdentifier,
|
||||||
@@ -409,6 +412,7 @@ final class MirrorEngine {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing.title = displayTitle
|
existing.title = displayTitle
|
||||||
|
existing.availability = blk.tentative ? .tentative : .busy
|
||||||
existing.startDate = blk.start
|
existing.startDate = blk.start
|
||||||
existing.endDate = blk.end
|
existing.endDate = blk.end
|
||||||
existing.isAllDay = false
|
existing.isAllDay = false
|
||||||
@@ -467,7 +471,7 @@ final class MirrorEngine {
|
|||||||
newEv.notes = notes
|
newEv.notes = notes
|
||||||
newEv.url = desiredURL
|
newEv.url = desiredURL
|
||||||
newEv.alarms = desiredAlarms(for: blk)
|
newEv.alarms = desiredAlarms(for: blk)
|
||||||
newEv.availability = .busy
|
newEv.availability = blk.tentative ? .tentative : .busy
|
||||||
do {
|
do {
|
||||||
try store.save(newEv, span: .thisEvent, commit: true)
|
try store.save(newEv, span: .thisEvent, commit: true)
|
||||||
created += 1
|
created += 1
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ final class BlockMathTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.count, 2)
|
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() {
|
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||||
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
|
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)
|
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.
|
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
|
## [1.9.0] - 2026-08-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Reference in New Issue
Block a user