Compare commits

...
25 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 aa795fde76 Stop one recurring event from taking its whole calendar down
An RRULE was parsed by splitting on ";" and "=", which leaves every value a
str. icalendar accepts that at add() time and only rejects it inside
to_ical(), where vRecur.types maps UNTIL to vDDDTypes and that demands a date.
So a single "repeats until <date>" event — exactly what the web editor writes —
made build_ics raise ValueError, and with it every REPORT, every event GET,
the whole-collection GET and the .ics export for that calendar.

PROPFIND never builds ICS, so discovery kept working. That is the asymmetry
behind the Home Assistant symptom: the calendar is found under its correct
name, then every update fails and the entity sits permanently unavailable. It
behaved identically through the token URL and the login URL because both reach
the same collection dispatch.

icalendar parses its own format correctly, so the rule now goes through
vRecur.from_ical. That parser can raise where the hand-rolled one silently
accepted anything, so an unreadable stored rule now drops the recurrence and
logs it instead of failing the calendar — the same tolerance the DTSTART and
DTEND parsing already has.

caldav_client carried a second copy of the same parser with the same defect,
used when pushing events to external CalDAV servers. Deleted; both directions
now share one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 16:59:41 +01:00
ScarriffleandClaude Opus 5 28c24bc3cd Stop dropping the CalDAV login URL after publishing
Publishing a calendar copied caldav_published and caldav_url out of the
response and ignored caldav_login_url, which the same response carries. The
"CalDAV-URL (with login)" field therefore stayed blank until the next full page
load — and that is the URL account-based clients need, so anyone following the
hint underneath it had nothing to copy. The token rotation handler dropped it
the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 16:00:15 +01:00
ScarriffleandClaude Opus 5 a153eea8f9 Make exporting a calendar work, and CalDAV publishing findable
Exporting any calendar whose name is not pure ASCII failed. str.isalnum() is
true for "ö", so the "safe" filename kept the umlaut and went into
Content-Disposition raw — an HTTP header must be ASCII, and a reverse proxy
drops the response. The client then had no JSON to read and showed its
"unknown error" fallback, which said nothing at all. attachments_router had
already learned this and documents it in a comment; the export endpoint never
got the same treatment. Now RFC 6266, exactly as that router does it.

downloadRequest discarded a non-JSON error body entirely. That body is the only
clue when the failure happens outside the app, so it is used now, with the
status appended when there is nothing to read.

The CalDAV publish button rendered as "..." — four buttons never fitted the
240px actions column, and the cell clips instead of overflowing because the
columns are drag-resizable. Nobody could tell there was a button there, which
is why publishing a calendar to Home Assistant looked impossible: the URL, the
copy button and both hints only appear once it is switched on. The label is now
just "CalDAV" with the full action as its tooltip, which also helps anyone who
already dragged that column narrower, since saved widths beat the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 15:55:48 +01:00
ScarriffleandClaude Opus 5 93297319a3 Give share recipients a bell in the web UI
Calendars reached through a share or a group showed a dash instead of a
reminder toggle, in both the sidebar and the settings table, because the
calendar-wide PUT refused a recipient. They now use the dedicated reminders
endpoint, which stores the recipient's own choice, so the toggle is theirs to
make. The comment claiming member-reached group calendars "can't (no PUT)" was
true and is no longer.

The settings table also had its tooltips and the Notifications column header
wired in German while the sidebar next to it went through t(). Moved to a
translation key, added for all ten languages. The remaining headers in that
table are still hardcoded — untouched here, it is a separate job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:31:50 +02:00
ScarriffleandClaude Opus 5 2bdf134d9c Bring a muted calendar down to Android, and let it build again
Reminders only ever travelled app -> server here: reconcileCalendarVisibility
read sidebar_hidden and nothing else, and reminderDisabledCalendarKeys came
purely from SharedPreferences, so a calendar muted on the web or on iOS stayed
loud on Android. It now reconciles reminders_enabled for all five sources,
which also means fetching local calendars and iCal subscriptions — a failed
fetch yields an empty list and reconciles nothing, so it can never clear a mute
by accident.

Local calendars go through the new reminders endpoint, so a share recipient's
mute is stored per user instead of being refused.

EventEditorSheet could not compile: a KDoc comment spelled out the catch-all
MIME wildcard, and the slash inside it closed the comment two lines early, so
the remainder parsed as code. Rewritten as line comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:30:39 +02:00
ScarriffleandClaude Opus 5 96cbbb0dc5 Make the per-calendar reminder toggle visible and keep what it sets
Three faults made the existing toggle unreliable on iOS.

Opening the filter or the drawer rebuilt the muted set from the five
server-backed sources and replaced the whole thing, so every device-calendar
mute was silently dropped — along with the mute of any calendar the server did
not happen to return. Only the server-backed part is replaced now.

Device-calendar events carry no reminders of their own, so the global default
applied and Calendarr notified on top of the system Calendar app. They stay
silent unless the event brings its own reminder; the per-calendar toggle can
still switch them on, which it could not before because of the fault above.

The toggle itself lived only behind a long press, with nothing to suggest it
existed until after it had been used — the bell.slash icon appeared only once
muted. It is a visible button in the row now, the way Android already does it,
and the duplicate context-menu entry is gone.

Local calendars use the new reminders endpoint, so a recipient's mute reaches
the server instead of dying in a 404 that `try?` swallowed.

Also adds the EventKit import that c811486 needed in five files: that commit
left beta unable to build for either iOS or Mac Catalyst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:26:49 +02:00
ScarriffleandClaude Opus 5 25f015a15b Let each person decide whether a shared calendar reminds them
reminders_enabled belongs to the calendar, so it belongs to its owner: whoever
shares a calendar decides whether it notifies everyone it reaches, and a
recipient's PUT is refused with 404 by the owner-only filter. For a group
calendar that is worse than it sounds — the creator owns it, so silencing it
for themselves would silence every member.

CalendarReminderPref follows CalendarColorPref exactly, which already solves
this for colours. Only local calendars are shareable (CalendarShare,
GroupCalendar and group_visible_calendar_id all point at local_calendars.id),
so no source discrimination is needed and the other four routers are untouched.
No migration: create_all makes new tables, which is why calendar_color_prefs
has no migration entry either.

A recipient without an override gets reminders on, rather than inheriting the
owner's choice. Muting is personal and should not spread. The new endpoint
decides owner-vs-override server-side, so the existing owner-only PUT stays the
guard it already is, and GET keeps returning reminders_enabled — now carrying
the effective value, so no client model has to change.

Birthday reminders were injected without consulting the flag at all, so a muted
birthday calendar still handed the schedulers something to fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:20:14 +02:00
ScarriffleandClaude Opus 5 c811486ea7 feat(ios): Kalender des Geraets anzeigen und bespielen
Die Kalenderveroeffentlichung in Outlook ist oft von der IT gesperrt, und
iCloud laesst sich nicht ueber einen ICS-Link einbinden. Das iPhone kennt
beide Konten aber bereits, also liest die App sie per EventKit direkt.

Die Termine bleiben auf dem Geraet und gehen nie an den Server. Im Web und
auf anderen Geraeten erscheinen sie deshalb nicht — dafuer braucht es keine
fremden Zugangsdaten und keine Kopie fremder Termindaten in unserer
Datenbank.

Standardmaessig aus: der Schalter steht unter Konten, und erst beim
Einschalten fragt EventKit nach Zugriff. Wer die Funktion nicht will, sieht
keine Berechtigungsabfrage.

Kaum neuer Code noetig, weil CalEvent schon source/calendarId/readOnly hat
und die Sichtbarkeits-, Sortier- und Ausblendlogik an
calendarKey(source:calendarId:) haengt: die Quelle "device" rutscht ohne
Umbau hinein. WritableCalendar bekommt nur ein deviceId, weil EventKit
Zeichenketten statt Zeilen-IDs vergibt.

Lesen, Anlegen, Aendern und Loeschen gehen ueber dieselben Ansichten wie bei
allen anderen Quellen. Nur die Gruppenansicht bleibt aussen vor, die ist
serverseitig zusammengesetzt.

Statt den Store durch zwei Views zu reichen, damit der Kalender nach dem
Einschalten nachlaedt, zaehlt der Dienst eine Generation hoch; ein
abweichender Stand gilt im Cache als Treffer-Fehlschlag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 14:57:15 +02:00
ScarriffleandClaude Opus 5 2db4b42fa1 Stop the calendar blinking empty after saving an event
Routing the post-save reload through the manual sync button made a new Home
Assistant event appear, but at the cost of invalidateCache(), which drops the
cached events as well as the range bounds — so every event vanished and
reappeared for one new one. That reads as unfinished, and it is.

Only the range bounds have to go: nil bounds are what force the next fetch to
reach the server. The events can stay on screen until the fresh ones replace
them. Saving now does exactly that and nothing more; the menu's manual sync
keeps clearing everything, which is what a full refresh is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 22:01:16 +02:00
ScarriffleandClaude Opus 5 75ae038529 Reload after saving the way the manual button already does
Saving ran loadEvents(force: true), which skips the isCached check but leaves
the existing cache in place. The manual reload button clears the cache first,
and that is the one that reliably shows a newly created Home Assistant event.
Rather than keep reasoning about why bypassing the cache is not enough, saving
now takes the path that demonstrably works — the only difference between the
two was invalidateCache().

reloadVisible had no callers left and is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:46:48 +02:00
ScarriffleandClaude Opus 5 5977ad0928 Wait for HA to list a new event before reporting it created
Home Assistant confirms calendar/event/create before its entity's event list
reflects it. Both clients already force a refetch after saving and the server
caches nothing, so the refetch simply lost the race: the event was created but
invisible until the next manual reload. Users read that as a failure, retry,
and end up with a duplicate.

Fixed once here rather than having web, iOS and Android each guess a delay.
Bounded at two seconds and silent on failure — the event exists either way, so
a slow Home Assistant must not turn a successful create into an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:08:06 +02:00
ScarriffleandClaude Opus 5 10686cf383 Carry the Android checkout's uncommitted work into the repo
Four changes lived only in the standalone Android checkout, which is about to
be removed: the foojay toolchain resolver in settings.gradle.kts, an F-Droid
recipe pinned to a commit rather than the v1.1.0 tag, the Gradle daemon JVM
criteria, and the Play Store launcher icon. None of them existed here.

Note that the F-Droid recipe still builds from Calendarr-Android.git, so that
remote has to keep working even though its local checkout is going away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 06:01:19 +02:00
ScarriffleandClaude Opus 5 4816a33980 Describe the monorepo the way it actually is now
The working instructions still described four separate repositories and a
server directory that did not exist on the development machine, which is how
work ended up in a fork whose remote had since been archived. Replaced with the
current layout, the build commands that are actually verifiable here, the
CalendarrKit subtree path the Xcode project depends on, and a pointer marking
the old per-app checkouts as stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 05:57:46 +02:00
ScarriffleandClaude Opus 5 9f864617c2 Let a hidden iCal calendar become visible again
An iCal subscription could be banished but never un-banished. The server has
always sent and accepted sidebar_hidden for subscriptions, but iOS left the
field off ICalSubscription entirely and omitted "ical" from
serverManagedSources, from setCalendarSidebarHidden and from both places that
reconcile the server's flag. So the key went into banishedCalendarKeys and
nothing ever took it out: refreshFromCache filters on that set as well as on
hiddenCalendarKeys, which is why showing the calendar again left its events
invisible and no amount of syncing helped. Closed on all four sides.

The account models are also no longer brittle about absent fields.
HomeAssistantAccount required auth_method, and CalDAVCalendar and
GoogleCalendar required sidebar_hidden, with no custom decoder — one missing
key threw, the `try?` at the call site turned that into an empty list, and the
reconciliation silently did not run, stranding calendars as banished in exactly
the same way. HACalendar already carried a lenient decoder for this reason; the
other three now do too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 05:54:05 +02:00
ScarriffleandClaude Opus 5 ab0c64a9e1 Bring CalendarrKit into the repo and fix the iOS build
The monorepo's Xcode project referenced ../CalendarrKit, but the package was
never brought across: it is the one repo that never had a remote, so it existed
only on one machine. Added as a subtree at the root, where that existing
relative path resolves, so the project needs no change. History is preserved —
the commits explaining why the snapshot colours sit flat on the wire and why
the package must stay Foundation-only are the kind of thing that cannot be
reconstructed later.

The iOS target also did not compile on beta, independent of any of this. The
attachments section combined a string title with a footer, and SwiftUI has no
initializer for that pair: the title then binds as the first positional
argument of Section(content:footer:), which is what the three misleading
"generic parameter 'Content'" errors were. Rewritten as header:/footer:, the
way the reminders section directly above already does it.

Carries over the pending change from the archived iOS repo: each calendar
source now reports its own failure instead of being swallowed by `try?`, so an
absent Home Assistant calendar can finally be diagnosed from the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 05:38:06 +02:00
Scarriffle dccf8671b2 Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'
git-subtree-dir: CalendarrKit
git-subtree-mainline: 8bc32faa7f
git-subtree-split: 9ea1a8705d
2026-10-19 05:34:15 +02:00
ScarriffleandClaude Opus 5 9ea1a8705d Give complication timelines minute granularity in the first hour
A compact countdown needs an entry per minute to stay honest. The live
Text(style: .timer) it replaces needed none, but renders 1:23:45 — eight
characters, which does not fit an accessoryCorner beside its curved label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:32:51 +02:00
ScarriffleandClaude Opus 5 d70cdca4e2 Compute complication timeline entries from event boundaries
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:54 +02:00
ScarriffleandClaude Opus 5 2e9989eab9 Decide complication pushes from the next appointment's identity
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:54 +02:00
ScarriffleandClaude Opus 5 50446c8de5 Add the WatchConnectivity payload format with schema refusal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:53 +02:00
ScarriffleandClaude Opus 5 66a51cc6fc Move calendar-key filtering into the shared package
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:53 +02:00
ScarriffleandClaude Opus 5 19454379e5 Add the watch coverage policy and snapshot trimming
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:29:20 +02:00
ScarriffleandClaude Opus 5 cbec421367 Declare watchOS support so a watch app can consume the snapshot
Uses the string form rather than .watchOS(.v26): that enum case requires
PackageDescription 6.2, and raising swift-tools-version for one constant
would change defaults across the whole manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:28:24 +02:00
Scarriffle d5a9dad769 Answer "signed out" vs "never ran" in one read; make SnapshotTheme constructible
Signing out deletes the cached events — keeping them would leak the previous
user's calendar to anything reading the container — so an absent cache alone
cannot distinguish "signed out" from "Calendarr has never run on this machine".
Those need different messages, so read() now falls back to the session record
and reports .loggedOut when one exists and says so. Every consumer would
otherwise have to reimplement that two-step, and any that forgot would tell a
signed-out user to go install an app they already have.

