Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/DMonteCore/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ public enum DefaultsKey {
public static let windowManagerShortcuts = "tool.windowManager.shortcuts"
public static let audioRouterPresets = "tool.audioRouter.presets"
public static let qrTemplate = "tool.qr.template"

/// Calendars the user has hidden. Excluded rather than included, so a calendar added later shows
/// up by default instead of silently vanishing.
public static let calendarExcludedCalendarIDs = "tool.calendar.excludedCalendarIDs"
public static let toolboxRecentToolIDs = "toolbox.recentToolIDs"

static let obsoleteKeys = [
Expand Down Expand Up @@ -119,6 +123,7 @@ public enum AppDefaults {
DefaultsKey.volumeMixerOutputRoutes: [:],
DefaultsKey.windowManagerShortcuts: [:],
DefaultsKey.toolboxRecentToolIDs: [],
DefaultsKey.calendarExcludedCalendarIDs: [],
DefaultsKey.focusTimerFocusMinutes: 25,
DefaultsKey.focusTimerShortBreakMinutes: 5,
DefaultsKey.focusTimerLongBreakMinutes: 15,
Expand Down
146 changes: 146 additions & 0 deletions Sources/DMonteCore/CalendarKit.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreGraphics
import Foundation

/// Pure, UI-free month-grid math used by the menu-bar Calendar tool. Everything here is Foundation
Expand Down Expand Up @@ -158,4 +159,149 @@ public enum CalendarKit {
formatter.dateFormat = "LLLL yyyy"
return formatter.string(from: date)
}

// MARK: - Per-calendar filtering

/// Whether events owned by `calendarID` should be shown.
///
/// The filter is stored as the set of *excluded* calendars, so anything unknown — a calendar the
/// user has never touched, one added after the last time the filter was edited, or an event whose
/// owning calendar EventKit could not resolve — is visible. Storing the inclusions instead would
/// make a newly-added calendar silently invisible.
public static func isCalendarVisible(_ calendarID: String?, excludedCalendarIDs: Set<String>) -> Bool {
guard let calendarID else { return true }
return !excludedCalendarIDs.contains(calendarID)
}

/// Filters `events` down to those whose owning calendar is not excluded, preserving order.
///
/// Generic over the element and its calendar lookup so the rule lives here — free of EventKit —
/// and can be applied identically to `EKEvent`s (for the month-grid dots) and to the view's own
/// row model (for the day/upcoming lists).
public static func visibleEvents<Event>(
_ events: [Event],
excludedCalendarIDs: Set<String>,
calendarID: (Event) -> String?
) -> [Event] {
guard !excludedCalendarIDs.isEmpty else { return events }
return events.filter { isCalendarVisible(calendarID($0), excludedCalendarIDs: excludedCalendarIDs) }
}

/// The exclusion set that results from showing (`visible == true`) or hiding a single calendar.
/// Pure so the toggle can be asserted without touching user defaults.
public static func excludedCalendarIDs(
_ excluded: Set<String>,
setting calendarID: String,
visible: Bool
) -> Set<String> {
var result = excluded
if visible {
result.remove(calendarID)
} else {
result.insert(calendarID)
}
return result
}

/// Whether any calendar that currently exists is hidden — i.e. whether the lists are actually
/// showing less than everything.
///
/// Deliberately not `!excludedCalendarIDs.isEmpty`: the stored set is never pruned (see
/// `persistedExcludedCalendarIDs`), so an identifier left behind by a deleted calendar or a
/// removed account would otherwise keep the "filtering" indicator lit forever while nothing is
/// being filtered. This answers the question the UI actually asks without dropping the
/// identifiers that let a returning calendar stay hidden.
public static func hasHiddenCalendars(
among calendarIDs: some Sequence<String>,
excludedCalendarIDs: Set<String>
) -> Bool {
guard !excludedCalendarIDs.isEmpty else { return false }
return calendarIDs.contains { excludedCalendarIDs.contains($0) }
}

// MARK: - Filter persistence

/// The persisted set of hidden calendar identifiers. Never migrates or prunes identifiers that no
/// longer resolve: an account can be temporarily offline, and dropping its identifier would make a
/// deliberately hidden calendar reappear the next time it comes back.
public static func persistedExcludedCalendarIDs(defaults: UserDefaults) -> Set<String> {
Set(defaults.stringArray(forKey: DefaultsKey.calendarExcludedCalendarIDs) ?? [])
}

/// Persists the hidden-calendar set. Sorted on the way out so the stored plist is stable and
/// diffable rather than reordering on every write.
public static func persistExcludedCalendarIDs(_ identifiers: Set<String>, defaults: UserDefaults) {
defaults.set(identifiers.sorted(), forKey: DefaultsKey.calendarExcludedCalendarIDs)
}

// MARK: - Click-through to Calendar.app

/// The `ical://` URL that reveals an event in Calendar.app, or `nil` when the event has no
/// identifier to address.
///
/// - Parameters:
/// - eventIdentifier: `EKEvent.eventIdentifier` — the *series* identifier, which every
/// occurrence of a recurring event shares.
/// - occurrenceDate: `EKEvent.occurrenceDate`, the occurrence's originally scheduled start.
/// Because the identifier alone cannot distinguish two occurrences, it is prefixed as a UTC
/// timestamp path component; without it Calendar.app opens the series' first occurrence.
/// A detached (moved) occurrence keeps its original date, which is exactly what this URL
/// needs, so the stamp must not be taken from `startDate`.
/// Whether two calendar colours are the same to the eye.
///
/// Compared componentwise in a shared colour space rather than with `==`: `CGColor` equality
/// also considers the colour space object, so the same visual red arriving from two calendars
/// can compare unequal and produce two identical-looking dots on one day.
public static func colorsMatch(_ lhs: CGColor, _ rhs: CGColor, tolerance: CGFloat = 0.01) -> Bool {
guard let a = lhs.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil),
let b = rhs.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil),
let ca = a.components, let cb = b.components,
ca.count == cb.count else {
return lhs == rhs
}
return zip(ca, cb).allSatisfy { abs($0 - $1) <= tolerance }
}

public static func eventShowURL(eventIdentifier: String?, occurrenceDate: Date?) -> URL? {
guard let eventIdentifier, !eventIdentifier.isEmpty,
let escaped = eventIdentifier.addingPercentEncoding(withAllowedCharacters: pathComponentAllowed) else {
return nil
}
// `options=more` opens the full inspector rather than the compact popover.
//
// The identifier is the only path component. An earlier version prefixed the occurrence
// date — `ical://ekevent/<stamp>/<id>` — reasoning that recurring occurrences share one
// identifier and the date would disambiguate them. Calendar.app does not accept that
// shape: reported from a real install, it launched and then sat on today's date instead
// of the event, which is what an unresolvable URL looks like from the outside.
//
// `occurrenceDate` is kept in the signature because callers pass it and because pinning
// the right occurrence is still the open question — see the tests, which lock in the
// shape rather than the behaviour, since only a live Calendar.app can confirm the latter.
_ = occurrenceDate
return URL(string: "ical://ekevent/\(escaped)?method=show&options=more")
}

/// `.urlPathAllowed` minus `/`. The identifier is one path component, but EventKit identifiers are
/// opaque, so a slash inside one would otherwise pass through unescaped and split the URL into
/// extra components — addressing something else entirely instead of failing loudly.
private static let pathComponentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
Comment on lines +285 to +288

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Pushing back on this one — CharacterSet.urlPathAllowed does not contain % on Darwin. Enumerating it gives !$&'()*+,-./0-9:;=@A-Z_a-z~, no percent. So % is already escaped to %25 today: id%2Fother encodes to id%252Fother and decodes back to the literal identifier, which is the data-not-escape behavior you're asking for. Subtracting % would be a no-op, so I left the set alone and locked the behavior in with a test instead (84caae0).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — % is absent from .urlPathAllowed on Darwin, so subtracting it would be a no-op. The regression guard in testEventShowURLEscapesAPercentInTheIdentifier (84caae0) locks that invariant in: if the set ever changes to include %, the test will catch it before a %-containing identifier can be misinterpreted as a pre-escaped sequence.


/// `yyyyMMddTHHmmssZ` in UTC, the shape Calendar.app's URL scheme expects for the occurrence path
/// component. Built from `DateComponents` rather than a `DateFormatter` so it needs no shared
/// mutable formatter state and no locale.
static func utcStamp(for date: Date) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
return String(
format: "%04d%02d%02dT%02d%02d%02dZ",
parts.year ?? 0,
parts.month ?? 0,
parts.day ?? 0,
parts.hour ?? 0,
parts.minute ?? 0,
parts.second ?? 0
)
}
}
Loading
Loading