SnapshotTheme only had the compiler's internal memberwise initializer, so no
consumer outside the package could actually build one.
2026-08-10 17:41:32 +02:00
ScarriffleandClaude Opus 5 ab2328969e Add CalendarrCore: the shared read contract for the calendar snapshot
Calendarr already publishes an event snapshot into its App Group container for
the widgets to render. That channel is what a second Mac app needs too, so this
turns an internal detail into a documented contract rather than something a
second app reverse-engineers and then drifts from.

Foundation only — no SwiftUI, no UIKit, no AppKit — so one source compiles for
iOS, Mac Catalyst and native macOS. Building against the native macOS SDK is the
point: it fails loudly on a UIKit leak that a Catalyst build would accept.

Three things earn their place here rather than being copied per app:

CalendarrAppGroup resolves the container identifier per platform. macOS requires
the Team ID prefix and iOS forbids it, and a mismatch fails silently — nil
container, every read and write a no-op, widgets blank forever.

CalendarKey normalises the two ways the server reports calendar_id (bare for
CalDAV, "local-3" style for everything else). Normalising them differently is
what made per-calendar filtering quietly do nothing for some sources.

The snapshot schema is versioned, with the coverage window now explicit. That
window is the easiest thing to get wrong downstream: outside it the snapshot
carries no information, which is not the same as no events, and rendering an
empty week for an uncovered date produces a screen that looks right and is
false. covers() exists so consumers can tell the two apart.

Reads return a named result rather than an optional. "Never ran", "signed out",
"too new to read" and "unreadable" need different messages, and a bare nil is
how the App Group misconfiguration used to hide.

Writing is behind @_spi(Writer) so a read-only consumer cannot corrupt the
shared cache. Writes post a Darwin notification, the one cross-process signal
that works between two sandboxed apps without an extra entitlement.

The six colour values stay flat on the wire instead of nesting under `theme`,
which would read better. A v1 reader requires them flat, so nesting would have
broken every older build on the first v2 write; `theme` is a computed view.
Tests pin both directions of compatibility, since the two apps ship
independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:30:23 +02:00
55 changed files with 2486 additions and 106 deletions
+86
View File
@@ -0,0 +1,86 @@
# Calendarr — Arbeitsweise (WICHTIG)
Dieses Repo ist ein **Monorepo**. Alles liegt hier, es gibt keine getrennten App-Repos mehr.
```
backend/ FastAPI + SQLite
frontend/ Web-Frontend
ios/ SwiftUI-App, Widgets, Watch-App, Komplikationen
android/ Kotlin/Compose-App
CalendarrKit/ Swift-Paket (CalendarrCore) — geteilt von iOS-App, Widgets und Watch
docs/ DESIGN.md u.a.
```
Gearbeitet wird auf Branch **`beta`**. Der Nutzer deployt daraus auf den Testserver
und merged später selbst `beta` → `master`.
## Diese Maschine = macOS-Entwicklungsrechner. Was hier NICHT geht:
- **KEINE Server-Tests, kein Server-Start.** Nicht `pytest` ausführen, keine venvs
aufsetzen, den Server nicht lokal starten. Backend-Änderungen werden per
Code-Review abgesichert; getestet wird ausschließlich auf dem Testserver.
- **Apps lassen sich hier nur bauen, nicht ausführen.** UI-, Verhaltens- und
Visual-Tests macht der Nutzer auf Gerät bzw. Simulator. Nicht über das Aussehen
spekulieren — bei Unsicherheit nachfragen.
- **Geräteabhängige Bugs** sind hier nicht reproduzierbar. Die Watch-Komplikationen
und der Liquid-Glass-Titel-Bug sind Beispiele dafür.
## Build-Verifikation (das geht, und nur das)
```bash
# iOS inkl. Widgets und Watch-App
cd ios && xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" \
-destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
# Mac Catalyst — separat prüfen, der Build bricht sonst unbemerkt
cd ios && xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" \
-destination 'platform=macOS,variant=Mac Catalyst' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
# CalendarrKit — gegen die native macOS-SDK, das fängt UIKit-Importe ab
cd CalendarrKit && swift build && swift test
# Android
cd android && ./gradlew assembleDebug
```
Xcode liegt unter `/Applications/Xcode.app`. Watch-Builds brauchen eine Xcode-Version,
deren watchOS-SDK zum Gerät passt.
## CalendarrKit
Liegt als `git subtree` in der Wurzel. Das Xcode-Projekt referenziert es relativ als
`../CalendarrKit` — von `ios/` aus zeigt das genau hierher. **Diesen Pfad nicht ändern
und das Verzeichnis nicht verschieben**, sonst baut iOS nicht mehr.
`CalendarrCore` ist bewusst **Foundation-only** — kein SwiftUI, kein UIKit, kein AppKit.
`swift build` gegen die macOS-SDK ist das, was einen versehentlichen UIKit-Import
auffallen lässt, bevor ein Consumer darüber stolpert. Das Wire-Format hat eigene
Regeln, siehe `CalendarrKit/README.md`.
## Plattform-Parität (WICHTIG)
Feature-Logik gehört möglichst auf den **Server**, nicht pro Client dupliziert.
Beispiel: die Gruppentermin-Dekoration liefert der Server als `display_title` im
`/combined`-Endpoint — Clients rendern nur. Sichtbare Features müssen auf **allen**
Clients umgesetzt werden (Web, iOS, Android), nicht nur einem.
Ausnahme sind plattformeigene Erweiterungen wie Watch-App und Komplikationen; ein
Wear-OS-Gegenstück ist ein eigenes Projekt.
## Konventionen
- **Keine Emojis als Icons** — überall echte Vektor-Icons (`Image(systemName:)` auf
Apple-Plattformen, Icon-Set bzw. inline SVG sonst). Emojis sehen auf jeder
Plattform anders aus, was der Paritäts-Anspruch nicht verträgt.
- Commit-Messages auf **Englisch**, Kommunikation mit dem Nutzer auf **Deutsch**.
- Nach jeder abgeschlossenen Einheit committen und pushen.
- Fehler nicht mit `try?` verschlucken. Eine Quelle, die scheitert, muss sagen warum —
sonst sieht sie aus wie eine Quelle ohne Inhalt. Das hat schon zweimal Bugs
verborgen (`KeychainStore`, `loadWritableCalendars`).
## Altlasten
`~/Projects_Coding/Calendar Stack/` enthält die früheren Einzel-Repos
(`Calendarr iOS`, `Calendarr Android`, `CalendarrKit`, `Calendarr server`).
Sie sind **veraltet** und enthalten nichts, was hier fehlt; das iOS-Remote ist
archiviert. Dort nicht mehr arbeiten.
+5
View File
@@ -0,0 +1,5 @@
.build/
.swiftpm/
DerivedData/
*.xcuserstate
.DS_Store
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CalendarrKit",
platforms: [
.iOS(.v17),
// String form, not `.v26`: that enum case needs PackageDescription 6.2,
// and raising swift-tools-version for one constant would change
// defaults across the whole manifest.
.watchOS("26.0"),
.macOS(.v14),
.macCatalyst(.v17),
],
products: [
// Foundation only — deliberately no SwiftUI, no UIKit, no AppKit, so it
// compiles for iOS, Mac Catalyst and native macOS from one source.
.library(name: "CalendarrCore", targets: ["CalendarrCore"]),
],
targets: [
.target(
name: "CalendarrCore",
// Matches the apps, which build in Swift 5 language mode. Note that
// this package deliberately does NOT set defaultIsolation(MainActor),
// even though the Calendarr iOS app target does: the widget extension
// and the read-only consumer app have no MainActor default, and making
// the package MainActor-by-default would force `await` at every
// SnapshotStore.read() call site.
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "CalendarrCoreTests",
dependencies: ["CalendarrCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
]
)
+105
View File
@@ -0,0 +1,105 @@
# CalendarrKit
Shared code between Calendarr (iOS / Mac Catalyst) and other apps from the same
developer team that want to **read** the user's calendar.
`CalendarrCore` depends on Foundation only — no SwiftUI, no UIKit, no AppKit — so
it compiles for iOS, Mac Catalyst and native macOS from one source. That
constraint is load-bearing: `swift build` against the native macOS SDK is what
catches a UIKit import before it reaches a consumer, because a Mac Catalyst build
would compile it happily.
## What a reading app gets
```swift
import CalendarrCore
let store = SnapshotStore() // uses CalendarrAppGroup.current
switch store.read() {
case .ok(let snap):
snap.events // [SnapshotEvent]
snap.theme // the colours Calendarr is currently rendering with
snap.writtenAt // "as of 14:32"
snap.coverageStart // see "Coverage" below
snap.coverageEnd
case .neverWritten: // Calendarr has never run here → "open Calendarr once"
case .loggedOut: // signed out → "sign in to Calendarr", discard old events
case .incompatible: // Calendarr is newer than this package → "update the app"
case .unreadable(let why): // almost always a wrong App Group; log `why`
}
store.readCalendars() // [SnapshotCalendar] for a filter UI
```
React to changes without polling:
```swift
let token = SnapshotChangeNotifier.observe { /* hop to your actor, read again */ }
// keep `token` alive; releasing it removes the observer
```
Also reload on window activation — the notification is a best-effort signal, and
missing one should leave you slightly stale, never wrong.
## Coverage — the part that bites
The snapshot is not the whole calendar. Outside `coverageStart..<coverageEnd` it
carries **no information**, which is not the same as "no events". Rendering an
empty March because it is outside the window produces a screen that looks correct
and is false. Use `snapshot.covers(date)` and grey out what you cannot speak for.
| | |
|---|---|
| Window | ~7 days back, ~42 days ahead, frozen at write time |
| Cap | 500 events, sorted by start |
| Filters | hidden and "banished" calendars are already excluded — you see the user's filtered view |
| Scope | personal only; group overlays are never published, deliberately |
| Dropped | notes, url, source, creator, privacy, read-only, reminders, recurrence |
What remains is enough to *display* a calendar and deliberately not enough to
edit one or reason about permissions.
## Setup for a consuming app
Same developer team (`PP34X97WS3`) is required — that is what makes the container
and the keychain group shared.
```xml
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>PP34X97WS3.group.com.scarriffleservices.calendarr</string></array>
```
The Team ID prefix is mandatory on macOS and forbidden on iOS. `CalendarrAppGroup.current`
resolves the right one; it must match the entitlement byte for byte, or
`containerURL(…)` returns nil and every read silently yields nothing.
Never sign in from a consuming app. Credentials, 2FA and token storage belong to
Calendarr alone.
## Wire format rules
`widget-cache.json`, `widget-calendars.json` and `session.json` live in the group
container and are read by apps that ship on independent schedules.
1. Filenames do not change within a major version, so an older build keeps reading
after the writer updates.
2. Readers refuse `schemaVersion > maxSupportedSchema` instead of guessing.
3. Writers only ever *add* optional fields. Never rename, remove or retype one.
4. A breaking change means a new major version *and* a new filename, with the old
file still written for one full release cycle.
5. The package version is the contract: `CalendarrKit 1.x` ⇔ snapshot schema 2.
Depend on `.upToNextMajor(from: "1.0.0")`.
Rule 3 is why the six colour values sit flat at the top level instead of nested
under `theme`, which would read better: a v1 reader requires them flat, so nesting
would have broken every older build on the first v2 write. `theme` is a computed
view over them. `SnapshotSchemaTests` pins this down in both directions.
## Verify
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # native macOS — catches UIKit leaks
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
```
@@ -0,0 +1,31 @@
import Foundation
/// The App Group the Calendarr apps share.
///
/// The identifier registered in the Apple Developer portal never changes. What
/// changes is the string the *runtime* expects: macOS — including Mac Catalyst —
/// requires the Team ID prefix, and iOS forbids it.
///
/// Getting this wrong fails silently. `containerURL(forSecurityApplication‑
/// GroupIdentifier:)` returns nil, every read and write no-ops, and widgets show
/// placeholder content forever with no error raised anywhere. `current` must
/// therefore stay byte-identical to `com.apple.security.application-groups` in
/// the entitlements file used for the platform being built.
public enum CalendarrAppGroup {
/// As registered in the Apple Developer portal.
public static let unprefixed = "group.com.scarriffleservices.calendarr"
/// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time.
public static let teamID = "PP34X97WS3"
/// The value to hand to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`.
#if os(macOS) || targetEnvironment(macCatalyst)
public static let current = "\(teamID).\(unprefixed)"
#else
public static let current = unprefixed
#endif
/// Keychain access group shared across apps from this team. Requires
/// `keychain-access-groups` in the entitlements of every app that uses it.
public static let sharedKeychainGroup = "\(teamID).com.scarriffleservices.calendarr.shared"
}
@@ -0,0 +1,30 @@
import Foundation
/// Stable identifier for one calendar, used to tie a snapshot event back to the
/// calendar it came from.
///
/// This exists because the server reports `calendar_id` inconsistently: a raw
/// numeric for CalDAV sources, but `"<source>-<id>"` for local, iCal, Google and
/// Home Assistant (e.g. `"local-3"`). Any consumer that filters by calendar has
/// to normalise the two forms identically, or filtering silently does nothing
/// for the prefixed sources — which is exactly the bug this rule was written to
/// fix. Keeping the rule in one shared place is why it lives here rather than
/// being reimplemented per app.
public enum CalendarKey {
/// Build a key, stripping a redundant `"<source>-"` prefix from the id.
public static func make(source: String, calendarId: String) -> String {
var id = calendarId
let prefix = "\(source)-"
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
return "\(source):\(id)"
}
/// Split a key back into its source and numeric id.
/// Returns nil for malformed keys or non-numeric ids.
public static func parse(_ key: String) -> (source: String, id: Int)? {
guard let colon = key.firstIndex(of: ":") else { return nil }
let source = String(key[..<colon])
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
return (source, id)
}
}
@@ -0,0 +1,61 @@
import Foundation
/// When a complication needs to be redrawn.
///
/// A watch-face complication has a small refresh budget, so the timeline is
/// pre-computed from data the watch already holds instead of being refreshed on
/// a schedule: an entry on every event boundary means the complication changes
/// in the exact minute the next appointment does, without a network call.
public enum ComplicationTimeline {
/// Dates at which a complication should re-render.
///
/// Always begins with `now` so the first entry renders immediately. Event
/// starts and ends within `horizon` become entries.
///
/// The first `fineGrainedWindow` then gets a mark every minute, and the rest
/// of the horizon one every hour. The fine-grained part is what lets a
/// complication show a *compact* countdown — "23m" rather than "1:23:45" —
/// which is the only kind that fits an accessoryCorner or a circular gauge.
/// A live `Text(style: .timer)` needs no entries at all but is eight
/// characters wide, and overlaps the curved label it sits next to.
///
/// The result is sorted, deduplicated to the second, and capped at
/// `maxEntries`, keeping the earliest.
public static func entryDates(from now: Date,
in snapshot: CalendarrSnapshot,
horizon: TimeInterval = 24 * 3600,
fineGrainedWindow: TimeInterval = 3600,
maxEntries: Int = 150) -> [Date] {
let end = now.addingTimeInterval(horizon)
var seen: Set<Int> = []
var dates: [Date] = []
func add(_ date: Date) {
guard date >= now, date <= end else { return }
// Deduplicate to whole seconds: two events that meet exactly would
// otherwise produce two entries a float apart.
let key = Int(date.timeIntervalSince1970.rounded())
guard seen.insert(key).inserted else { return }
dates.append(date)
}
add(now)
for event in snapshot.events {
add(event.start)
add(event.end)
}
let fineEnd = min(end, now.addingTimeInterval(fineGrainedWindow))
var minute = now.addingTimeInterval(60)
while minute <= fineEnd {
add(minute)
minute = minute.addingTimeInterval(60)
}
var hourly = now.addingTimeInterval(3600)
while hourly <= end {
add(hourly)
hourly = hourly.addingTimeInterval(3600)
}
return Array(dates.sorted().prefix(maxEntries))
}
}
@@ -0,0 +1,85 @@
import Foundation
/// Non-secret session facts published alongside the snapshot.
///
/// This exists so a consumer can answer "which server, and is anyone signed in?"
/// without reading Calendarr's own `UserDefaults`, which lives in a different
/// sandbox. The auth token is deliberately *not* here — it belongs in the shared
/// keychain group, not in a plain file in the container.
public struct SharedSession: Codable, Sendable {
public static let currentSchema = 1
public static let maxSupportedSchema = 1
public let schemaVersion: Int
public let baseURL: String
public let username: String
public let isLoggedIn: Bool
public let writtenAt: Date
public init(baseURL: String, username: String, isLoggedIn: Bool, writtenAt: Date) {
self.schemaVersion = Self.currentSchema
self.baseURL = baseURL
self.username = username
self.isLoggedIn = isLoggedIn
self.writtenAt = writtenAt
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
baseURL = try c.decode(String.self, forKey: .baseURL)
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
writtenAt = try c.decodeIfPresent(Date.self, forKey: .writtenAt) ?? Date(timeIntervalSince1970: 0)
}
enum CodingKeys: String, CodingKey {
case schemaVersion, baseURL, username, isLoggedIn, writtenAt
}
/// The server URL as a `URL`, or nil when unset or malformed.
public var url: URL? { baseURL.isEmpty ? nil : URL(string: baseURL) }
}
public struct SharedSessionStore: Sendable {
private static let filename = "session.json"
public let appGroupID: String
public init(appGroupID: String = CalendarrAppGroup.current) {
self.appGroupID = appGroupID
}
private var fileURL: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)?
.appendingPathComponent(Self.filename)
}
/// Nil when Calendarr has never published, or the file is unreadable or newer
/// than this reader understands.
public func read() -> SharedSession? {
guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let session = try? decoder.decode(SharedSession.self, from: data),
session.schemaVersion <= SharedSession.maxSupportedSchema else { return nil }
return session
}
@_spi(Writer)
public func write(_ session: SharedSession) throws {
guard let url = fileURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(session).write(to: url, options: .atomic)
}
@_spi(Writer)
public func clear() {
guard let url = fileURL else { return }
try? FileManager.default.removeItem(at: url)
}
}
@@ -0,0 +1,62 @@
import Foundation
/// Cross-process "the snapshot changed" broadcast.
///
/// Two sandboxed apps cannot see each other's `NotificationCenter`, and neither
/// can poll the container cheaply. Darwin notifications are the one signal that
/// crosses the process boundary without an extra entitlement, a running
/// connection, or a shared framework.
///
/// The payload is always empty — Darwin notifications carry no data. The signal
/// means only "read again". If it is ever missed, a consumer that also reloads
/// on window activation degrades to a slightly stale view rather than a wrong one.
public enum SnapshotChangeNotifier {
public static let name = "com.scarriffleservices.calendarr.snapshot-changed"
/// Announce that the snapshot was rewritten. Called by `SnapshotStore`.
public static func post() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(name as CFString),
nil, nil, true)
}
/// Start listening. The handler runs on an arbitrary thread, so hop to
/// whatever actor your UI needs before touching state.
///
/// Returns a token you must keep alive; deinit removes the observer.
public static func observe(_ handler: @escaping @Sendable () -> Void) -> ObservationToken {
ObservationToken(handler: handler)
}
public final class ObservationToken: @unchecked Sendable {
private let handler: @Sendable () -> Void
fileprivate init(handler: @escaping @Sendable () -> Void) {
self.handler = handler
// Darwin notifications hand back no context pointer we can rely on
// across process boundaries, so route through an unretained pointer
// to this token and look it up in the callback.
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
observer,
{ _, observer, _, _, _ in
guard let observer else { return }
Unmanaged<ObservationToken>.fromOpaque(observer)
.takeUnretainedValue().handler()
},
SnapshotChangeNotifier.name as CFString,
nil,
.deliverImmediately)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
CFNotificationName(SnapshotChangeNotifier.name as CFString),
nil)
}
}
}
@@ -0,0 +1,32 @@
import Foundation
extension CalendarrSnapshot {
/// Keep only the listed calendars. An empty set means "every calendar",
/// which is what an unconfigured widget or complication asks for.
///
/// Filtering narrows the events but deliberately not the window: the
/// snapshot still speaks for the same range, it just has fewer calendars
/// in it.
public func filtered(toCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { keys.contains($0.calendarKey) })
}
/// Remove the listed calendars, keeping everything else. An empty set is a
/// no-op.
public func filtered(excludingCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { !keys.contains($0.calendarKey) })
}
private func replacingEvents(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: writtenAt,
coverageStart: coverageStart,
coverageEnd: coverageEnd,
isLoggedIn: isLoggedIn,
writerVersion: writerVersion,
events: events,
theme: theme,
language: language)
}
}
@@ -0,0 +1,223 @@
import Foundation
// MARK: - Coverage policy
/// How much of the calendar the writer publishes. Shared so the writer and the
/// v1-file reconstruction below can never disagree about it.
public enum SnapshotCoverage {
/// Days before today. The current week must stay complete for widgets that
/// render Monday–today.
public static let daysBehind = 7
/// Days after today.
public static let daysAhead = 42
/// Hard cap on events written, applied after sorting by start date.
public static let maxEvents = 500
}
// MARK: - Event
/// One event as published to the shared container.
///
/// Deliberately lossy: notes, URL, source, creator, privacy, read-only status,
/// reminders and recurrence rules are all dropped. This is enough to *display* a
/// calendar and deliberately not enough to edit one or reason about permissions.
public struct SnapshotEvent: Codable, Sendable, Identifiable, Hashable {
public let id: String
public let title: String
public let start: Date
public let end: Date
public let isAllDay: Bool
public let colorHex: String
public let location: String
/// Ties the event to a calendar. See `CalendarKey`.
public let calendarKey: String
public init(id: String, title: String, start: Date, end: Date,
isAllDay: Bool, colorHex: String, location: String, calendarKey: String) {
self.id = id; self.title = title; self.start = start; self.end = end
self.isAllDay = isAllDay; self.colorHex = colorHex
self.location = location; self.calendarKey = calendarKey
}
// Lenient: caches written before calendarKey existed simply lack the field.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
title = try c.decode(String.self, forKey: .title)
start = try c.decode(Date.self, forKey: .start)
end = try c.decode(Date.self, forKey: .end)
isAllDay = try c.decode(Bool.self, forKey: .isAllDay)
colorHex = try c.decode(String.self, forKey: .colorHex)
location = try c.decode(String.self, forKey: .location)
calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? ""
}
}
// MARK: - Calendar
/// A calendar available for filtering, published alongside the events so a
/// consumer can build a picker without a network call.
public struct SnapshotCalendar: Codable, Sendable, Identifiable, Hashable {
/// A `CalendarKey`, matching `SnapshotEvent.calendarKey`.
public let id: String
public let name: String
public let colorHex: String
public init(id: String, name: String, colorHex: String) {
self.id = id; self.name = name; self.colorHex = colorHex
}
}
// MARK: - Theme
/// The colours the app is currently rendering with, so a consumer can match it
/// without reading the app's own preferences.
public struct SnapshotTheme: Sendable, Hashable {
public let today: String
public let text: String
public let background: String
public let line: String
public let primary: String
public let accent: String
public init(today: String, text: String, background: String,
line: String, primary: String, accent: String) {
self.today = today; self.text = text; self.background = background
self.line = line; self.primary = primary; self.accent = accent
}
}
// MARK: - Snapshot
/// The published calendar snapshot.
///
/// ## Wire compatibility
///
/// The six colour values are stored **flat** at the top level rather than nested
/// under `theme`, even though `theme` is the nicer thing to read in code. That is
/// deliberate: v1 files carry them flat and a v1 reader requires them flat, so
/// nesting would break older readers on the very first v2 write. Within a major
/// version this format only ever gains optional fields — old readers ignore what
/// they do not know, new readers default what is missing. `theme` is a computed
/// view over the stored values, so callers get the clean shape anyway.
public struct CalendarrSnapshot: Codable, Sendable {
/// Bump only for a breaking change, and see the rules in the README.
public static let currentSchema = 2
/// Files claiming a higher schema are refused rather than mis-decoded.
public static let maxSupportedSchema = 2
public let schemaVersion: Int
public let writtenAt: Date
/// Start of the published window. Outside `coverageStart..<coverageEnd` the
/// snapshot carries *no information* — which is not the same as "no events".
public let coverageStart: Date
public let coverageEnd: Date
/// False once the user signs out. Distinguishes "signed out" from "never ran".
public let isLoggedIn: Bool
/// CFBundleShortVersionString of whoever wrote the file. Diagnostics only.
public let writerVersion: String
public let events: [SnapshotEvent]
public let language: String
// Stored flat for wire compatibility — read them via `theme`.
let todayColorHex: String
let textColorHex: String
let backgroundColorHex: String
let lineColorHex: String
let primaryColorHex: String
let accentColorHex: String
public var theme: SnapshotTheme {
SnapshotTheme(today: todayColorHex, text: textColorHex,
background: backgroundColorHex, line: lineColorHex,
primary: primaryColorHex, accent: accentColorHex)
}
/// True when `date` falls inside the published window, i.e. when an absence
/// of events for that date genuinely means "nothing scheduled".
public func covers(_ date: Date) -> Bool {
date >= coverageStart && date < coverageEnd
}
public init(writtenAt: Date,
coverageStart: Date,
coverageEnd: Date,
isLoggedIn: Bool,
writerVersion: String,
events: [SnapshotEvent],
theme: SnapshotTheme,
language: String) {
self.schemaVersion = Self.currentSchema
self.writtenAt = writtenAt
self.coverageStart = coverageStart
self.coverageEnd = coverageEnd
self.isLoggedIn = isLoggedIn
self.writerVersion = writerVersion
self.events = events
self.language = language
self.todayColorHex = theme.today
self.textColorHex = theme.text
self.backgroundColorHex = theme.background
self.lineColorHex = theme.line
self.primaryColorHex = theme.primary
self.accentColorHex = theme.accent
}
/// Lenient decoder. Everything added after v1 is optional here, and a v1
/// file's coverage window is reconstructed from the policy that produced it.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
events = try c.decode([SnapshotEvent].self, forKey: .events)
language = try c.decode(String.self, forKey: .language)
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
textColorHex = try c.decode(String.self, forKey: .textColorHex)
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
writerVersion = try c.decodeIfPresent(String.self, forKey: .writerVersion) ?? "unknown"
// v1 had no explicit window; it always wrote the same one, measured from
// the start of the day it was written.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: writtenAt)
coverageStart = try c.decodeIfPresent(Date.self, forKey: .coverageStart)
?? cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? dayStart
coverageEnd = try c.decodeIfPresent(Date.self, forKey: .coverageEnd)
?? cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? dayStart
}
enum CodingKeys: String, CodingKey {
case schemaVersion, writtenAt, coverageStart, coverageEnd, isLoggedIn, writerVersion
case events, language
case todayColorHex, textColorHex, backgroundColorHex, lineColorHex
case primaryColorHex, accentColorHex
}
}
// MARK: - Read result
/// Why a read produced no usable snapshot. The distinction matters for the UI:
/// "never ran", "signed out" and "genuinely empty" call for different messages.
public enum SnapshotReadResult: Sendable {
case ok(CalendarrSnapshot)
/// The user signed out of Calendarr. Show a sign-in prompt, not stale events.
case loggedOut(writtenAt: Date)
/// Calendarr has never published on this machine. Ask the user to open it once.
case neverWritten
/// Written by a newer Calendarr than this reader understands. Refuse rather
/// than guess; ask the user to update.
case incompatible(foundSchema: Int, maxSupported: Int)
/// Corrupt file, or — much more likely — a misconfigured App Group.
case unreadable(String)
public var snapshot: CalendarrSnapshot? {
if case .ok(let s) = self { return s }
return nil
}
}
@@ -0,0 +1,139 @@
import Foundation
public enum SnapshotStoreError: Error, LocalizedError {
case containerUnavailable(appGroupID: String)
public var errorDescription: String? {
switch self {
case .containerUnavailable(let id):
return "App Group container unavailable for \(id). Check that "
+ "com.apple.security.application-groups in this target's "
+ "entitlements matches exactly, and that macOS builds use the "
+ "Team ID prefix."
}
}
}
/// Reads — and, for Calendarr itself, writes — the shared calendar snapshot.
///
/// Writing is gated behind `@_spi(Writer)` so a read-only consumer cannot
/// corrupt the shared cache even by accident. Calendarr imports the package as
/// `@_spi(Writer) import CalendarrCore`; everyone else gets read access only.
public struct SnapshotStore: Sendable {
private static let cacheFilename = "widget-cache.json"
private static let calendarsFilename = "widget-calendars.json"
public let appGroupID: String
public init(appGroupID: String = CalendarrAppGroup.current) {
self.appGroupID = appGroupID
}
private var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}
private static func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}
// MARK: - Reading
/// Load the published snapshot.
///
/// Never returns a bare nil: every failure mode is named, because they call
/// for different things in the UI and because a silent nil is precisely how
/// a misconfigured App Group used to hide itself.
public func read() -> SnapshotReadResult {
guard let container = containerURL else {
return .unreadable(SnapshotStoreError
.containerUnavailable(appGroupID: appGroupID).localizedDescription)
}
let url = container.appendingPathComponent(Self.cacheFilename)
guard FileManager.default.fileExists(atPath: url.path) else {
// No cache. Signing out deletes the events — keeping them would leak
// the previous user's calendar — so the session file is what tells
// "signed out" apart from "never ran here". Answering that in one
// call keeps every consumer from reimplementing the distinction.
if let session = SharedSessionStore(appGroupID: appGroupID).read(),
!session.isLoggedIn {
return .loggedOut(writtenAt: session.writtenAt)
}
return .neverWritten
}
let data: Data
do { data = try Data(contentsOf: url) }
catch { return .unreadable("Could not read \(Self.cacheFilename): \(error.localizedDescription)") }
// Check the version before decoding: a newer writer may have changed the
// meaning of a field we think we understand, so refusing beats guessing.
if let probe = try? Self.decoder().decode(SchemaProbe.self, from: data) {
let found = probe.schemaVersion ?? 1
if found > CalendarrSnapshot.maxSupportedSchema {
return .incompatible(foundSchema: found,
maxSupported: CalendarrSnapshot.maxSupportedSchema)
}
}
do {
let snapshot = try Self.decoder().decode(CalendarrSnapshot.self, from: data)
guard snapshot.isLoggedIn else { return .loggedOut(writtenAt: snapshot.writtenAt) }
return .ok(snapshot)
} catch {
return .unreadable("Could not decode \(Self.cacheFilename): \(error)")
}
}
/// The calendars available for filtering. Empty when nothing is published.
public func readCalendars() -> [SnapshotCalendar] {
guard let url = containerURL?.appendingPathComponent(Self.calendarsFilename),
let data = try? Data(contentsOf: url) else { return [] }
return (try? Self.decoder().decode([SnapshotCalendar].self, from: data)) ?? []
}
/// Reads only the version field, so an unsupported file is never fully decoded.
private struct SchemaProbe: Decodable { let schemaVersion: Int? }
// MARK: - Writing (Calendarr only)
@_spi(Writer)
public func write(_ snapshot: CalendarrSnapshot) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(snapshot)
try data.write(to: container.appendingPathComponent(Self.cacheFilename), options: .atomic)
SnapshotChangeNotifier.post()
}
@_spi(Writer)
public func writeCalendars(_ calendars: [SnapshotCalendar]) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(calendars)
try data.write(to: container.appendingPathComponent(Self.calendarsFilename), options: .atomic)
}
/// Remove everything published. Call this on sign-out and on server reset —
/// the container outlives the session, so without it the next reader keeps
/// rendering the previous user's calendar.
@_spi(Writer)
public func clear() {
guard let container = containerURL else { return }
for name in [Self.cacheFilename, Self.calendarsFilename] {
try? FileManager.default.removeItem(at: container.appendingPathComponent(name))
}
SnapshotChangeNotifier.post()
}
}
@@ -0,0 +1,67 @@
import Foundation
/// How much of the calendar travels to the watch.
///
/// Deliberately narrower than `SnapshotCoverage`: the payload crosses
/// WatchConnectivity on every publish, and a watch complication never needs six
/// weeks of history to answer "what is next".
public enum WatchCoverage {
/// Days before today. One is enough for "still running" all-day events.
public static let daysBehind = 1
/// Days after today.
public static let daysAhead = 14
/// Hard cap on events sent, applied after sorting by start date.
public static let maxEvents = 120
}
extension CalendarrSnapshot {
/// A narrower copy of this snapshot, suitable for sending to the watch.
///
/// The coverage window shrinks together with the events, and never widens
/// past what the writer already claimed. If `WatchCoverage.maxEvents`
/// truncates the list, `coverageEnd` is pulled back to the first event we
/// had to drop: everything before that point is provably complete, and
/// beyond it the watch must not pretend to know anything.
public func trimmedForWatch(now: Date = Date(),
calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot {
let dayStart = calendar.startOfDay(for: now)
let windowStart = max(coverageStart,
calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart)
let windowEnd = min(coverageEnd,
calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart)
guard windowEnd > windowStart else {
return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: [])
}
let inWindow = events
.filter { $0.start < windowEnd && $0.end > windowStart }
.sorted { $0.start < $1.start }
guard inWindow.count > WatchCoverage.maxEvents else {
return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow)
}
let kept = Array(inWindow.prefix(WatchCoverage.maxEvents))
// The first dropped event marks the edge of what we can prove. Its start
// becomes the new end of coverage; the last kept event is dropped too
// when it starts at that same instant, which keeps the invariant
// "every event we carry starts before coverageEnd" exactly true.
let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start))
return Self.rebuilt(from: self, start: windowStart, end: provenEnd,
events: kept.filter { $0.start < provenEnd && $0.end > windowStart })
}
private static func rebuilt(from source: CalendarrSnapshot,
start: Date, end: Date,
events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: source.writtenAt,
coverageStart: start,
coverageEnd: end,
isLoggedIn: source.isLoggedIn,
writerVersion: source.writerVersion,
events: events,
theme: source.theme,
language: source.language)
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Identifies the appointment a complication is currently showing.
///
/// The start date is part of the identity on purpose: a moved event keeps its
/// id, and the complication is wrong until it is told.
public struct NextEventIdentity: Equatable, Sendable, Codable {
public let id: String
public let start: Date
public init(id: String, start: Date) {
self.id = id
self.start = start
}
}
public enum WatchPushPolicy {
/// The first event that has not ended yet — which is what every "next
/// appointment" complication renders, including one that is running now.
public static func nextEventIdentity(in snapshot: CalendarrSnapshot,
at now: Date) -> NextEventIdentity? {
snapshot.events
.filter { $0.end > now }
.min { $0.start < $1.start }
.map { NextEventIdentity(id: $0.id, start: $0.start) }
}
/// Whether this change is worth one of the day's limited complication
/// pushes. Only a changed next appointment is — everything else reaches the
/// watch on the next application-context delivery.
public static func shouldPushComplicationUpdate(previous: NextEventIdentity?,
current: NextEventIdentity?) -> Bool {
previous != current
}
}
@@ -0,0 +1,101 @@
import Foundation
/// Dictionary keys used on the WatchConnectivity wire. Stable within a major
/// version, like the snapshot filenames.
public enum WatchTransportKey {
public static let schema = "schema"
public static let snapshot = "snapshot"
public static let calendars = "calendars"
public static let session = "session"
}
public enum WatchTransportError: Error, Equatable, LocalizedError {
case missingSnapshot
case unsupportedSchema(found: Int, maxSupported: Int)
case malformed(String)
public var errorDescription: String? {
switch self {
case .missingSnapshot:
return "Watch payload carried no snapshot."
case .unsupportedSchema(let found, let max):
return "Watch payload schema \(found) is newer than this build understands (\(max))."
case .malformed(let why):
return "Watch payload could not be decoded: \(why)"
}
}
}
/// What the phone sends to the watch: a trimmed snapshot, the calendar list for
/// a filter UI, and the non-secret session facts so the watch can tell "signed
/// out" from "never received anything".
///
/// The auth token is deliberately absent. The watch never talks to the server,
/// so there is nothing for it to authenticate with and nothing to leak.
public struct WatchTransportPayload: Sendable {
/// Bump only for a breaking change to the dictionary shape.
public static let currentSchema = 1
/// Payloads claiming a higher schema are refused rather than mis-decoded.
public static let maxSupportedSchema = 1
public let snapshot: CalendarrSnapshot
public let calendars: [SnapshotCalendar]
public let session: SharedSession?
public init(snapshot: CalendarrSnapshot,
calendars: [SnapshotCalendar],
session: SharedSession?) {
self.snapshot = snapshot
self.calendars = calendars
self.session = session
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}
private static func decoder() -> JSONDecoder {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}
/// Encode for `WCSession`. Values are `Data` and `Int` only, because
/// WatchConnectivity accepts property-list types and rejects everything
/// else at runtime rather than at compile time.
public func encoded() throws -> [String: Any] {
let e = Self.encoder()
var dict: [String: Any] = [
WatchTransportKey.schema: Self.currentSchema,
WatchTransportKey.snapshot: try e.encode(snapshot),
WatchTransportKey.calendars: try e.encode(calendars),
]
if let session { dict[WatchTransportKey.session] = try e.encode(session) }
return dict
}
public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload {
let found = dict[WatchTransportKey.schema] as? Int ?? 1
guard found <= maxSupportedSchema else {
throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema)
}
guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else {
throw WatchTransportError.missingSnapshot
}
let d = decoder()
let snapshot: CalendarrSnapshot
do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) }
catch { throw WatchTransportError.malformed("snapshot: \(error)") }
var calendars: [SnapshotCalendar] = []
if let data = dict[WatchTransportKey.calendars] as? Data {
calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? []
}
var session: SharedSession?
if let data = dict[WatchTransportKey.session] as? Data {
session = try? d.decode(SharedSession.self, from: data)
}
return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session)
}
}
@@ -0,0 +1,51 @@
import Testing
@testable import CalendarrCore
/// The server reports `calendar_id` two different ways, and normalising them
/// inconsistently is what made per-calendar filtering silently do nothing for
/// some sources. These pin the rule down.
struct CalendarKeyTests {
@Test("CalDAV ids arrive bare and are used as-is")
func bareNumericId() {
#expect(CalendarKey.make(source: "caldav", calendarId: "12") == "caldav:12")
}
@Test("Prefixed ids from the other sources get the prefix stripped")
func strippedPrefix() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3") == "local:3")
#expect(CalendarKey.make(source: "google", calendarId: "google-5") == "google:5")
#expect(CalendarKey.make(source: "ical", calendarId: "ical-7") == "ical:7")
#expect(CalendarKey.make(source: "homeassistant",
calendarId: "homeassistant-9") == "homeassistant:9")
}
@Test("Both spellings of the same calendar produce the same key")
func bothFormsAgree() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3")
== CalendarKey.make(source: "local", calendarId: "3"))
}
@Test("Only a leading prefix is stripped, and only once")
func stripsOnlyLeadingPrefix() {
// A prefix appearing later in the id must survive untouched.
#expect(CalendarKey.make(source: "local", calendarId: "3-local") == "local:3-local")
// Two prefixes: only the first goes.
#expect(CalendarKey.make(source: "local", calendarId: "local-local-3") == "local:local-3")
}
@Test("parse round-trips make")
func roundTrip() {
let key = CalendarKey.make(source: "local", calendarId: "local-42")
let parsed = CalendarKey.parse(key)
#expect(parsed?.source == "local")
#expect(parsed?.id == 42)
}
@Test("parse rejects what it cannot represent")
func rejectsMalformed() {
#expect(CalendarKey.parse("nocolon") == nil)
#expect(CalendarKey.parse("local:abc") == nil) // non-numeric id
#expect(CalendarKey.parse("local:") == nil)
}
}
@@ -0,0 +1,110 @@
import Foundation
import Testing
@testable import CalendarrCore
struct ComplicationTimelineTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The first entry is always now, so the complication renders immediately")
func startsAtNow() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
#expect(dates.first == Self.now)
}
@Test("Entries are sorted and free of duplicates")
func sortedAndDeduplicated() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800),
Self.event("b", startOffset: 5400, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates == dates.sorted())
#expect(Set(dates).count == dates.count)
}
@Test("Event starts and ends inside the horizon become entries")
func includesEventBoundaries() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.contains(Self.now.addingTimeInterval(3600)))
#expect(dates.contains(Self.now.addingTimeInterval(5400)))
}
@Test("Boundaries outside the horizon are ignored")
func ignoresBoundariesBeyondHorizon() {
let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600)
#expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false)
#expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) })
}
@Test("Boundaries already in the past are ignored")
func ignoresPastBoundaries() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.allSatisfy { $0 >= Self.now })
}
@Test("An empty calendar still gets hourly entries across the horizon")
func fillsHourlyWhenEmpty() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]),
horizon: 24 * 3600)
#expect(dates.count >= 24)
}
@Test("The first hour gets minute-granular entries, so a compact countdown stays honest")
func minuteGranularFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let firstHour = dates.filter { $0 <= Self.now.addingTimeInterval(3600) }
// 60 minute marks plus `now` itself.
#expect(firstHour.count >= 60)
#expect(firstHour.contains(Self.now.addingTimeInterval(60)))
#expect(firstHour.contains(Self.now.addingTimeInterval(1800)))
}
@Test("Past the first hour the entries thin out to hourly")
func hourlyAfterFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let later = dates.filter { $0 > Self.now.addingTimeInterval(3600) }
// 23 further hours, not 23 * 60 minutes.
#expect(later.count < 40)
#expect(later.contains(Self.now.addingTimeInterval(2 * 3600)))
}
@Test("The entry count is capped, keeping the earliest entries")
func capsEntries() {
var events: [SnapshotEvent] = []
for i in 0..<300 {
events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30))
}
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events),
maxEntries: 50)
#expect(dates.count == 50)
#expect(dates.first == Self.now)
#expect(dates == dates.sorted())
}
}
@@ -0,0 +1,51 @@
import Foundation
import Testing
@testable import CalendarrCore
struct SnapshotFilterTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, key: String) -> SnapshotEvent {
SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key)
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("An empty allow-list means every calendar, not no calendar")
func emptyAllowListKeepsEverything() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: []).events.count == 2)
}
@Test("An allow-list keeps only the listed calendars")
func allowListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"])
}
@Test("A deny-list removes only the listed calendars")
func denyListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"])
}
@Test("Filtering narrows the events but never the window")
func windowIsUntouched() {
let snap = Self.snapshot([Self.event("a", key: "local:1")])
let filtered = snap.filtered(toCalendarKeys: ["nothing:0"])
#expect(filtered.events.isEmpty)
#expect(filtered.coverageStart == snap.coverageStart)
#expect(filtered.coverageEnd == snap.coverageEnd)
}
}
@@ -0,0 +1,172 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The wire format is a contract between apps that ship independently, so both
/// directions of compatibility need a test: a new reader must understand an old
/// file, and an old reader must survive a new one.
struct SnapshotSchemaTests {
private static func decoder() -> JSONDecoder {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}
/// A real v1 file: no schemaVersion, no coverage window, no isLoggedIn,
/// colours flat at the top level.
private static let v1Fixture = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern",
"calendarKey": "local:3"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"primaryColorHex": "#58B900",
"accentColorHex": "#45A148",
"language": "de"
}
"""
@Test("A v1 file decodes, and its missing fields get sane defaults")
func decodesV1() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1Fixture.utf8))
#expect(snap.schemaVersion == 1)
#expect(snap.events.count == 1)
#expect(snap.events[0].calendarKey == "local:3")
#expect(snap.language == "de")
#expect(snap.theme.today == "#6FB669")
#expect(snap.theme.accent == "#45A148")
// v1 never said whether anyone was signed in; assuming yes preserves the
// old behaviour of simply rendering whatever was cached.
#expect(snap.isLoggedIn)
#expect(snap.writerVersion == "unknown")
// The window is reconstructed from the policy v1 always used.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: snap.writtenAt)
#expect(snap.coverageStart == cal.date(byAdding: .day, value: -7, to: dayStart))
#expect(snap.coverageEnd == cal.date(byAdding: .day, value: 42, to: dayStart))
}
/// Older still: written before per-calendar filtering existed, so the events
/// carry no calendarKey at all.
private static let v1FixtureNoCalendarKey = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"language": "de"
}
"""
@Test("A v1 file that predates calendarKey and the newer colours still decodes")
func decodesV1WithoutCalendarKey() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1FixtureNoCalendarKey.utf8))
#expect(snap.events[0].calendarKey == "")
// primary/accent were added later too, so they fall back to defaults.
#expect(snap.theme.primary == "#4285f4")
#expect(snap.theme.accent == "#ea4335")
}
@Test("v2 round-trips without losing anything")
func roundTripsV2() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let original = CalendarrSnapshot(
writtenAt: start,
coverageStart: start.addingTimeInterval(-7 * 86_400),
coverageEnd: start.addingTimeInterval(42 * 86_400),
isLoggedIn: true,
writerVersion: "3.5",
events: [SnapshotEvent(id: "x", title: "Termin", start: start,
end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#58B900",
location: "", calendarKey: "local:1")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "ch")
let decoded = try Self.decoder().decode(
CalendarrSnapshot.self, from: Self.encoder().encode(original))
#expect(decoded.schemaVersion == CalendarrSnapshot.currentSchema)
#expect(decoded.writerVersion == "3.5")
#expect(decoded.language == "ch")
#expect(decoded.coverageStart == original.coverageStart)
#expect(decoded.coverageEnd == original.coverageEnd)
#expect(decoded.theme == original.theme)
#expect(decoded.events == original.events)
}
/// The reason the colours are stored flat rather than nested under `theme`:
/// a v1 reader requires them at the top level, so nesting them would have
/// broken every older build on the first v2 write.
@Test("v2 keeps the colour keys where a v1 reader expects them")
func v2StaysReadableByV1() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: start, coverageStart: start, coverageEnd: start,
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "#a", text: "#b", background: "#c",
line: "#d", primary: "#e", accent: "#f"),
language: "de")
let object = try JSONSerialization.jsonObject(
with: Self.encoder().encode(snap)) as? [String: Any]
for key in ["todayColorHex", "textColorHex", "backgroundColorHex",
"lineColorHex", "primaryColorHex", "accentColorHex",
"writtenAt", "events", "language"] {
#expect(object?[key] != nil, "v1 readers require \(key) at the top level")
}
#expect(object?["theme"] == nil, "theme must stay a computed view, not a stored key")
}
@Test("covers() separates 'no events' from 'no information'")
func coverageWindow() {
let now = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: now,
coverageStart: now.addingTimeInterval(-86_400),
coverageEnd: now.addingTimeInterval(86_400),
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "", text: "", background: "",
line: "", primary: "", accent: ""),
language: "de")
#expect(snap.covers(now))
#expect(snap.covers(now.addingTimeInterval(-86_399)))
#expect(!snap.covers(now.addingTimeInterval(-90_000)))
#expect(!snap.covers(snap.coverageEnd)) // half-open: end is excluded
}
}
@@ -0,0 +1,76 @@
import Foundation
import Testing
@testable import CalendarrCore
struct WatchPushPolicyTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval = 3600) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The next event is the first one that has not ended yet")
func picksFirstUnfinished() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200),
Self.event("running", startOffset: -600),
Self.event("later", startOffset: 3600)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running")
}
@Test("An empty calendar has no next event")
func emptyHasNoIdentity() {
#expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil)
}
@Test("Events are considered in start order regardless of input order")
func sortsBeforePicking() {
let snap = Self.snapshot([Self.event("late", startOffset: 7200),
Self.event("soon", startOffset: 1800)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon")
}
@Test("An unchanged next event does not spend a push")
func unchangedDoesNotPush() {
let identity = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false)
}
@Test("A different event, a moved event, or a first event all spend a push")
func changesPush() {
let a = NextEventIdentity(id: "a", start: Self.now)
let b = NextEventIdentity(id: "b", start: Self.now)
let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a))
}
@Test("Losing the last event spends a push, so the watch stops showing it")
func clearingPushes() {
let a = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil))
}
@Test("Two empty calendars in a row do not push")
func stayingEmptyDoesNotPush() {
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false)
}
}
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The phone and the watch ship on independent schedules, so the transport gets
/// the same treatment as the file format: refuse a newer schema rather than
/// mis-decode it.
struct WatchTransportTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func snapshot() -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.2",
events: [SnapshotEvent(id: "a", title: "Zahnarzt",
start: now.addingTimeInterval(3600),
end: now.addingTimeInterval(7200),
isAllDay: false, colorHex: "#4285f4",
location: "Bern", calendarKey: "local:3")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("A payload survives a round trip")
func roundTrips() throws {
let payload = WatchTransportPayload(
snapshot: Self.snapshot(),
calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")],
session: SharedSession(baseURL: "https://cal.example.com", username: "guido",
isLoggedIn: true, writtenAt: Self.now))
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.snapshot.events.map(\.id) == ["a"])
#expect(decoded.snapshot.language == "de")
#expect(decoded.calendars.map(\.name) == ["Privat"])
#expect(decoded.session?.username == "guido")
}
@Test("A payload with no session record still decodes")
func sessionIsOptional() throws {
let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil)
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.session == nil)
#expect(decoded.calendars.isEmpty)
}
@Test("The encoded form only contains property-list types")
func encodesToPlistTypes() throws {
let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
#expect(dict[WatchTransportKey.schema] is Int)
#expect(dict[WatchTransportKey.snapshot] is Data)
#expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary))
}
@Test("A newer schema is refused rather than guessed at")
func refusesNewerSchema() throws {
var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1
#expect(throws: WatchTransportError.unsupportedSchema(
found: WatchTransportPayload.maxSupportedSchema + 1,
maxSupported: WatchTransportPayload.maxSupportedSchema)) {
try WatchTransportPayload.decode(dict)
}
}
@Test("A payload without a snapshot is rejected")
func rejectsMissingSnapshot() {
let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema]
#expect(throws: WatchTransportError.missingSnapshot) {
try WatchTransportPayload.decode(dict)
}
}
}
@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The watch payload is a narrower snapshot, not a different one. These tests
/// pin down the part that is easy to get wrong: the coverage window has to
/// shrink with the events, or the watch renders an empty week it knows nothing
/// about.
struct WatchTrimmingTests {
private static let cal = Calendar(identifier: .gregorian)
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
let dayStart = cal.startOfDay(for: now)
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
let start = cal.date(byAdding: .hour, value: hour, to: day)!
return SnapshotEvent(id: id, title: "Event \(id)",
start: start, end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
let dayStart = cal.startOfDay(for: now)
return CalendarrSnapshot(
writtenAt: now,
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
isLoggedIn: true,
writerVersion: "1.0",
events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The window narrows to the watch policy, not just the events")
func narrowsWindow() {
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
let dayStart = Self.cal.startOfDay(for: Self.now)
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
}
@Test("Events outside the watch window are dropped")
func dropsEventsOutsideWindow() {
let inside = Self.event("in", dayOffset: 3)
let outside = Self.event("out", dayOffset: 30)
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["in"])
}
@Test("An event still running at the window start is kept")
func keepsRunningEvent() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
let running = SnapshotEvent(id: "running", title: "Urlaub",
start: start,
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
isAllDay: true, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["running"])
}
@Test("Metadata survives trimming unchanged")
func preservesMetadata() {
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.language == "de")
#expect(trimmed.isLoggedIn == true)
#expect(trimmed.writerVersion == "1.0")
#expect(trimmed.theme.accent == "#6")
#expect(trimmed.writtenAt == original.writtenAt)
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
}
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
func capPullsBackCoverage() {
var many: [SnapshotEvent] = []
for day in 0..<14 {
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
}
#expect(many.count > WatchCoverage.maxEvents)
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
let dayStart = Self.cal.startOfDay(for: Self.now)
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
#expect(trimmed.coverageEnd < fullEnd)
#expect(trimmed.coverageEnd > trimmed.coverageStart)
}
@Test("Trimming never widens a window the writer already narrowed")
func neverWidens() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let narrow = CalendarrSnapshot(
writtenAt: Self.now,
coverageStart: dayStart,
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
isLoggedIn: true, writerVersion: "1.0", events: [],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.coverageStart == dayStart)
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
}
}
@@ -357,7 +357,7 @@ class CalendarRepository @Inject constructor(
val body = jsonBody("reminders_enabled" to enabled)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
"local" -> api.setLocalCalendarReminders(calendarId, body).ensureSuccess()
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
@@ -192,6 +192,11 @@ interface CalendarrApi {
@PUT("api/local/calendars/{id}/color")
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// Reminder-only update that works for owners AND share recipients
// (recipients get their own per-user mute; owners flip the calendar's flag).
@PUT("api/local/calendars/{id}/reminders")
suspend fun setLocalCalendarReminders(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
@@ -490,6 +490,11 @@ class CalendarViewModel @Inject constructor(
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
// Needed for the reminder flag, which exists on all five sources. A
// failed fetch yields an empty list, which simply reconciles nothing —
// never a wrongly cleared mute.
val local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
val ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList())
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
fun apply(source: String, id: Int, serverHidden: Boolean) {
@@ -500,9 +505,25 @@ class CalendarViewModel @Inject constructor(
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
if (banished == settingsStore.banishedCalendarKeys) return false
settingsStore.banishedCalendarKeys = banished
_state.update { it.copy(banishedKeys = banished) }
// Reminders were only ever pushed app -> server, so a mute set on the
// web or on iOS never arrived here.
val muted = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
fun applyReminders(source: String, id: Int, enabled: Boolean) {
val key = calendarKey(source, id.toString())
if (enabled) muted.remove(key) else muted.add(key)
}
local.forEach { applyReminders("local", it.id, it.remindersEnabled) }
ical.forEach { applyReminders("ical", it.id, it.remindersEnabled) }
caldav.forEach { acc -> acc.calendars?.forEach { applyReminders("caldav", it.id, it.remindersEnabled) } }
google.forEach { acc -> acc.calendars?.forEach { applyReminders("google", it.id, it.remindersEnabled) } }
ha.forEach { acc -> acc.calendars?.forEach { applyReminders("homeassistant", it.id, it.remindersEnabled) } }
val banishedChanged = banished != settingsStore.banishedCalendarKeys
val mutedChanged = muted != settingsStore.reminderDisabledCalendarKeys
if (!banishedChanged && !mutedChanged) return false
if (banishedChanged) settingsStore.banishedCalendarKeys = banished
if (mutedChanged) settingsStore.reminderDisabledCalendarKeys = muted
_state.update { it.copy(banishedKeys = banished, reminderDisabledKeys = muted) }
return true
}
@@ -473,8 +473,10 @@ private fun ReminderRow(
}
}
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
* should not offer files the upload would reject. */
// Mirrors the server allowlist. The catch-all wildcard type is deliberately
// absent — the picker should not offer files the upload would reject.
// (Written as line comments: spelling that wildcard inside a block comment
// closes it early, which is what used to break this file.)
private val ATTACHMENT_MIME_TYPES = arrayOf(
"application/pdf",
"image/png", "image/jpeg", "image/webp", "image/gif",
+1 -1
View File
@@ -19,7 +19,7 @@ Repo: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git
Builds:
- versionName: 1.1.0
versionCode: 2
commit: v1.1.0
commit: dd347a07f1c7c5fc55cc8bfb7fdfc5c5c020793f
subdir: app
gradle:
- yes
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+3
View File
@@ -5,6 +5,9 @@ pluginManagement {
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositories {
+5 -15
View File
@@ -4,7 +4,11 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
import caldav
from icalendar import Calendar, Event, vDatetime, vRecur
from icalendar import Calendar, Event, vDatetime
# One parser for both directions: this file used to carry its own copy with
# the same UNTIL defect.
from ical_io import _rrule_str_to_vrecur as _parse_rrule_str
logger = logging.getLogger(__name__)
@@ -306,20 +310,6 @@ def delete_event(url: str, username: str, password: str, event_url: str,
resource.delete()
def _parse_rrule_str(rrule_str: str) -> vRecur:
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
params = {}
for part in rrule_str.split(";"):
if "=" not in part:
continue
key, val = part.split("=", 1)
if "," in val:
params[key] = val.split(",")
else:
params[key] = val
return vRecur(params)
def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
+14 -7
View File
@@ -135,13 +135,17 @@ def parse_ics(raw: bytes) -> dict:
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
params = {}
for part in rrule_str.split(";"):
if "=" not in part:
continue
key, val = part.split("=", 1)
params[key] = val.split(",") if "," in val else val
return vRecur(params)
"""Parse an RRULE string into a vRecur with correctly typed values.
Hand-splitting on ";" and "=" leaves every value a str, which icalendar
accepts silently here and then rejects inside ``to_ical()``: vRecur.types
maps UNTIL to vDDDTypes, and that demands a date. A single "repeats until
<date>" event therefore made the whole calendar fail to serialise — a 500
on REPORT, on GET and on export, while PROPFIND kept working because it
never builds ICS. Letting icalendar parse its own format gives UNTIL as a
datetime and COUNT/INTERVAL as ints.
"""
return vRecur(vRecur.from_ical(rrule_str))
def _parse_iso(s: str) -> datetime:
@@ -198,7 +202,10 @@ def build_ics(calendar, events, *, name_cache: dict | None = None,
if ev.color:
item.add("x-calendarr-color", ev.color)
if ev.rrule:
try:
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
except (ValueError, TypeError):
logger.warning("Skipping unparsable RRULE %r on event %s", ev.rrule, ev.id)
# ORGANIZER from the creator (local user or imported name).
organizer_name = None
+5 -1
View File
@@ -158,7 +158,11 @@ def build_local_event_dict(
except (ValueError, TypeError):
pass
d["display_title"] = display
if not d["reminders"] and cal.birthday_notify_days_before is not None:
# Honour the calendar's mute: injecting a reminder here would hand the
# schedulers something to fire for a calendar the user silenced.
if (not d["reminders"]
and cal.birthday_notify_days_before is not None
and getattr(cal, "reminders_enabled", True)):
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
if owner is not None:
d["owner"] = owner
+18
View File
@@ -432,6 +432,24 @@ class CalendarColorPref(Base):
color = Column(String(16), nullable=False)
class CalendarReminderPref(Base):
"""A user's personal reminder mute for a calendar they don't own — works for
any way a foreign calendar becomes visible (direct share, group calendar, or
a co-member's group-visible calendar). Absent = reminders on, regardless of
what the owner chose: muting is a personal decision and must not spread from
the owner to everyone a calendar reaches."""
__tablename__ = "calendar_reminder_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_reminder_pref"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
enabled = Column(Boolean, nullable=False)
class Group(Base):
__tablename__ = "groups"
+11
View File
@@ -187,6 +187,17 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
return list(ids)
def reminder_prefs_for(db: Session, user_id: int) -> dict[int, bool]:
"""Map calendar_id -> the user's personal reminder flag for calendars they
don't own (any sharing path). Empty when the user set no overrides."""
return {
p.calendar_id: bool(p.enabled)
for p in db.query(models.CalendarReminderPref).filter(
models.CalendarReminderPref.user_id == user_id
)
}
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
"""Map calendar_id -> the user's personal colour for calendars they don't
own (any sharing path). Empty when the user set no overrides."""
+33 -5
View File
@@ -3,7 +3,7 @@ import logging
import secrets
import ssl
import time
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlencode
@@ -248,6 +248,35 @@ def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
return {}
def _ha_wait_until_listed(url: str, token: str, entity_id: str, data: dict) -> None:
"""Block until HA actually lists the event we just created.
HA confirms the create before its entity's event list reflects it, so a
client that refetches straight away misses the new event, the user assumes
it failed, retries — and ends up with a duplicate. Waiting here fixes every
client at once instead of each one guessing a delay.
Gives up quietly: the event exists either way, it may just need one more
refresh. Never turn a successful create into an error.
"""
try:
start = datetime.fromisoformat(_ha_format_dt(data.get("start", "")))
end = datetime.fromisoformat(_ha_format_dt(data.get("end", "")))
except Exception:
return
title = data.get("title", "")
# Widen the window so an all-day event or a timezone edge cannot hide it.
window_start, window_end = start - timedelta(days=1), end + timedelta(days=1)
for _ in range(8):
time.sleep(0.25)
try:
raw = _ha_get_events(url, token, entity_id, window_start, window_end)
except Exception:
return
if any(ev.get("summary") == title for ev in raw):
return
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
"""Update an event. Tries calendar/event/update first; if the integration
doesn't support update (e.g. Google Calendar via HA), falls back to
@@ -697,10 +726,9 @@ def create_event(
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_create_event(
account.url, token, cal.entity_id,
data.model_dump(exclude_none=True),
)
payload = data.model_dump(exclude_none=True)
_ha_create_event(account.url, token, cal.entity_id, payload)
_ha_wait_until_listed(account.url, token, cal.entity_id, payload)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event create failed: {exc}")
+65 -4
View File
@@ -15,6 +15,7 @@ import permissions
from auth import get_current_user
from database import get_db
from local_events_util import build_local_event_dict, resolve_creator
from urllib.parse import quote
router = APIRouter()
@@ -81,6 +82,7 @@ class ShareCreate(BaseModel):
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None,
color_override: Optional[str] = None,
reminders_override: Optional[bool] = None,
request: Optional[Request] = None) -> dict:
d = {
"id": cal.id,
@@ -91,7 +93,9 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
"color": color_override or cal.color,
"enabled": cal.enabled,
"sidebar_hidden": bool(cal.sidebar_hidden),
"reminders_enabled": bool(cal.reminders_enabled),
# A recipient's own choice wins over the owner's flag.
"reminders_enabled": bool(cal.reminders_enabled)
if reminders_override is None else reminders_override,
"caldav_published": bool(cal.caldav_published),
"is_birthday": bool(cal.is_birthday),
"birthday_notify_days_before": cal.birthday_notify_days_before,
@@ -141,6 +145,7 @@ def list_calendars(
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
reminder_prefs = permissions.reminder_prefs_for(db, current_user.id)
# Own calendars
own = (
@@ -175,6 +180,7 @@ def list_calendars(
shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission,
color_override=color_prefs.get(cal.id),
reminders_override=reminder_prefs.get(cal.id, True),
)
if cal.id in group_cal_map:
d["group"] = True
@@ -190,7 +196,8 @@ def list_calendars(
continue
seen_ids.add(cal_id)
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
color_override=color_prefs.get(cal.id))
color_override=color_prefs.get(cal.id),
reminders_override=reminder_prefs.get(cal.id, True))
d["group"] = True
result.append(d)
@@ -207,6 +214,7 @@ def list_calendars(
shared_by=(owner.display_name or owner.username) if owner else None,
permission="read",
color_override=color_prefs.get(cal.id),
reminders_override=reminder_prefs.get(cal.id, True),
request=request,
)
d["group_shared"] = True
@@ -353,6 +361,48 @@ def set_calendar_color(
return {"ok": True, "color": data.color}
class CalendarRemindersUpdate(BaseModel):
reminders_enabled: bool
@router.put("/calendars/{calendar_id}/reminders")
def set_calendar_reminders(
calendar_id: int,
data: CalendarRemindersUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Mute or unmute a calendar's reminders. The owner flips the calendar's own
flag; anyone else who can see it (direct share, group calendar, or a
co-member's group-visible calendar) sets only their OWN override and never
touches the owner's. The existing PUT /calendars/{id} stays owner-only,
which is what keeps a recipient from reaching the owner's flag at all."""
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
if cal is None:
raise HTTPException(404, "Calendar not found")
if cal.user_id == current_user.id:
cal.reminders_enabled = data.reminders_enabled
else:
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
raise HTTPException(403, "You cannot access this calendar")
pref = (
db.query(models.CalendarReminderPref)
.filter(
models.CalendarReminderPref.calendar_id == calendar_id,
models.CalendarReminderPref.user_id == current_user.id,
)
.first()
)
if pref:
pref.enabled = data.reminders_enabled
else:
db.add(models.CalendarReminderPref(
calendar_id=calendar_id, user_id=current_user.id,
enabled=data.reminders_enabled))
db.commit()
return {"ok": True, "reminders_enabled": data.reminders_enabled}
@router.post("/calendars/{calendar_id}/dav-token/rotate")
def rotate_dav_token(
calendar_id: int,
@@ -790,9 +840,20 @@ def export_calendar(
atts = attachments_store.by_event(db, [e.id for e in events])
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
attachments_by_event=atts, base_url=base_url)
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
# RFC 6266, the same way attachments_router already does it: str.isalnum()
# is true for "ö", so without an explicit ASCII test the "safe" name carries
# the very bytes it exists to avoid — a raw umlaut in an HTTP header, which
# a reverse proxy rejects outright. That is what made exporting a calendar
# called "Persönlich" fail with no usable error.
ascii_name = "".join(
c for c in cal.name if c.isascii() and (c.isalnum() or c in " -_")
).strip() or "calendar"
disposition = (
f'attachment; filename="{ascii_name}.ics"; '
f"filename*=UTF-8''{quote(cal.name + '.ics')}"
)
return Response(
content=ics,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
headers={"Content-Disposition": disposition},
)
+52
View File
@@ -0,0 +1,52 @@
"""RRULE serialisation.
An RRULE whose values stayed strings serialised fine until ``to_ical()``, where
icalendar rejects a str for UNTIL. One "repeats until <date>" event therefore
returned 500 for every REPORT, GET and export of its calendar while PROPFIND
kept succeeding — which is how a Home Assistant calendar ends up discovered
under the right name and permanently unavailable.
"""
import pytest
from ical_io import _rrule_str_to_vrecur
@pytest.mark.parametrize("rule", [
"FREQ=WEEKLY;UNTIL=20260501T235959Z",
"FREQ=DAILY;UNTIL=20260501T235959Z;INTERVAL=2",
"FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20260501T235959Z",
])
def test_until_survives_serialisation(rule):
"""The regression: these used to raise inside to_ical()."""
out = _rrule_str_to_vrecur(rule).to_ical().decode()
assert "UNTIL=20260501T235959Z" in out
@pytest.mark.parametrize("rule", [
"FREQ=WEEKLY;BYDAY=MO",
"FREQ=DAILY;COUNT=5",
"FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2",
"FREQ=MONTHLY;BYMONTHDAY=15",
])
def test_rules_without_until_still_round_trip(rule):
"""These always worked; pin them so the fix did not narrow what is accepted."""
out = _rrule_str_to_vrecur(rule).to_ical().decode()
for part in rule.split(";"):
key, val = part.split("=", 1)
assert key in out
for v in val.split(","):
assert v in out
def test_count_and_interval_become_integers():
"""icalendar types these; the hand-rolled parser left them as strings."""
rec = _rrule_str_to_vrecur("FREQ=DAILY;COUNT=5;INTERVAL=2")
assert rec["COUNT"] == [5]
assert rec["INTERVAL"] == [2]
def test_malformed_rule_raises_rather_than_corrupting():
"""build_ics catches this and drops the recurrence; it must not pass silently."""
with pytest.raises((ValueError, TypeError)):
_rrule_str_to_vrecur("FREQ=WEEKLY;UNTIL=not-a-date").to_ical()
+7 -2
View File
@@ -86,8 +86,13 @@ async function downloadRequest(path, fallbackName) {
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
const e = new Error(err.detail || `HTTP ${res.status}`);
// A non-JSON body means the failure happened outside the app — a reverse
// proxy rejecting the response, say. Showing "unknown error" there hides
// the one clue there is, so fall back to the body text and the status.
const raw = await res.text().catch(() => '');
let detail = '';
try { detail = JSON.parse(raw).detail || ''; } catch { detail = raw.trim().slice(0, 200); }
const e = new Error(detail || t('unknown_error') + ' (HTTP ' + res.status + ')');
// Keep the status so callers can map it to a translated message instead of
// showing the backend's German detail to someone running the app in Finnish.
e.status = res.status;
+23 -10
View File
@@ -830,16 +830,18 @@ function renderCalendarList() {
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
name: cal.shared_by || cal.name, color: cal.color, enabled: !hiddenLocal.has(cal.id), readOnly,
sourceLabel: `${t('shared_with_me')} · ${cal.name}${readOnly ? ' · ' + t('perm_read') : ''}`,
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
remove: { icon: EYE_OFF, title: t('hide_cal') } });
});
// Group calendars (owned by the creator or reached via membership) — shown so
// they can be toggled/recoloured. Owned group calendars can also be muted
// (reminders), which the server then syncs; member-reached ones can't (no PUT).
// they can be toggled, recoloured and muted. Muting is per user: the creator
// owns the calendar, and silencing it for themselves must not silence the
// whole group.
state.localCalendars.filter(c => c.group).forEach(cal => {
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
name: cal.name, color: cal.color,
enabled: cal.owned !== false ? cal.enabled : !hiddenLocal.has(cal.id),
reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false,
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
});
@@ -984,7 +986,11 @@ function renderCalendarList() {
}
if (!cal) return;
const newVal = cal.reminders_enabled === false; // toggle
const path = source === 'ical'
// local has a dedicated endpoint so share recipients can mute for
// themselves; the others still take the calendar-wide PUT.
const path = source === 'local'
? `/local/calendars/${id}/reminders`
: source === 'ical'
? `/ical/subscriptions/${id}`
: `/${source}/calendars/${id}`;
try {
@@ -4377,7 +4383,7 @@ function initResizableCalTable(table) {
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
// / Export-Import / Sync / trash); user drags override and persist.
const DEFAULTS = [200, 90, 80, 150, 240, 90, 44];
const DEFAULTS = [200, 90, 80, 150, 300, 90, 44];
ths.forEach((th, i) => {
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
: (DEFAULTS[i] || th.offsetWidth || 100);
@@ -4432,7 +4438,7 @@ function renderCalendarTable() {
const hid = (src, id, isVisible) =>
`<button class="icon-btn mini-btn ct-eye" data-ct-hid="${src}" data-ct-id="${id}" data-ct-visible="${isVisible ? '1' : '0'}" title="${isVisible ? 'Ausblenden' : 'Anzeigen'}">${isVisible ? EYE_ON : EYE_OFF}</button>`;
const rem = (src, id, on) =>
`<button class="icon-btn mini-btn ct-bell" data-ct-rem="${src}" data-ct-id="${id}" data-ct-on="${on ? '1' : '0'}" title="${on ? 'Benachrichtigungen aus' : 'Benachrichtigungen an'}">${on ? BELL_ON : BELL_OFF}</button>`;
`<button class="icon-btn mini-btn ct-bell" data-ct-rem="${src}" data-ct-id="${id}" data-ct-on="${on ? '1' : '0'}" title="${on ? t('calendar_reminders_on') : t('calendar_reminders_off')}">${on ? BELL_ON : BELL_OFF}</button>`;
const dot = (color, fb) =>
`<span class="ct-dot" style="background:${color || fb}"></span>`;
@@ -4452,7 +4458,7 @@ function renderCalendarTable() {
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}" title="${pub ? t('caldav_unpublish') : t('caldav_publish')}">CalDAV</button>
</td>
<td>—</td>
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
@@ -4565,7 +4571,8 @@ function renderCalendarTable() {
rows += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
<td>${hid('shared', cal.id, !sharedHidden.has(cal.id))}</td><td>—</td><td>—</td><td>—</td><td>—</td>
<td>${hid('shared', cal.id, !sharedHidden.has(cal.id))}</td>
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td><td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
@@ -4577,7 +4584,7 @@ function renderCalendarTable() {
container.innerHTML = `<table class="cal-manage-table">
<thead><tr>
<th>Name</th><th>Herkunft</th><th>Sichtbar</th><th>Benachrichtigungen</th><th>Export / Import</th><th>Sync</th><th></th>
<th>Name</th><th>Herkunft</th><th>Sichtbar</th><th>${t('calendar_reminders_col')}</th><th>Export / Import</th><th>Sync</th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
@@ -4622,7 +4629,9 @@ function renderCalendarTable() {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctRem, id = parseInt(btn.dataset.ctId);
const enabled = btn.dataset.ctOn !== '1'; // currently on → turn off, currently off → turn on
const path = src === 'ical' ? `/ical/subscriptions/${id}` : `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
const path = src === 'local' ? `/local/calendars/${id}/reminders`
: src === 'ical' ? `/ical/subscriptions/${id}`
: `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
try {
await api.put(path, { reminders_enabled: enabled });
if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; }
@@ -4713,6 +4722,9 @@ function renderCalendarTable() {
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
cal.caldav_published = updated.caldav_published;
cal.caldav_url = updated.caldav_url;
// The response carries both URLs; dropping this one left the
// "with login" field blank until the next full page load.
cal.caldav_login_url = updated.caldav_login_url;
renderCalendarTable();
} catch (e) { showToast(e.message, true); }
});
@@ -4732,6 +4744,7 @@ function renderCalendarTable() {
try {
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
cal.caldav_url = updated.caldav_url;
cal.caldav_login_url = updated.caldav_login_url;
renderCalendarTable();
showToast(t('caldav_token_rotated'));
} catch (e) { showToast(e.message, true); }
+10
View File
@@ -202,6 +202,7 @@ const translations = {
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
calendar_reminders_on: 'Benachrichtigungen aktiviert',
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
calendar_reminders_col: 'Benachrichtigungen',
copy: 'Kopieren',
caldav_publish: 'CalDAV veröffentlichen',
caldav_unpublish: 'CalDAV deaktivieren',
@@ -596,6 +597,7 @@ const translations = {
reminders_calendar_off: 'Für dä Kalender sind Benachrichtigunge deaktiviert – Erinnerige werded nöd usgfüert.',
calendar_reminders_on: 'Benachrichtigunge aktiviert',
calendar_reminders_off: 'Benachrichtigunge deaktiviert',
calendar_reminders_col: 'Benachrichtigunge',
copy: 'Kopierä',
caldav_publish: 'CalDAV veröffentlichä',
caldav_unpublish: 'CalDAV deaktivierä',
@@ -1028,6 +1030,7 @@ const translations = {
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
calendar_reminders_on: 'Reminders enabled',
calendar_reminders_off: 'Reminders disabled',
calendar_reminders_col: 'Notifications',
copy: 'Copy',
caldav_publish: 'Publish via CalDAV',
caldav_unpublish: 'Unpublish CalDAV',
@@ -1422,6 +1425,7 @@ const translations = {
reminders_calendar_off: 'Les rappels sont désactivés pour cet agenda – ils ne se déclencheront pas.',
calendar_reminders_on: 'Rappels activés',
calendar_reminders_off: 'Rappels désactivés',
calendar_reminders_col: 'Notifications',
copy: 'Copier',
caldav_publish: 'Publier via CalDAV',
caldav_unpublish: 'Dépublier CalDAV',
@@ -1816,6 +1820,7 @@ const translations = {
reminders_calendar_off: 'I promemoria sono disattivati per questo calendario – non verranno attivati.',
calendar_reminders_on: 'Promemoria attivati',
calendar_reminders_off: 'Promemoria disattivati',
calendar_reminders_col: 'Notifiche',
copy: 'Copia',
caldav_publish: 'Pubblica via CalDAV',
caldav_unpublish: 'Annulla pubblicazione CalDAV',
@@ -2210,6 +2215,7 @@ const translations = {
reminders_calendar_off: 'Herinneringen zijn uitgeschakeld voor deze agenda – ze worden niet geactiveerd.',
calendar_reminders_on: 'Herinneringen ingeschakeld',
calendar_reminders_off: 'Herinneringen uitgeschakeld',
calendar_reminders_col: 'Meldingen',
copy: 'Kopiëren',
caldav_publish: 'Publiceren via CalDAV',
caldav_unpublish: 'CalDAV-publicatie stoppen',
@@ -2604,6 +2610,7 @@ const translations = {
reminders_calendar_off: 'Påmindelser er slået fra for denne kalender – de bliver ikke udløst.',
calendar_reminders_on: 'Påmindelser slået til',
calendar_reminders_off: 'Påmindelser slået fra',
calendar_reminders_col: 'Notifikationer',
copy: 'Kopiér',
caldav_publish: 'Udgiv via CalDAV',
caldav_unpublish: 'Stop CalDAV-udgivelse',
@@ -2998,6 +3005,7 @@ const translations = {
reminders_calendar_off: 'Påminnelser er slått av for denne kalenderen – de blir ikke utløst.',
calendar_reminders_on: 'Påminnelser slått på',
calendar_reminders_off: 'Påminnelser slått av',
calendar_reminders_col: 'Varsler',
copy: 'Kopier',
caldav_publish: 'Publiser via CalDAV',
caldav_unpublish: 'Stopp CalDAV-publisering',
@@ -3392,6 +3400,7 @@ const translations = {
reminders_calendar_off: 'Påminnelser är avstängda för den här kalendern – de kommer inte att utlösas.',
calendar_reminders_on: 'Påminnelser aktiverade',
calendar_reminders_off: 'Påminnelser avstängda',
calendar_reminders_col: 'Aviseringar',
copy: 'Kopiera',
caldav_publish: 'Publicera via CalDAV',
caldav_unpublish: 'Sluta publicera via CalDAV',
@@ -3786,6 +3795,7 @@ const translations = {
reminders_calendar_off: 'Muistutukset on poistettu käytöstä tässä kalenterissa – niitä ei laukaista.',
calendar_reminders_on: 'Muistutukset käytössä',
calendar_reminders_off: 'Muistutukset pois käytöstä',
calendar_reminders_col: 'Ilmoitukset',
copy: 'Kopioi',
caldav_publish: 'Julkaise CalDAV:n kautta',
caldav_unpublish: 'Lopeta CalDAV-julkaisu',
@@ -1084,6 +1084,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an.";
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -1136,6 +1137,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an.";
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -17,12 +17,12 @@
<key>CalendarrWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>CalendarrWatchComplications.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>2</integer>
</dict>
<key>CalendarrWatchExtension.xcscheme_^#shared#^_</key>
<dict>
@@ -32,7 +32,7 @@
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>3</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
@@ -111,6 +111,16 @@ struct CalDAVCalendar: Codable, Identifiable {
case sidebarHidden = "sidebar_hidden"
case remindersEnabled = "reminders_enabled"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
}
}
struct LocalCalendar: Codable, Identifiable {
@@ -161,12 +171,27 @@ struct ICalSubscription: Codable, Identifiable {
var refreshMinutes: Int
var lastFetched: String?
var remindersEnabled: Bool?
var sidebarHidden: Bool = false
enum CodingKeys: String, CodingKey {
case id, name, url, color, enabled
case refreshMinutes = "refresh_minutes"
case lastFetched = "last_fetched"
case remindersEnabled = "reminders_enabled"
case sidebarHidden = "sidebar_hidden"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#4285f4"
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
refreshMinutes = try c.decodeIfPresent(Int.self, forKey: .refreshMinutes) ?? 60
lastFetched = try c.decodeIfPresent(String.self, forKey: .lastFetched)
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
}
}
@@ -189,6 +214,16 @@ struct GoogleCalendar: Codable, Identifiable {
case sidebarHidden = "sidebar_hidden"
case remindersEnabled = "reminders_enabled"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
}
}
struct HomeAssistantAccount: Codable, Identifiable {
@@ -202,6 +237,15 @@ struct HomeAssistantAccount: Codable, Identifiable {
case id, name, url, calendars
case authMethod = "auth_method"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Home Assistant"
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
authMethod = try c.decodeIfPresent(String.self, forKey: .authMethod) ?? "token"
calendars = try c.decodeIfPresent([HACalendar].self, forKey: .calendars)
}
}
struct HACalendar: Codable, Identifiable {
+76 -12
View File
@@ -1,6 +1,8 @@
import Foundation
import SwiftUI
import CalendarrCore
// loadWritableCalendars reads EKCalendar.title/.calendarIdentifier directly.
import EventKit
extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from
@@ -44,6 +46,12 @@ struct WritableCalendar: Identifiable {
let color: String
let source: String
let numericId: Int
/// EventKit calendar identifier; set only for `source == "device"`, whose
/// ids are strings rather than server row ids.
var deviceId: String? = nil
/// The id as it appears in a calendar key, whichever kind it is.
var calendarIdString: String { deviceId ?? String(numericId) }
}
@Observable
@@ -68,6 +76,10 @@ class CalendarStore {
var syncErrors: [SyncError] = []
var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = []
/// Why a source contributed nothing, keyed by source name. Empty when every
/// source answered. Surfaced in the event editor, because an empty picker
/// with no explanation is indistinguishable from "you own no calendars".
var writableCalendarErrors: [String: String] = [:]
// When set, the calendar shows the group's combined overlay instead of the
// user's own events. nil = personal view.
var activeGroup: CalGroup? = nil
@@ -283,7 +295,8 @@ class CalendarStore {
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
let (caldav, google, ha) = await (c, g, h)
async let i = (try? await api.getICalSubscriptions()) ?? []
let (caldav, google, ha, ical) = await (c, g, h, i)
var b = banishedCalendarKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
@@ -293,6 +306,7 @@ class CalendarStore {
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
for sub in ical { applyServerHidden("ical", sub.id, sub.sidebarHidden) }
guard b != banishedCalendarKeys else { return false }
banishedCalendarKeys = b
@@ -346,7 +360,7 @@ class CalendarStore {
}
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant", "ical"]
var userCalendar: Calendar {
var cal = Calendar.current
@@ -414,8 +428,13 @@ class CalendarStore {
/// Load events for a specific range. Skips the network if already cached,
/// unless `force` is set (used after create/edit to pull fresh server data
/// for the visible range, bypassing the cache).
/// Device-calendar generation the cache was filled at (see DeviceCalendarService).
private var deviceGeneration = DeviceCalendarService.generation
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
if !force, isCached(start: start, end: end) {
let deviceChanged = deviceGeneration != DeviceCalendarService.generation
if deviceChanged { deviceGeneration = DeviceCalendarService.generation }
if !force, !deviceChanged, isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}
@@ -447,7 +466,11 @@ class CalendarStore {
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
return (combined.map { decorateGroupEvent($0) }, [])
}
return try await api.fetchEvents(start: start, end: end)
let (server, errors) = try await api.fetchEvents(start: start, end: end)
// Device calendars live on the phone, so they are merged in here rather
// than coming from the server. They are deliberately absent from the
// group overlay, which is a server-side combined view.
return (server + DeviceCalendarService.shared.events(start: start, end: end), errors)
}
/// Prefix a combined-view event with its owner (others) or 👥 + creator
@@ -508,6 +531,14 @@ class CalendarStore {
allCachedEvents = []
}
/// Force the next fetch to reach the server, without emptying what is on
/// screen. `invalidateCache()` drops the events too, which makes the whole
/// calendar blink empty and refill — far too much for one new event.
func invalidateRange() {
cachedStart = nil
cachedEnd = nil
}
/// Calendars / sources that reported a sync error on the last fetch. Events
/// matching either must NOT be evicted from the cache on a partial sync —
/// stale data beats a completely empty calendar.
@@ -634,29 +665,62 @@ class CalendarStore {
// MARK: – Writable calendars
func loadWritableCalendars(api: CalendarrAPI) async {
async let localCals = (try? await api.getLocalCalendars()) ?? []
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
async let haCals = (try? await api.getHACalendars()) ?? []
// Each source is fetched independently and reports its own failure.
// `try?` used to discard all four, so a source that errored looked
// exactly like a source with nothing in it — which is how a missing
// Home Assistant calendar can go unexplained for a long time.
var errors: [String: String] = [:]
async let localResult = Self.attempt("local") { try await api.getLocalCalendars() }
async let caldavResult = Self.attempt("caldav") { try await api.getCalDAVAccounts() }
async let googleResult = Self.attempt("google") { try await api.getGoogleCalendars() }
async let haResult = Self.attempt("homeassistant"){ try await api.getHACalendars() }
let (localCals, localErr) = await localResult
let (caldavAccs, caldavErr) = await caldavResult
let (googleCals, googleErr) = await googleResult
let (haCals, haErr) = await haResult
for (source, message) in [("local", localErr), ("caldav", caldavErr),
("google", googleErr), ("homeassistant", haErr)] {
if let message { errors[source] = message }
}
var result: [WritableCalendar] = []
// Skip read-only shared calendars — offering them in the event editor
// only leads to a 403 on save. Own + read_write (incl. group) stay.
for cal in await localCals where cal.owned || cal.permission == "read_write" {
for cal in localCals where cal.owned || cal.permission == "read_write" {
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
}
for acc in await caldavAccs where acc.enabled {
for acc in caldavAccs where acc.enabled {
for cal in acc.calendars ?? [] where cal.enabled {
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
}
}
for (email, id, name, color) in await googleCals {
for (email, id, name, color) in googleCals {
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
}
for (accName, id, name, color) in await haCals {
for (accName, id, name, color) in haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
}
for cal in DeviceCalendarService.shared.calendars() where cal.allowsContentModifications {
result.append(WritableCalendar(
id: "device-\(cal.calendarIdentifier)",
name: cal.title,
color: "#8E8E93",
source: DeviceCalendarService.source,
numericId: 0,
deviceId: cal.calendarIdentifier))
}
writableCalendars = result
writableCalendarErrors = errors
}
/// Run one source fetch, returning its result or the reason it produced
/// nothing. Never throws: one broken source must not empty the picker.
private static func attempt<T>(_ source: String,
_ work: () async throws -> [T]) async -> ([T], String?) {
do { return (try await work(), nil) }
catch { return ([], error.localizedDescription) }
}
// MARK: – Query helpers
@@ -297,6 +297,10 @@ private let strings: [String: [String: String]] = [
"login.totp_placeholder": "6-stelliger Code",
"login.remember": "Angemeldet bleiben",
"login.signin": "Anmelden",
"device.calendars.header": "Gerätekalender",
"device.calendars.enable": "Kalender des iPhones anzeigen",
"device.calendars.desc": "Zeigt iCloud-, Outlook- und andere Kalender, die auf diesem iPhone eingerichtet sind. Sie bleiben auf dem Gerät und werden nicht an den Server übertragen — im Web und auf anderen Geräten erscheinen sie deshalb nicht.",
"device.calendars.denied": "Kein Zugriff auf die Kalender. Erlaube ihn in den Systemeinstellungen unter Datenschutz → Kalender.",
"login.choose_server": "Anderen Server wählen",
// Single sign-on (OpenID Connect)
"sso.or": "oder",
@@ -694,6 +698,10 @@ private let strings: [String: [String: String]] = [
"login.totp_placeholder": "6-digit code",
"login.remember": "Stay signed in",
"login.signin": "Sign in",
"device.calendars.header": "Device calendars",
"device.calendars.enable": "Show this iPhone's calendars",
"device.calendars.desc": "Shows iCloud, Outlook and other calendars set up on this iPhone. They stay on the device and are never sent to the server, so they do not appear on the web or on your other devices.",
"device.calendars.denied": "No calendar access. Grant it in System Settings under Privacy → Calendars.",
"login.choose_server": "Choose another server",
// Single sign-on (OpenID Connect)
"sso.or": "or",
@@ -517,6 +517,7 @@ class CalendarrAPI {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
default: return
}
_ = try await request(path, method: "PUT",
@@ -531,7 +532,7 @@ class CalendarrAPI {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
case "local": path = "/api/local/calendars/\(calendarId)"
case "local": path = "/api/local/calendars/\(calendarId)/reminders"
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
default: return
}
@@ -0,0 +1,170 @@
import EventKit
import Foundation
import UIKit
/// The calendars iOS itself manages — iCloud, Outlook/Exchange, Google — read
/// and written through EventKit.
///
/// These never reach the Calendarr server: they live on the device, so they
/// show up in this app only. That is the point. Publishing an Outlook calendar
/// is often blocked by company policy, and the phone already has the account.
///
/// Off until the user turns it on in Accounts, so nobody is asked for calendar
/// access they did not ask for.
@MainActor
final class DeviceCalendarService {
static let shared = DeviceCalendarService()
/// `CalEvent.source` for device events; also the prefix of their calendar key.
static let source = "device"
static let enabledKey = "deviceCalendarsEnabled"
private let store = EKEventStore()
private init() {}
// MARK: - Access
static var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: enabledKey) }
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
}
var hasAccess: Bool {
EKEventStore.authorizationStatus(for: .event) == .fullAccess
}
/// True once the user granted access. Write access needs full access —
/// `.writeOnly` would let us add events but never show them.
@discardableResult
func requestAccess() async -> Bool {
if hasAccess { return true }
let granted = (try? await store.requestFullAccessToEvents()) ?? false
if granted { Self.generation += 1 }
return granted
}
/// Both switches on: the user enabled the feature *and* granted access.
var isActive: Bool { Self.isEnabled && hasAccess }
/// Bumped when access is granted. The event cache was filled before the
/// device calendars existed, so the store treats a changed generation as a
/// cache miss — otherwise the new calendars would only turn up after a
/// restart. Cheaper than threading the store through the accounts screen.
private(set) static var generation = 0
// MARK: - Reading
/// Every calendar the device knows, writable ones included.
func calendars() -> [EKCalendar] {
guard isActive else { return [] }
return store.calendars(for: .event)
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
func calendar(id: String) -> EKCalendar? {
guard isActive else { return nil }
return store.calendar(withIdentifier: id)
}
/// Device events in the range, mapped onto the app's own event type so the
/// existing views, filters and ordering treat them like any other source.
func events(start: Date, end: Date) -> [CalEvent] {
guard isActive else { return [] }
let cals = store.calendars(for: .event)
guard !cals.isEmpty else { return [] }
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: cals)
return store.events(matching: predicate).compactMap(Self.map)
}
private static func map(_ ek: EKEvent) -> CalEvent? {
// A recurring event's occurrences share one eventIdentifier, so append
// the start date to keep SwiftUI's per-event identity unique.
guard let ident = ek.eventIdentifier,
let start = ek.startDate, let end = ek.endDate else { return nil }
let cal = ek.calendar
return CalEvent(
id: "\(ident)|\(Int(start.timeIntervalSince1970))",
url: "",
title: ek.title ?? "",
startDate: start,
endDate: end,
isAllDay: ek.isAllDay,
location: ek.location ?? "",
notes: ek.notes ?? "",
color: hex(cal?.cgColor),
calendarId: cal?.calendarIdentifier ?? "",
calendarName: cal?.title ?? "",
calendarColor: hex(cal?.cgColor) ?? "#8E8E93",
source: source,
readOnly: !(cal?.allowsContentModifications ?? false)
)
}
/// The EKEvent behind one of our composite ids (see `map`).
private func ekEvent(for calEventId: String) -> EKEvent? {
let ident = calEventId.split(separator: "|").first.map(String.init) ?? calEventId
return store.event(withIdentifier: ident)
}
// MARK: - Writing
func create(calendarId: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, notes: String) throws {
guard let cal = store.calendar(withIdentifier: calendarId) else {
throw DeviceCalendarError.calendarGone
}
let ev = EKEvent(eventStore: store)
ev.calendar = cal
apply(ev, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, notes: notes)
try store.save(ev, span: .thisEvent, commit: true)
}
func update(eventId: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, notes: String) throws {
guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone }
apply(ev, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, notes: notes)
// .thisEvent on a recurring series edits only the occurrence the user
// opened, which is what tapping a single day implies.
try store.save(ev, span: .thisEvent, commit: true)
}
func delete(eventId: String) throws {
guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone }
try store.remove(ev, span: .thisEvent, commit: true)
}
private func apply(_ ev: EKEvent, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, notes: String) {
ev.title = title
ev.startDate = start
ev.endDate = end
ev.isAllDay = isAllDay
ev.location = location.isEmpty ? nil : location
ev.notes = notes.isEmpty ? nil : notes
}
// MARK: - Helpers
private static func hex(_ color: CGColor?) -> String? {
guard let color, let c = UIColor(cgColor: color).cgColor.components, c.count >= 3 else { return nil }
return String(format: "#%02X%02X%02X",
Int((c[0] * 255).rounded()),
Int((c[1] * 255).rounded()),
Int((c[2] * 255).rounded()))
}
}
enum DeviceCalendarError: LocalizedError {
case calendarGone
case eventGone
var errorDescription: String? {
switch self {
case .calendarGone: return "Dieser Gerätekalender existiert nicht mehr."
case .eventGone: return "Dieser Termin existiert auf dem Gerät nicht mehr."
}
}
}
@@ -35,8 +35,13 @@ enum NotificationScheduler {
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
if disabledCalendarKeys.contains(key) { continue }
}
// Device calendars are already served by the system Calendar
// app, so applying our default here notifies twice for one
// event. They stay silent unless the event carries its own
// reminder; the per-calendar toggle can still switch them on.
let allowsDefault = defaultMin >= 0 && ev.source != "device"
let offsets = ev.reminders.isEmpty
? (defaultMin >= 0 ? [defaultMin] : [])
? (allowsDefault ? [defaultMin] : [])
: ev.reminders
for m in offsets {
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
@@ -1,5 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
import EventKit
struct AccountsView: View {
let api: CalendarrAPI
@@ -30,6 +31,8 @@ struct AccountsView: View {
@State private var birthdayNotify = -1
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage(DeviceCalendarService.enabledKey) private var deviceCalendarsEnabled = false
@State private var deviceCalendarAccessGranted = false
var body: some View {
NavigationStack {
@@ -42,6 +45,7 @@ struct AccountsView: View {
caldavSection
localSection
birthdayContactsSection
deviceCalendarSection
icalSection
googleSection
haSection
@@ -235,6 +239,30 @@ struct AccountsView: View {
localCalendars.first { $0.isBirthday && $0.owned }
}
/// Calendars the phone already has (iCloud, Outlook/Exchange, Google).
/// They stay on the device — the server never sees them — so this is the
/// way in when publishing an Outlook calendar is blocked by company policy.
@ViewBuilder var deviceCalendarSection: some View {
Section {
Toggle(L10n.t("device.calendars.enable", appLang), isOn: $deviceCalendarsEnabled)
if deviceCalendarsEnabled && !deviceCalendarAccessGranted {
Text(L10n.t("device.calendars.denied", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text(L10n.t("device.calendars.header", appLang))
} footer: {
Text(L10n.t("device.calendars.desc", appLang))
}
.onChange(of: deviceCalendarsEnabled) { _, on in
guard on else { return }
// Granting access bumps the service generation, which makes the
// calendar reload instead of serving its pre-device cache.
Task { deviceCalendarAccessGranted = await DeviceCalendarService.shared.requestAccess() }
}
.task { deviceCalendarAccessGranted = DeviceCalendarService.shared.hasAccess }
}
@ViewBuilder var birthdayContactsSection: some View {
Section {
if let cal = birthdayCalendar {
@@ -464,7 +464,7 @@ struct CalendarHostView: View {
showNewBirthday: $showNewBirthday,
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
reloadForce: { await reloadAfterSave() })
}
// MARK: – Loading logic
@@ -525,11 +525,15 @@ struct CalendarHostView: View {
await store.loadEvents(api: api, start: s, end: e)
}
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
/// created/edited event shows up immediately (the server is authoritative).
private func reloadVisible(force: Bool) async {
/// After creating or editing: refetch the visible range from the server,
/// keeping the current events on screen while it happens. Clearing only the
/// range — not the events — is what separates this from the menu's manual
/// sync, which is allowed to blank the calendar because the user asked for
/// a full refresh.
private func reloadAfterSave() async {
store.invalidateRange()
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e, force: force)
await store.loadEvents(api: api, start: s, end: e, force: true)
}
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
@@ -1,4 +1,5 @@
import SwiftUI
import EventKit
/// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance
/// on a stdlib type would be visible module-wide, so wrap it instead.
@@ -264,6 +265,8 @@ struct EventDetailSheet: View {
// calendarId looks like "homeassistant-42" → numeric DB id 42
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
case DeviceCalendarService.source:
try DeviceCalendarService.shared.delete(eventId: event.id)
default:
let calId = Int(event.calendarId)
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
@@ -1,5 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
import EventKit
struct EventEditorSheet: View {
let api: CalendarrAPI
@@ -35,6 +36,18 @@ struct EventEditorSheet: View {
private var isEditing: Bool { editingEvent != nil }
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
/// Sources that failed to answer, named instead of silently leaving their
/// calendars out of the picker.
@ViewBuilder
private var sourceErrorRows: some View {
let failures = store.writableCalendarErrors.sorted { $0.key < $1.key }
ForEach(failures, id: \.key) { failure in
Label("\(failure.key): \(failure.value)", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.font(.caption)
}
}
private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId }
}
@@ -44,7 +57,7 @@ struct EventEditorSheet: View {
/// controls and explain that they won't fire.
private var remindersDisabled: Bool {
guard let cal = selectedCal else { return false }
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
let key = CalendarStore.calendarKey(source: cal.source, calendarId: cal.calendarIdString)
return store.reminderDisabledKeys.contains(key)
}
@@ -80,7 +93,9 @@ struct EventEditorSheet: View {
Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary)
.font(.callout)
} else {
}
sourceErrorRows
if !store.writableCalendars.isEmpty {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
@@ -122,7 +137,7 @@ struct EventEditorSheet: View {
}
.disabled(remindersDisabled)
Section(L10n.t("event.attachments", appLang)) {
Section {
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
HStack {
Image(systemName: att.symbolName).foregroundStyle(.secondary)
@@ -156,6 +171,8 @@ struct EventEditorSheet: View {
Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip")
}
.disabled(attachmentSlotsUsed >= 10)
} header: {
Text(L10n.t("event.attachments", appLang))
} footer: {
Text(L10n.t("event.attachment_hint", appLang))
}
@@ -277,6 +294,8 @@ struct EventEditorSheet: View {
if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
selectedCalendarId = "ha-\(num)"
} else if ev.source == DeviceCalendarService.source {
selectedCalendarId = "device-\(ev.calendarId)"
} else {
selectedCalendarId = ev.calendarId
}
@@ -331,6 +350,10 @@ struct EventEditorSheet: View {
try await api.createHAEvent(calendarId: haCalId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
case DeviceCalendarService.source:
try DeviceCalendarService.shared.update(
eventId: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, notes: notes)
default: // caldav
let calId = Int(ev.calendarId)
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
@@ -354,6 +377,10 @@ struct EventEditorSheet: View {
try await api.createHAEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
case DeviceCalendarService.source:
try DeviceCalendarService.shared.create(
calendarId: cal.calendarIdString, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, notes: notes)
default: // caldav
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
@@ -1,4 +1,5 @@
import SwiftUI
import EventKit
/// A single calendar row in the flat, reorderable list.
private struct CalRow: Identifiable {
@@ -87,6 +88,8 @@ struct CalendarFilterContent: View {
@ViewBuilder
private func row(_ cal: CalRow) -> some View {
let isVisible = !hidden.contains(cal.id)
let muted = reminderDisabled.contains(cal.id)
HStack(spacing: 8) {
Button {
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
store.setCalendarHidden(cal.id, hidden: isVisible)
@@ -103,23 +106,26 @@ struct CalendarFilterContent: View {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if reminderDisabled.contains(cal.id) {
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
// A visible control, the way Android already does it. The mute used
// to live only behind a long press, with no hint it existed until
// after it had been switched on.
Button {
toggleReminders(forKey: cal.id)
} label: {
let disabled = reminderDisabled.contains(cal.id)
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
systemImage: disabled ? "bell" : "bell.slash")
Image(systemName: muted ? "bell.slash" : "bell")
.foregroundStyle(muted ? .secondary : Color.accentColor)
}
.buttonStyle(.borderless)
.accessibilityLabel(L10n.t(muted ? "filter.reminders_on" : "filter.reminders_off", appLang))
}
.contextMenu {
Button(role: .destructive) {
hidden.remove(cal.id)
banished.insert(cal.id)
@@ -213,6 +219,7 @@ struct CalendarFilterContent: View {
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
for sub in icalSubs { applyServerHidden("ical", sub.id, sub.sidebarHidden) }
store.setBanishedCalendars(b)
banished = b
@@ -225,8 +232,16 @@ struct CalendarFilterContent: View {
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
store.setReminderDisabledKeys(rd)
reminderDisabled = rd
// Only server-backed sources are reconciled here. Replacing the whole
// set would drop every device-calendar mute — and the mute of any
// calendar the server happened not to return on this call.
let serverSources: Set<String> = ["local", "caldav", "ical", "google", "homeassistant"]
let keptLocally = store.reminderDisabledKeys.filter { key in
!serverSources.contains(key.split(separator: ":").first.map(String.init) ?? "")
}
let merged = rd.union(keptLocally)
store.setReminderDisabledKeys(merged)
reminderDisabled = merged
// Build the flat row list (banished excluded), then sort by stored order.
var combined: [CalRow] = []
@@ -243,6 +258,14 @@ struct CalendarFilterContent: View {
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
// Device calendars key on an EventKit identifier, not a server row id.
for cal in DeviceCalendarService.shared.calendars() {
let key = CalendarStore.calendarKey(source: DeviceCalendarService.source,
calendarId: cal.calendarIdentifier)
if b.contains(key) { continue }
combined.append(CalRow(id: key, name: cal.title, colorHex: "#8E8E93",
readOnly: !cal.allowsContentModifications))
}
allKeys = Set(combined.map(\.id))
let orderedKeys = store.ordered(combined.map(\.id))