Compare commits

..
33 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 a67602dbc2 Configure the invitation mailbox in the UI, not the environment
Putting IMAP settings in environment variables was my call to make and it was
the wrong one: it meant editing a file on the server and restarting to change a
folder name, and it was never offered as a choice.

The mailbox is now a row in mailbox_accounts, edited under Settings → Admin:
host, user, password, port, folder, interval, on/off. Administrators only — it
is a shared forwarding target, not a per-user account. The password is never
returned by any endpoint, the same as the CalDAV account routes, and leaving
the field empty on edit keeps the stored one so changing a port does not mean
retyping it. The panel shows when the mailbox was last read and what went wrong
if it did, because a mailbox that quietly stopped working looks exactly like
one that has no mail.

Worth stating plainly: the password now lives in the database, in clear text,
like the CalDAV, Google and Home Assistant credentials beside it. Unlike those
three, an IMAP password opens an entire mailbox.

The scheduler job is registered unconditionally now — whether a mailbox exists
can change without a restart, and fetch_invites returns immediately when none
does. test_scheduler knows about it.

Also adds the requests list to the sidebar with accept and decline, 26
translation keys across all ten languages, and tests covering that the password
is never returned, that an edit without one keeps it, and that a disabled
mailbox counts as unconfigured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:31:56 +01:00
ScarriffleandClaude Opus 5 9bf7d3797d Take in invitations that arrive by mail
A watched mailbox is a trust boundary this app did not have: anyone who knows
the address can hand the server an iCalendar file. So a mail can do exactly one
thing — create a row in event_invites. No calendar read path touches that
table, and an invitation becomes an event only when a person accepts it.

Routing is by the ATTENDEE address, never the From header, which a sender can
claim freely. Addresses are matched against users.email plus a dedicated table,
so forwarded mail still finds its owner: the invitation names the original
address, not the mailbox it landed in.

An accepted invitation gets a fresh UID; the organiser's goes to external_uid.
LocalEvent.uid is globally unique, so reusing it would let a stranger's mail
collide with an event already there. That case has a test, because it is the
one that would be a real problem.

parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list
and RECURRENCE-ID. All additive — existing callers read the keys they knew.
METHOD sits on the VCALENDAR, so it is read outside the component loop; that is
the only reliable way to tell an invitation from a plain calendar file. A VEVENT
carrying RECURRENCE-ID is skipped rather than applied, since treating one
occurrence as the series would move every other date.

The mailbox job registers only when IMAP_HOST is set: an unconditional job would
log every few minutes that it has nothing to do, and test_scheduler asserts the
exact job set. Credentials come from the environment, which keeps the one secret
that opens a whole mailbox out of a table an endpoint could return.

Replies to the organiser are the next step; accepting and declining work without
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:16:25 +01:00
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 8bc32faa7f fix(theme): Farben erben wieder den Standard statt ihn einzufrieren
Fuenf Farbspalten trugen einen fest eingebauten Vorgabewert — primary_color
#4285f4, accent_color #ea4335, today_color #4285f4, month_divider_color und
month_label_color #7090c0. Damit hatte *jeder* Nutzer immer eine eigene
Farbe, und der Fallback settings.X || baseColor(X) kam nie zum Zug. Das
Admin-Versprechen "Gilt fuer alle Nutzer ohne eigene Farbe" war fuer diese
fuenf unerfuellbar, und die Werte widersprachen DEFAULT_COLORS im Frontend,
das laut Kommentar die einzige Quelle sein soll.

Das betraf nicht nur Altbestand: UserSettings wird ohne Farbwerte angelegt,
also bekam auch jeder heute neu angelegte Nutzer das Blau.

- models.py: die fuenf Spalten sind nullable, NULL heisst "erbt".
- settings_router: NULL wird ausgeliefert statt durch einen Standard ersetzt,
  und ein explizites null wird gespeichert (NULLABLE_OVERRIDES).
- main.py: einmalige Migration setzt genau die alten Vorgabewerte auf NULL.
  Bewusst gewaehlte Farben bleiben unangetastet.
- calendar.js: eine erbende Zeile merkt sich das und sendet beim Speichern
  null. "Zurueck" heisst wieder erben, statt den aktuellen Standard in die
  Nutzerzeile zu kopieren — sonst erreichen spaetere Aenderungen des
  Standards den Nutzer nie mehr.

Verifiziert: frische Installation liefert --primary #58B900 statt #4285f4;
zurueckgeschriebenes Alt-Blau wird beim Neustart geleert; eine eigene Farbe
(#FF00AA) ueberlebt die Migration. Suite 129 gruen, davon 5 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:44:25 +02:00
ScarriffleandClaude Opus 5 aaecfc435d fix(theme): Theme aus den wirksamen statt den rohen Einstellungen anwenden
initCalendar baute mit mergeEffective() korrekt die wirksamen Einstellungen
zusammen, benutzte danach aber die rohe Server-Antwort zum Anwenden:

  applyTheme(settings) statt applyTheme(state.settings)

Fuer jede Einstellung, deren Sync-Flag aus ist, ist der wirksame Wert laut
effectiveValue() der browser-lokale — der wurde damit stillschweigend
ignoriert. Die Einstellungstabelle zeigte also einen Wert an, waehrend die
Oberflaeche einen anderen benutzte.

Derselbe Fehler bei setLang: die Zeile mit dem wirksamen Wert wurde wenige
Zeilen spaeter vom rohen Server-Wert ueberschrieben. Die doppelte Zuweisung
faellt weg, die richtige bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:34:55 +02:00
ScarriffleandClaude Opus 5 6b7317b4e0 docs: Design-Vertrag fuer Web, iOS und Android
Unterhalb der Farbebene war nie etwas einmal entschieden worden: Android
hat 40 verschiedene Abstandswerte, 9 Schriftgroessen, 9 Radien und vier
verschiedene Seitenraender; das Web streut aehnlich. Deshalb springt der
Textrand beim Screenwechsel und dieselbe Termin-Pille hat vier Radien.

Der Vertrag legt Abstandsleiter, Radien nach Rolle, Schriftskala,
Trefferflaechen, Komponenten-Inventar, Zustaende, Termin-Darstellung und
den gemeinsamen Widget-Zuschnitt fest -- nach dem Vorbild von
SETTINGS_SYNC.md, das denselben Zweck fuer die Einstellungen erfuellt.

Bewusst nicht vereinheitlicht: Navigation, Systemschrift, Bedienelemente
und Gesten. Gleiche Struktur und gleiche Masse, nicht gleiche Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:56:59 +02:00
ScarriffleandClaude Opus 5 cd6a4698aa fix(anhaenge): Anhang-Bereich ruhiger und im Stil der Eingabefelder
Die beiden Texte steckten in .form-hint -- eine spaetere Regel fuer den
SSO-Login macht daraus einen umrandeten Kasten in Primaerfarbe. Fuer eine
Warnung ist das richtig, fuer eine Bildunterschrift viel zu laut. Eigene
leise Klasse statt dessen.

"Datei hinzufuegen" sieht jetzt aus wie die Textfelder darueber: gleicher
Grund, Rahmen, Radius und Innenabstand, nur linksbuendig und in der
gedaempften Textfarbe.

Der Leerzustand "Keine Anhaenge" faellt weg -- die Erinnerungsliste macht
es genauso, und die Zeile unter dem Knopf sagt ohnehin, was hier hin kann.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:47:16 +02:00
ScarriffleandClaude Opus 5 a1f15e33aa feat(android): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment und StagedAttachment als
Modelle, fuenf Retrofit-Endpunkte plus Repository-Wrapper. Die
Event-Endpunkte liefern wie bisher den rohen Body und werden von Hand
geparst, passend zum iOS-Client.

Die Detailansicht zeigt die Liste und speichert eine Datei ueber SAF --
derselbe Zweischritt wie beim ICS-Export, damit weder ein FileProvider im
Manifest noch eine Speicherberechtigung noetig wird. Der Editor staged
neue Dateien und wendet sie erst beim Speichern an; createLocalEvent gibt
dafuer jetzt die neue UID zurueck.

Thumbnails werden mit BitmapFactory dekodiert statt eine Bildbibliothek
aufzunehmen: es sind serverseitig auf 320 px verkleinerte JPEGs, dafuer
lohnt Coil nicht.

strOrNull ist aus dem Companion von CalEvent auf Dateiebene gewandert,
damit EventAttachment es auch nutzen kann.

Bewusst TextOverflow.Ellipsis statt MiddleEllipsis: die Compose-BOM
2023.10.01 kennt letzteres noch nicht.

Nicht kompiliert: auf diesem Rechner gibt es weder JDK noch Android-SDK.
Muss auf einer Maschine mit beidem gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:39:16 +02:00
ScarriffleandClaude Opus 5 3b649ec310 feat(ios): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment als Modell, API-Methoden
fuer Liste, Upload, Download, Thumbnail und Loeschen. Der Multipart-Body
ist von importICS uebernommen.

Die Detailansicht laedt die Liste beim Oeffnen und teilt eine Datei ueber
das System-Share-Sheet; der Editor staged neue Dateien und wendet sie
erst nach dem Speichern an, weil ein neuer Termin vorher keine UID hat.

EventAttachment liegt bewusst in CalEvent.swift statt in einer eigenen
Datei: eine hier angelegte .swift-Datei waere in keinem Xcode-Target
Mitglied und wuerde schlicht nicht mitkompiliert -- das faellt erst auf
dem Mac auf.

Die URL fuer das Share-Sheet steckt in einem kleinen Identifiable-Wrapper,
weil URL das nicht ist und eine nachtraegliche Konformitaet auf einen
Stdlib-Typ modulweit sichtbar waere.

Nicht kompiliert: auf diesem Rechner gibt es kein Xcode. Muss auf dem Mac
gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:32:01 +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
74 changed files with 5135 additions and 161 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))
}
}
+21
View File
@@ -112,6 +112,7 @@ Configuration is done via environment variables. The `install.sh` script generat
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
| `MAIL_POLL_MINUTES` | `5` | How often the watched mailbox is read |
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
@@ -239,6 +240,26 @@ The server runs two periodic jobs in-process:
|---|---|---|
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
| `mail.invites` | 5 min | Reads the watched mailbox for invitations *(idle until one is configured)* |
#### Invitations by mail
With a mailbox configured, invitations sent to it appear under **Requests** and
become calendar entries only once someone accepts them. Nothing a mail contains
is written to a calendar on its own.
An invitation is matched to a user by its `ATTENDEE` address, never by the
sender — compared against that user's `email` plus any additional addresses.
That matters when mail is forwarded: the invitation still names the original
address, not the mailbox it arrived in, so put the address people actually
invite into the user's profile.
The mailbox is set up under **Settings → Invitations** by an administrator: IMAP
host, user, password, folder and how often to check. One mailbox per instance —
it is a shared forwarding target, not a per-user account. The password is stored
the same way CalDAV, Google and Home Assistant credentials are, and no endpoint
returns it. Worth knowing before you point this at a real account: unlike those
three, an IMAP password opens an entire mailbox.
Attachments are normally cleaned up the moment an event, calendar or user is
deleted; the sweep is the safety net for what gets past that — an external
@@ -11,6 +11,7 @@ import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
@@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
@@ -355,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()
@@ -437,9 +439,13 @@ class CalendarRepository @Inject constructor(
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
): String? = guarded {
val resp = api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
resp.ensureSuccess()
// The response is the full event dict; its "id" is the uid attachments
// are addressed by. Callers that don't need it simply ignore it.
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("id").ifBlank { null } }
.getOrNull()
}
suspend fun updateLocalEvent(
@@ -504,6 +510,50 @@ class CalendarRepository @Inject constructor(
return importIcs(calendarId, part)
}
// ---- Attachments ----
suspend fun listAttachments(uid: String): List<EventAttachment> = guarded {
val resp = api.listAttachments(uid)
resp.ensureSuccess()
val arr = JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.let { EventAttachment.fromJson(it)?.let(::add) }
}
}
}
/** Upload one file. The server sniffs the bytes, so the media type here is
* only about forming a valid multipart part. */
suspend fun uploadAttachment(uid: String, bytes: ByteArray, filename: String,
mimeType: String?): EventAttachment = guarded {
val body = bytes.toRequestBody((mimeType ?: "application/octet-stream").toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
val resp = api.uploadAttachment(uid, part)
resp.ensureSuccess()
EventAttachment.fromJson(JSONObject(resp.body()?.string() ?: "{}"))
?: error("Unerwartete Antwort")
}
suspend fun downloadAttachment(id: Int): ByteArray = guarded {
val resp = api.downloadAttachment(id)
resp.ensureSuccess()
resp.body()?.bytes() ?: ByteArray(0)
}
/** Thumbnail bytes, or null when there is none — a missing preview just
* means the row shows its type icon. */
suspend fun attachmentThumbnail(id: Int): ByteArray? = try {
val resp = api.attachmentThumbnail(id)
if (resp.isSuccessful) resp.body()?.bytes() else null
} catch (e: Exception) {
null
}
suspend fun deleteAttachment(id: Int): Unit = guarded {
api.deleteAttachment(id).ensureSuccess()
}
suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
val resp = api.exportCalendar(calendarId)
resp.ensureSuccess()
@@ -18,6 +18,7 @@ import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Streaming
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
@@ -86,6 +87,28 @@ interface CalendarrApi {
@GET("api/local/calendars/{id}/export")
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- Attachments ----
// Like the event endpoints, these return the raw body and are parsed by
// hand, mirroring the iOS client.
@GET("api/local/events/{uid}/attachments")
suspend fun listAttachments(@Path("uid") uid: String): Response<ResponseBody>
@Multipart
@POST("api/local/events/{uid}/attachments")
suspend fun uploadAttachment(@Path("uid") uid: String,
@Part part: MultipartBody.Part): Response<ResponseBody>
@Streaming
@GET("api/local/attachments/{id}")
suspend fun downloadAttachment(@Path("id") id: Int): Response<ResponseBody>
@GET("api/local/attachments/{id}/thumb")
suspend fun attachmentThumbnail(@Path("id") id: Int): Response<ResponseBody>
@DELETE("api/local/attachments/{id}")
suspend fun deleteAttachment(@Path("id") id: Int): Response<ResponseBody>
// ---- Groups ----
@GET("api/groups/")
@@ -169,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>
@@ -11,6 +11,77 @@ import java.time.Instant
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
data class EventPerson(val id: Int?, val displayName: String)
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON null
* value, which previously made every event blue (color "null" → unparseable
* → fallback) and showed "null" for empty location/notes.
*
* File-scope rather than inside CalEvent's companion, so EventAttachment can
* use it as well.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/**
* A file attached to an event. Mirrors iOS `EventAttachment`.
*/
data class EventAttachment(
val id: Int,
val filename: String,
val contentType: String,
val sizeBytes: Int,
val hasThumbnail: Boolean,
val uploadedBy: EventPerson? = null,
) {
val isImage: Boolean get() = contentType.startsWith("image/")
companion object {
fun fromJson(json: JSONObject): EventAttachment? {
val id = (json.opt("id") as? Number)?.toInt() ?: return null
val filename = json.strOrNull("filename") ?: return null
return EventAttachment(
id = id,
filename = filename,
contentType = json.strOrNull("content_type") ?: "application/octet-stream",
sizeBytes = json.optInt("size_bytes", 0),
hasThumbnail = json.optBoolean("has_thumb", false),
uploadedBy = json.optJSONObject("uploaded_by")?.let { obj ->
obj.strOrNull("display_name")?.let { name ->
EventPerson((obj.opt("id") as? Number)?.toInt(), name)
}
},
)
}
}
}
/**
* A file picked in the editor but not uploaded yet. The bytes are read at pick
* time (the content URI may not survive the save), and the event it belongs to
* may not exist yet.
*/
data class StagedAttachment(
val bytes: ByteArray,
val filename: String,
val mimeType: String?,
) {
// ByteArray uses identity equality, so a data class holding one needs these
// spelled out or two distinct picks of the same file compare unequal in a
// list diff.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is StagedAttachment) return false
return filename == other.filename && mimeType == other.mimeType &&
bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int =
31 * (31 * filename.hashCode() + (mimeType?.hashCode() ?: 0)) + bytes.contentHashCode()
}
data class CalEvent(
val id: String,
val url: String,
@@ -41,6 +112,10 @@ data class CalEvent(
// True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`.
val isBirthday: Boolean = false,
// How many files hang off this event. The list payload carries only the
// count; the files themselves are fetched when the detail screen opens.
// Absent on non-local sources and on busy-masked private events, so 0.
val attachmentCount: Int = 0,
) {
/**
* Title to render: the server-decorated one (birthday age, group prefix)
@@ -70,17 +145,6 @@ data class CalEvent(
return FALLBACK_PALETTE[idx]
}
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON
* null value, which previously made every event blue (color "null" →
* unparseable → fallback) and showed "null" for empty location/notes.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/** Parse a {id, display_name} person object (creator/owner). */
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
val obj = json?.optJSONObject(key) ?: return null
@@ -139,6 +203,7 @@ data class CalEvent(
} ?: emptyList(),
readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false),
attachmentCount = json.optInt("attachment_count", 0),
)
}
}
@@ -122,6 +122,8 @@ object L10n {
"event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?",
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
"event.attachments" to "Anhänge", "event.attachment_add" to "Datei hinzufügen",
"event.attachment_hint" to "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
"event.reminder_before" to "vorher",
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
@@ -318,6 +320,8 @@ object L10n {
"event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?",
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
"event.attachments" to "Attachments", "event.attachment_add" to "Add file",
"event.attachment_hint" to "Max. 10 MB per file, 10 files. PDF, images, text.",
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
"event.reminder_before" to "before",
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -156,6 +157,19 @@ fun CalendarScreen(
var viewMenuOpen by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
// Downloading an attachment is two steps, like the .ics export: fetch the
// bytes, then let the user choose where to put them. Going through SAF
// means no FileProvider entry and no storage permission.
var pendingAttachment by remember { mutableStateOf<ByteArray?>(null) }
val saveAttachment = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("*/*")
) { uri ->
val bytes = pendingAttachment
pendingAttachment = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
@@ -328,9 +342,23 @@ fun CalendarScreen(
) {
val ev = lastDetail
if (ev != null) {
val attachments by vm.attachments.collectAsState()
val attachmentThumbs by vm.attachmentThumbs.collectAsState()
LaunchedEffect(ev.id) { vm.loadAttachments(ev) }
EventDetailScreen(
event = ev,
currentUserId = vm.currentUserId,
attachments = attachments,
attachmentThumbs = attachmentThumbs,
onOpenAttachment = { att ->
// Fetch the bytes, then let the user pick where to save
// them — the same two-step the .ics export already uses,
// which needs no FileProvider entry in the manifest.
vm.downloadAttachment(att) { bytes ->
pendingAttachment = bytes
saveAttachment.launch(att.filename)
}
},
onClose = { detailEvent = null },
onEdit = {
detailEvent = null
@@ -349,14 +377,19 @@ fun CalendarScreen(
}
editor?.let { req ->
// The editor needs the event's current attachments to offer removing
// them; loading is the same call the detail screen makes.
LaunchedEffect(req.existing?.id) { req.existing?.let { vm.loadAttachments(it) } }
val editorAttachments by vm.attachments.collectAsState()
EventEditorSheet(
request = req,
writableCalendars = state.writableCalendars,
existingAttachments = if (req.existing?.source == "local") editorAttachments else emptyList(),
onDismiss = { editor = null },
defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed) { error ->
if (error == null) editor = null
}
},
@@ -1,5 +1,8 @@
package com.scarriffle.calendarr.ui.calendar
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
@@ -7,9 +10,11 @@ import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.SyncError
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,9 +22,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -483,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) {
@@ -493,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
}
@@ -731,20 +759,25 @@ class CalendarViewModel @Inject constructor(
color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
removedAttachmentIds: Set<Int> = emptySet(),
onResult: (String?) -> Unit,
) {
viewModelScope.launch {
val result = runCatching {
if (existing != null && existing.source == calendar.source) {
when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"local" -> {
repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
applyAttachmentChanges(existing.id, stagedAttachments, removedAttachmentIds)
}
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
}
} else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
}
}
result.onSuccess { afterMutation(); onResult(null) }
@@ -756,15 +789,73 @@ class CalendarViewModel @Inject constructor(
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
) {
when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"local" -> {
// Only now does a uid exist to attach the staged files to.
val uid = repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
if (uid != null) applyAttachmentChanges(uid, stagedAttachments, emptySet())
}
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
}
}
// ---- Attachments ----
// The event payload carries only a count, so the list is fetched when the
// detail screen opens. Thumbnails are small server-rendered JPEGs, decoded
// here rather than pulling in an image-loading dependency for them.
private val _attachments = MutableStateFlow<List<EventAttachment>>(emptyList())
val attachments: StateFlow<List<EventAttachment>> = _attachments.asStateFlow()
private val _attachmentThumbs = MutableStateFlow<Map<Int, ImageBitmap>>(emptyMap())
val attachmentThumbs: StateFlow<Map<Int, ImageBitmap>> = _attachmentThumbs.asStateFlow()
fun loadAttachments(event: CalEvent) {
clearAttachments()
if (event.source != "local" || event.attachmentCount <= 0) return
viewModelScope.launch {
val loaded = runCatching { repository.listAttachments(event.id) }.getOrDefault(emptyList())
_attachments.value = loaded
val thumbs = mutableMapOf<Int, ImageBitmap>()
for (att in loaded.filter { it.hasThumbnail }) {
val bytes = repository.attachmentThumbnail(att.id) ?: continue
val bitmap = withContext(Dispatchers.IO) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} ?: continue
thumbs[att.id] = bitmap.asImageBitmap()
}
_attachmentThumbs.value = thumbs
}
}
/** Applied after the event itself was saved; a failure leaves the event. */
private suspend fun applyAttachmentChanges(
uid: String,
staged: List<StagedAttachment>,
removedIds: Set<Int>,
) {
for (id in removedIds) runCatching { repository.deleteAttachment(id) }
for (file in staged) {
runCatching { repository.uploadAttachment(uid, file.bytes, file.filename, file.mimeType) }
}
}
fun clearAttachments() {
_attachments.value = emptyList()
_attachmentThumbs.value = emptyMap()
}
/** Download the bytes and hand them to the caller (which writes them via SAF). */
fun downloadAttachment(att: EventAttachment, onReady: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.downloadAttachment(att.id) }.onSuccess(onReady)
}
}
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
viewModelScope.launch {
val result = runCatching {
@@ -1,6 +1,8 @@
package com.scarriffle.calendarr.ui.event
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,8 +16,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
@@ -51,6 +58,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr
@@ -66,6 +74,9 @@ fun EventDetailScreen(
onEdit: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
attachments: List<EventAttachment> = emptyList(),
attachmentThumbs: Map<Int, ImageBitmap> = emptyMap(),
onOpenAttachment: (EventAttachment) -> Unit = {},
) {
val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) }
@@ -118,6 +129,59 @@ fun EventDetailScreen(
}
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
// Attachments exist on local events only; the payload carries
// just the count, so the list arrives once the screen is open.
if (event.source == "local" && event.attachmentCount > 0) {
Spacer(Modifier.height(12.dp))
Text(
tr("event.attachments"),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
attachments.forEach { att ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenAttachment(att) }
.padding(vertical = 8.dp),
) {
val thumb = attachmentThumbs[att.id]
if (thumb != null) {
Image(
bitmap = thumb,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp)),
)
} else {
Icon(
Icons.Filled.AttachFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp),
)
}
Spacer(Modifier.width(12.dp))
Text(
att.filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
formatAttachmentSize(att.sizeBytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(28.dp))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -166,3 +230,10 @@ private fun DetailRow(icon: ImageVector, text: String) {
Text(text, style = MaterialTheme.typography.bodyLarge)
}
}
/** Human-readable size, matching what the web and iOS show. */
private fun formatAttachmentSize(bytes: Int): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
}
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton
@@ -44,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -52,7 +56,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.ReminderOptions
import com.scarriffle.calendarr.domain.model.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
@@ -79,7 +85,8 @@ fun EventEditorSheet(
onDismiss: () -> Unit,
defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit,
existingAttachments: List<EventAttachment> = emptyList(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>, List<StagedAttachment>, Set<Int>) -> Unit,
) {
val zone = ZoneId.systemDefault()
val context = LocalContext.current
@@ -126,6 +133,28 @@ fun EventEditorSheet(
}
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
var calMenuOpen by remember { mutableStateOf(false) }
// Files picked before the event exists are staged and uploaded after it is
// created; removals of existing ones are applied on save, so Cancel really
// does cancel.
var stagedAttachments by remember { mutableStateOf(emptyList<StagedAttachment>()) }
var removedAttachmentIds by remember { mutableStateOf(emptySet<Int>()) }
val attachmentSlotsUsed =
existingAttachments.count { it.id !in removedAttachmentIds } + stagedAttachments.size
val pickAttachment = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null && attachmentSlotsUsed < 10) {
// Read the bytes now: the content URI may not outlive this screen.
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
val name = queryDisplayName(context, uri) ?: "anhang"
val mime = context.contentResolver.getType(uri)
if (bytes != null && bytes.size <= 10 * 1024 * 1024) {
stagedAttachments = stagedAttachments + StagedAttachment(bytes, name, mime)
}
}
}
var error by remember { mutableStateOf<String?>(null) }
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
@@ -282,6 +311,42 @@ fun EventEditorSheet(
},
) { Text(tr("event.reminder_add")) }
Spacer(Modifier.size(12.dp))
Text(tr("event.attachments"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
existingAttachments.filter { it.id !in removedAttachmentIds }.forEach { att ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(att.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
removedAttachmentIds = removedAttachmentIds + att.id
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
stagedAttachments.forEach { file ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.size(8.dp))
Text(file.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
stagedAttachments = stagedAttachments - file
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
androidx.compose.material3.TextButton(
enabled = attachmentSlotsUsed < 10,
onClick = { pickAttachment.launch(ATTACHMENT_MIME_TYPES) },
) { Text(tr("event.attachment_add")) }
Text(
tr("event.attachment_hint"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
}
// Color
@@ -325,7 +390,9 @@ fun EventEditorSheet(
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
val rem = if (cal.source == "local") reminders else emptyList()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem)
val staged = if (cal.source == "local") stagedAttachments else emptyList()
val removed = if (cal.source == "local") removedAttachmentIds else emptySet()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem, staged, removed)
},
enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
@@ -405,3 +472,23 @@ private fun ReminderRow(
}
}
}
// 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",
"text/plain", "text/markdown", "text/csv", "text/comma-separated-values",
)
/** The user-visible name behind a content URI; the last path segment is an
* opaque id and would end up as the attachment's filename. */
private fun queryDisplayName(context: android.content.Context, uri: android.net.Uri): String? =
runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull()
+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)
+67 -10
View File
@@ -60,8 +60,50 @@ def _organizer_name(component) -> str | None:
return raw or None
def _cal_address(prop) -> str | None:
"""The bare lowercase address of an ORGANIZER/ATTENDEE property."""
if prop is None:
return None
raw = str(prop).strip()
if raw.lower().startswith("mailto:"):
raw = raw[7:]
raw = raw.strip().strip("<>").lower()
return raw or None
def _attendees(component) -> list[dict]:
"""The ATTENDEE list with the parameters an iTIP reply needs.
icalendar hands back a single property when there is one attendee and a
list when there are several, so both shapes have to be normalised.
"""
raw = component.get("ATTENDEE")
if raw is None:
return []
items = raw if isinstance(raw, list) else [raw]
out = []
for prop in items:
email = _cal_address(prop)
if not email:
continue
params = getattr(prop, "params", {}) or {}
out.append({
"email": email,
"cn": str(params.get("CN")) if params.get("CN") else None,
"partstat": str(params.get("PARTSTAT") or "NEEDS-ACTION").upper(),
"role": str(params.get("ROLE") or "REQ-PARTICIPANT").upper(),
"rsvp": str(params.get("RSVP") or "").upper() == "TRUE",
})
return out
def parse_ics(raw: bytes) -> dict:
"""Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}.
"""Parse .ics bytes into {"method": str|None, "events": [...], "errors": [...]}.
``method`` sits on the VCALENDAR, not on a VEVENT, so it is read outside the
component loop — it is the only reliable way to tell an invitation
(METHOD:REQUEST) from a plain calendar file. Every key added for iTIP is
additive: existing callers read the ones they already knew.
Raises ValueError if the payload is not a parseable calendar at all.
"""
@@ -127,21 +169,33 @@ def parse_ics(raw: bytes) -> dict:
"rrule": _rrule_to_str(component),
"exdate": _exdate_to_csv(component),
"organizer": _organizer_name(component),
"organizer_email": _cal_address(component.get("ORGANIZER")),
"attendees": _attendees(component),
"sequence": int(component.get("SEQUENCE", 0) or 0),
# A single occurrence of a series. Applying one as if it were
# the whole series would silently replace every other date.
"recurrence_id": (str(component.get("RECURRENCE-ID").dt)
if component.get("RECURRENCE-ID") is not None else None),
})
except Exception as exc:
logger.warning("Skipping malformed VEVENT: %s", exc)
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
return {"events": events, "errors": errors}
method = str(cal.get("METHOD") or "").upper() or None
return {"method": method, "events": events, "errors": errors}
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 +252,10 @@ def build_ics(calendar, events, *, name_cache: dict | None = None,
if ev.color:
item.add("x-calendarr-color", ev.color)
if ev.rrule:
item.add("rrule", _rrule_str_to_vrecur(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
+333
View File
@@ -0,0 +1,333 @@
"""Pull iTIP invitations out of a watched mailbox.
A mailbox is a trust boundary this app did not have before: anyone who knows the
address can hand the server an iCalendar file. So the rule throughout is that a
mail can only ever create a row in ``event_invites``. No calendar read path
touches that table — an invitation becomes an event when a human accepts it, and
not before.
The mailbox is configured in the UI and stored in ``mailbox_accounts`` — one row
per instance, since it is a shared forwarding target rather than a per-user
account. The password is kept as given, like every other external account here;
no endpoint returns it.
Single process assumed, same as the rest of the scheduler (see scheduler.py).
"""
from __future__ import annotations
import contextlib
import imaplib
import logging
import re
import uuid
from datetime import datetime, timezone
from email import message_from_bytes
from email.message import EmailMessage
from sqlalchemy import func
from sqlalchemy.orm import Session
import dav_util
import ical_io
import models
logger = logging.getLogger(__name__)
# An invitation is a few kilobytes. These caps exist so one oversized mail
# cannot exhaust memory, and so a flooded mailbox cannot monopolise a run.
MAX_MESSAGE_BYTES = 1 * 1024 * 1024
MAX_ICS_BYTES = 256 * 1024
MAX_MESSAGES_PER_RUN = 25
MAX_CAL_PARTS = 3
# Checked before an address is ever put into a mail header. Rejecting the
# characters that could break a header out is cheaper than trusting the
# formatter, and EmailMessage refuses embedded newlines on top of it.
_ADDR_RE = re.compile(r"^[^@\s<>\",;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
_HANDLED_METHODS = ("REQUEST", "CANCEL")
def get_account(db: Session):
"""The configured mailbox, or None. Disabled counts as not configured."""
return (db.query(models.MailboxAccount)
.filter(models.MailboxAccount.enabled.is_(True))
.order_by(models.MailboxAccount.id).first())
def is_configured(db: Session) -> bool:
return get_account(db) is not None
def _valid_address(addr: str | None) -> str | None:
if not addr:
return None
addr = addr.strip().lower()
return addr if _ADDR_RE.match(addr) else None
def user_for_address(db: Session, addr: str | None):
"""The user who owns this address, or None.
Reads the dedicated table and `users.email` both, so someone whose
invitation address is already in their profile needs no extra setup.
"""
addr = _valid_address(addr)
if not addr:
return None
row = (db.query(models.UserEmailAddress)
.filter(models.UserEmailAddress.address == addr).first())
if row:
return row.user
return (db.query(models.User)
.filter(func.lower(models.User.email) == addr).first())
# ── Ingest ────────────────────────────────────────────────
def _match_attendee(db: Session, item: dict):
"""Which user this invitation is for, read from the ATTENDEE list.
Never from the From header: a mail can claim any sender it likes, and that
header is what an attacker controls. The ATTENDEE list is what the organiser
put in the payload, matched against addresses a user has proven are theirs.
"""
# Our own invitation coming back to us is not an invitation to accept.
if user_for_address(db, item.get("organizer_email")):
return None, None
for att in item.get("attendees") or ():
user = user_for_address(db, att.get("email"))
if user:
return user, _valid_address(att.get("email"))
return None, None
def _apply_request(db: Session, user, attendee: str, item: dict, raw_ics: str) -> None:
inv = (db.query(models.EventInvite)
.filter(models.EventInvite.user_id == user.id,
models.EventInvite.uid == item["uid"]).first())
sequence = int(item.get("sequence") or 0)
if inv and sequence <= inv.sequence and inv.status != "cancelled":
return # Forwarded mail arrives twice; the same SEQUENCE is the same news.
if inv is None:
inv = models.EventInvite(user_id=user.id, uid=item["uid"])
db.add(inv)
inv.sequence = sequence
inv.title = (item.get("title") or "(ohne Titel)")[:255]
inv.start = item["start"]
inv.end = item["end"]
inv.all_day = bool(item.get("all_day"))
inv.location = (item.get("location") or None) and item["location"][:500]
inv.description = (item.get("description") or None) and item["description"][:8000]
inv.rrule = item.get("rrule")
inv.organizer_email = _valid_address(item.get("organizer_email"))
inv.organizer_name = (item.get("organizer") or None) and item["organizer"][:255]
inv.attendee_email = attendee
inv.raw_ics = raw_ics
inv.received_at = datetime.now(timezone.utc)
# A changed invitation is a new decision, so an already answered one goes
# back to pending. The event created earlier stays until the user answers
# again — this never rewrites a calendar entry behind their back.
inv.status = "pending"
inv.reply_state = None
db.commit()
logger.info("Einladung %s für Nutzer %s gespeichert (SEQUENCE %s)",
item["uid"], user.id, sequence)
def _apply_cancel(db: Session, user, item: dict) -> None:
inv = (db.query(models.EventInvite)
.filter(models.EventInvite.user_id == user.id,
models.EventInvite.uid == item["uid"]).first())
if inv is None:
return
drop_event(db, inv)
inv.status = "cancelled"
db.commit()
logger.info("Einladung %s zurückgezogen", item["uid"])
def drop_event(db: Session, inv) -> None:
"""Remove the event an acceptance created, if it still exists."""
if not inv.event_id:
return
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
if ev:
cal = (db.query(models.LocalCalendar)
.filter(models.LocalCalendar.id == ev.calendar_id).first())
db.delete(ev)
if cal:
dav_util.bump_dav(cal)
inv.event_id = None
def ingest_ics(db: Session, raw: bytes) -> bool:
"""Store one invitation from an iCalendar payload. True when something was."""
if len(raw) > MAX_ICS_BYTES:
logger.info("Kalenderteil zu groß (%s Bytes), übersprungen", len(raw))
return False
try:
parsed = ical_io.parse_ics(raw)
except ValueError:
return False
method = (parsed.get("method") or "").upper()
if method not in _HANDLED_METHODS:
return False # REPLY/COUNTER/PUBLISH: we are never the organiser.
text = raw.decode("utf-8", "replace")
for item in parsed.get("events") or ():
if item.get("recurrence_id"):
# One occurrence of a series. Applying it as the whole series would
# silently move every other date.
logger.info("Einzeltermin einer Serie übersprungen (UID %s)", item.get("uid"))
continue
user, attendee = _match_attendee(db, item)
if not user:
continue
if method == "CANCEL":
_apply_cancel(db, user, item)
else:
_apply_request(db, user, attendee, item, text)
return True # One invitation per mail; never a bulk import.
return False
# ── Mailbox ───────────────────────────────────────────────
def _connect(acc) -> imaplib.IMAP4:
box = (imaplib.IMAP4_SSL(acc.imap_host, acc.imap_port, timeout=30) if acc.imap_ssl
else imaplib.IMAP4(acc.imap_host, acc.imap_port, timeout=30))
box.login(acc.imap_user, acc.imap_password)
return box
def _calendar_parts(msg) -> list[bytes]:
"""Every iCalendar payload in a mail, capped.
Senders disagree on the content type: a proper invitation is text/calendar,
but plenty of clients attach it as application/ics or just a .ics file.
"""
out: list[bytes] = []
for part in msg.walk():
if len(out) >= MAX_CAL_PARTS:
break
ctype = (part.get_content_type() or "").lower()
name = (part.get_filename() or "").lower()
if ctype in ("text/calendar", "application/ics") or name.endswith(".ics"):
try:
payload = part.get_payload(decode=True)
except Exception:
continue
if payload:
out.append(payload)
return out
def _handle_message(db: Session, box: imaplib.IMAP4, uid: bytes) -> None:
typ, size_data = box.uid("FETCH", uid, "(RFC822.SIZE)")
if typ == "OK" and size_data and size_data[0]:
m = re.search(rb"RFC822\.SIZE (\d+)", size_data[0])
if m and int(m.group(1)) > MAX_MESSAGE_BYTES:
logger.info("Mail %s zu groß (%s Bytes), übersprungen", uid, m.group(1))
return
# PEEK: a plain FETCH sets \Seen on the server even if we crash afterwards,
# which would lose the invitation entirely.
typ, data = box.uid("FETCH", uid, "(BODY.PEEK[])")
if typ != "OK" or not data or not data[0]:
return
msg = message_from_bytes(data[0][1])
for payload in _calendar_parts(msg):
if ingest_ics(db, payload):
return
def fetch_invites(db: Session) -> None:
"""Scheduler job: read new mail and store any invitations it holds.
Records what happened on the account so the UI can show a mailbox that
stopped working, rather than letting invitations quietly never arrive.
"""
acc = get_account(db)
if acc is None:
return
try:
_run(db, acc)
acc.last_error = None
except Exception as exc:
logger.exception("Postfach %s nicht erreichbar", acc.imap_host)
acc.last_error = f"{type(exc).__name__}: {exc}"[:500]
acc.last_run = datetime.now(timezone.utc)
with contextlib.suppress(Exception):
db.commit()
def _run(db: Session, acc) -> None:
box = _connect(acc)
try:
box.select(acc.imap_folder or "INBOX")
# UID commands, not sequence numbers: those shift when a message is
# expunged mid-run and we would read the wrong mail.
typ, data = box.uid("SEARCH", None, "UNSEEN")
if typ != "OK":
logger.warning("IMAP SEARCH fehlgeschlagen: %s", typ)
return
uids = (data[0] or b"").split()[:MAX_MESSAGES_PER_RUN]
for uid in uids:
try:
_handle_message(db, box, uid)
except Exception:
logger.exception("Einladungsmail %s nicht verarbeitbar", uid)
with contextlib.suppress(Exception):
db.rollback()
finally:
# Advertising and broken mail get marked too, or every run
# retries them forever. Nothing is ever deleted.
with contextlib.suppress(Exception):
box.uid("STORE", uid, "+FLAGS", "\\Seen")
finally:
with contextlib.suppress(Exception):
box.logout()
# ── Accepting ─────────────────────────────────────────────
def upsert_event(db: Session, inv, cal) -> models.LocalEvent:
"""Create or update the event an acceptance produces.
The event gets a fresh UID. LocalEvent.uid is globally unique, so reusing
the organiser's would let a mail collide with — and refuse to coexist
alongside — an event that is already there. The organiser's UID goes to
external_uid, which is what the rest of the app already uses for
"this came from somewhere else".
"""
ev = None
if inv.event_id:
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
if ev is None:
ev = models.LocalEvent(uid=str(uuid.uuid4()),
external_uid=f"invite:{inv.uid}",
creator_id=inv.user_id)
db.add(ev)
ev.calendar_id = cal.id
ev.title = inv.title
ev.start = inv.start
ev.end = inv.end
ev.all_day = bool(inv.all_day)
ev.location = inv.location
ev.description = inv.description
ev.rrule = inv.rrule
ev.creator_name_external = inv.organizer_name
ev.etag = dav_util.new_tag()
dav_util.bump_dav(cal)
db.flush()
inv.event_id = ev.id
return ev
+24 -1
View File
@@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
import scheduler
from database import Base, engine
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, invites_router, local_router, oidc_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
@@ -383,6 +383,28 @@ def _migrate():
except Exception:
pass
# ── Theme inheritance ────────────────────────────────────────────
# These five colours used to carry a hardcoded column default, so every
# user row held a concrete value and the admin instance default could
# never apply. Clear the ones that still hold exactly that old default
# so they inherit again. A user who deliberately picked the same colour
# also inherits now — which is the same colour, until an admin changes
# the instance default.
for col, old_default in (("primary_color", "#4285f4"),
("accent_color", "#ea4335"),
("today_color", "#4285f4"),
("month_divider_color", "#7090c0"),
("month_label_color", "#7090c0")):
try:
conn.execute(text(
f"UPDATE user_settings SET {col} = NULL "
f"WHERE lower({col}) = lower(:old)"
), {"old": old_default})
conn.commit()
except Exception:
pass
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
@@ -438,6 +460,7 @@ app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
app.include_router(invites_router.router, prefix="/api/invites", tags=["invites"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
+134 -5
View File
@@ -95,16 +95,19 @@ class UserSettings(Base):
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
default_view = Column(String(20), default="month")
week_start_day = Column(String(10), default="monday")
primary_color = Column(String(7), default="#4285f4")
accent_color = Column(String(7), default="#ea4335")
today_color = Column(String(7), default="#4285f4")
# NULL = inherit the admin instance default (see admin_router). A baked-in
# column default would mean every user always *has* an own colour, so the
# instance default could never apply — which is exactly what happened.
primary_color = Column(String(7), nullable=True)
accent_color = Column(String(7), nullable=True)
today_color = Column(String(7), nullable=True)
dim_past_events = Column(Boolean, default=False)
text_contrast = Column(Integer, default=3)
line_contrast = Column(Integer, default=3)
hour_height = Column(Integer, default=60)
language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0")
month_divider_color = Column(String(7), nullable=True) # NULL = inherit
month_label_color = Column(String(7), nullable=True) # NULL = inherit
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
@@ -395,6 +398,114 @@ class HomeAssistantCalendar(Base):
# ── Collaboration: sharing & groups (local calendars only) ────────────────
class UserEmailAddress(Base):
"""An address under which this user appears in an ATTENDEE list.
A list rather than a column: the mail arrives at a forwarding mailbox while
the invitation still names the original address, and people hand out more
than one. Globally unique so nobody can claim someone else's address and
push events into their calendar.
"""
__tablename__ = "user_email_addresses"
__table_args__ = (UniqueConstraint("address", name="uq_user_email_address"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# Always lowercase and without the "mailto:" prefix.
address = Column(String(255), nullable=False)
user = relationship("User")
class MailboxAccount(Base):
"""The mailbox watched for invitations, configured in the UI.
One row per instance: this is a shared inbox that invitations are forwarded
to, not a per-user account. Administrators manage it.
The password is stored as given, the same as CalDAVAccount, GoogleAccount
and HomeAssistantAccount — IMAP needs the original to log in, so a hash is
no use. Worth knowing: unlike those three, this one opens an entire mailbox.
No endpoint ever returns it.
SMTP lives here too so replies to organisers have somewhere to come from.
"""
__tablename__ = "mailbox_accounts"
id = Column(Integer, primary_key=True, index=True)
enabled = Column(Boolean, default=True, nullable=False)
imap_host = Column(String(255), nullable=False)
imap_port = Column(Integer, default=993, nullable=False)
imap_ssl = Column(Boolean, default=True, nullable=False)
imap_user = Column(String(255), nullable=False)
imap_password = Column(String(255), nullable=False)
imap_folder = Column(String(255), default="INBOX", nullable=False)
poll_minutes = Column(Integer, default=5, nullable=False)
smtp_host = Column(String(255), nullable=True)
smtp_port = Column(Integer, default=587, nullable=True)
smtp_ssl = Column(Boolean, default=False, nullable=True)
smtp_user = Column(String(255), nullable=True)
smtp_password = Column(String(255), nullable=True)
smtp_from = Column(String(255), nullable=True)
# Surfaced in the UI so a mailbox that stopped working says so, instead of
# invitations quietly never arriving.
last_run = Column(DateTime, nullable=True)
last_error = Column(Text, nullable=True)
class EventInvite(Base):
"""An iTIP invitation (METHOD:REQUEST) that arrived by mail.
Raw *and* parsed. ``raw_ics`` is the only thing the organiser actually sent,
and the reply is built from it — a rebuilt DTSTART is what strict
counterparts reject. The parsed columns exist so clients can render a list
without parsing iCalendar, and use the same formats as LocalEvent.
Nothing here is visible in any calendar read path: an invitation becomes an
event only when a human accepts it.
"""
__tablename__ = "event_invites"
__table_args__ = (UniqueConstraint("user_id", "uid", name="uq_event_invite_user_uid"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# The organiser's UID. Only ever matched together with user_id, so two
# users invited to the same meeting never collide.
uid = Column(String(255), nullable=False)
sequence = Column(Integer, default=0, nullable=False)
# pending | accepted | declined | cancelled
status = Column(String(12), default="pending", nullable=False)
title = Column(String(255), nullable=False)
start = Column(String(50), nullable=False)
end = Column(String(50), nullable=False)
all_day = Column(Boolean, default=False)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
rrule = Column(Text, nullable=True)
organizer_email = Column(String(255), nullable=True)
organizer_name = Column(String(255), nullable=True)
# The ATTENDEE address that matched this user. The reply must name exactly
# this one or the organiser will not recognise the answer.
attendee_email = Column(String(255), nullable=False)
raw_ics = Column(Text, nullable=False)
# The event created on acceptance, so an update or a CANCEL can find it.
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True)
received_at = Column(DateTime, default=datetime.utcnow)
# None | sent | failed | skipped
reply_state = Column(String(12), nullable=True)
user = relationship("User")
class CalendarShare(Base):
"""A local calendar shared with another Calendarr user."""
@@ -429,6 +540,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}")
+264
View File
@@ -0,0 +1,264 @@
"""Invitations that arrived by mail: list them, accept or decline, dismiss.
The decision is the only thing that turns an invitation into a calendar entry.
Everything a mail can do on its own stops at the event_invites table.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import mail_invites
import models
import permissions
from auth import get_current_user
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
def _invite_dict(inv: models.EventInvite) -> dict:
return {
"id": inv.id,
"uid": inv.uid,
"status": inv.status,
"title": inv.title,
"start": inv.start,
"end": inv.end,
"all_day": bool(inv.all_day),
"location": inv.location,
"description": inv.description,
"rrule": inv.rrule,
"organizer_email": inv.organizer_email,
"organizer_name": inv.organizer_name,
"attendee_email": inv.attendee_email,
"reply_state": inv.reply_state,
"received_at": inv.received_at.isoformat() if inv.received_at else None,
}
def _own_invite(db: Session, user: models.User, invite_id: int) -> models.EventInvite:
inv = (db.query(models.EventInvite)
.filter(models.EventInvite.id == invite_id,
models.EventInvite.user_id == user.id).first())
if inv is None:
raise HTTPException(404, "Einladung nicht gefunden")
return inv
def _default_invite_calendar(db: Session, user: models.User) -> models.LocalCalendar:
"""Where an accepted invitation lands when the client names no calendar.
The user's own, non-birthday calendar with the lowest id — the same one they
would have picked by hand in almost every case. A birthday calendar is
excluded because the server injects reminders into those.
"""
cal = (db.query(models.LocalCalendar)
.filter(models.LocalCalendar.user_id == user.id,
models.LocalCalendar.is_birthday.isnot(True))
.order_by(models.LocalCalendar.id).first())
if cal:
return cal
cal = models.LocalCalendar(user_id=user.id, name="Einladungen", color="#4285f4")
db.add(cal)
db.flush()
return cal
@router.get("")
def list_invites(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
"""Everything still awaiting a decision, plus withdrawn ones.
Cancelled invitations stay visible on purpose: a withdrawal the user never
sees is worse than one more row to dismiss.
"""
rows = (db.query(models.EventInvite)
.filter(models.EventInvite.user_id == current_user.id,
models.EventInvite.status.in_(("pending", "cancelled")))
.order_by(models.EventInvite.start).all())
return [_invite_dict(i) for i in rows]
class InviteRespond(BaseModel):
action: str # accept | decline
calendar_id: Optional[int] = None
@router.post("/{invite_id}/respond")
def respond(invite_id: int, data: InviteRespond,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
inv = _own_invite(db, current_user, invite_id)
if data.action not in ("accept", "decline"):
raise HTTPException(422, "action muss accept oder decline sein")
if inv.status == "cancelled":
raise HTTPException(409, "Die Einladung wurde zurückgezogen.")
cal = None
if data.action == "accept":
# The target is checked server-side; a client cannot name a calendar it
# may not write to.
cal = (permissions.accessible_local_calendar(db, current_user, data.calendar_id,
require_write=True)
if data.calendar_id else _default_invite_calendar(db, current_user))
mail_invites.upsert_event(db, inv, cal)
inv.status = "accepted"
else:
mail_invites.drop_event(db, inv)
inv.status = "declined"
# Committed before any mail is attempted: a broken SMTP must never roll back
# a decision the user already made.
db.commit()
return {"ok": True, "status": inv.status, "reply_state": inv.reply_state,
"calendar_id": cal.id if cal else None}
@router.delete("/{invite_id}")
def dismiss(invite_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
"""Clear an invitation from the list. An accepted event stays."""
inv = _own_invite(db, current_user, invite_id)
db.delete(inv)
db.commit()
return {"ok": True}
@router.get("/status")
def status(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
acc = mail_invites.get_account(db)
return {
"imap_configured": acc is not None,
"smtp_configured": bool(acc and acc.smtp_host),
"last_run": acc.last_run.isoformat() if acc and acc.last_run else None,
"last_error": acc.last_error if acc else None,
}
# ── Mailbox configuration ─────────────────────────────────
#
# One mailbox per instance: it is a shared forwarding target, not a per-user
# account, so only administrators may see or change it.
def _require_admin(user: models.User) -> None:
if not user.is_admin:
raise HTTPException(403, "Nur für Administratoren")
def _mailbox_dict(acc) -> dict | None:
if acc is None:
return None
# Passwords are never returned, the same as the CalDAV account endpoints.
return {
"id": acc.id,
"enabled": bool(acc.enabled),
"imap_host": acc.imap_host,
"imap_port": acc.imap_port,
"imap_ssl": bool(acc.imap_ssl),
"imap_user": acc.imap_user,
"imap_folder": acc.imap_folder,
"poll_minutes": acc.poll_minutes,
"has_imap_password": bool(acc.imap_password),
"smtp_host": acc.smtp_host,
"smtp_port": acc.smtp_port,
"smtp_ssl": bool(acc.smtp_ssl),
"smtp_user": acc.smtp_user,
"smtp_from": acc.smtp_from,
"has_smtp_password": bool(acc.smtp_password),
"last_run": acc.last_run.isoformat() if acc.last_run else None,
"last_error": acc.last_error,
}
class MailboxUpdate(BaseModel):
enabled: bool = True
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
imap_user: str
# Optional on edit: left out keeps the stored one, so changing a port does
# not mean retyping the password.
imap_password: Optional[str] = None
imap_folder: str = "INBOX"
poll_minutes: int = 5
smtp_host: Optional[str] = None
smtp_port: Optional[int] = 587
smtp_ssl: Optional[bool] = False
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
smtp_from: Optional[str] = None
@router.get("/mailbox")
def get_mailbox(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
_require_admin(current_user)
return _mailbox_dict(
db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first())
@router.put("/mailbox")
def set_mailbox(data: MailboxUpdate, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
_require_admin(current_user)
acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first()
if acc is None:
acc = models.MailboxAccount(imap_host="", imap_user="", imap_password="")
db.add(acc)
if not data.imap_password and not acc.imap_password:
raise HTTPException(422, "Passwort fehlt")
if not 1 <= data.poll_minutes <= 1440:
raise HTTPException(422, "Abrufintervall muss zwischen 1 und 1440 Minuten liegen")
acc.enabled = data.enabled
acc.imap_host = data.imap_host.strip()
acc.imap_port = data.imap_port
acc.imap_ssl = data.imap_ssl
acc.imap_user = data.imap_user.strip()
if data.imap_password:
acc.imap_password = data.imap_password
acc.imap_folder = (data.imap_folder or "INBOX").strip()
acc.poll_minutes = data.poll_minutes
acc.smtp_host = (data.smtp_host or "").strip() or None
acc.smtp_port = data.smtp_port
acc.smtp_ssl = bool(data.smtp_ssl)
acc.smtp_user = (data.smtp_user or "").strip() or None
if data.smtp_password:
acc.smtp_password = data.smtp_password
acc.smtp_from = (data.smtp_from or "").strip() or None
# Settings changed, so whatever went wrong before is no longer current.
acc.last_error = None
db.commit()
return _mailbox_dict(acc)
@router.delete("/mailbox")
def delete_mailbox(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
_require_admin(current_user)
acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first()
if acc:
db.delete(acc)
db.commit()
return {"ok": True}
@router.post("/poll")
def poll_now(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
"""Fetch right now instead of waiting for the scheduler."""
_require_admin(current_user)
if not mail_invites.is_configured(db):
raise HTTPException(400, "Kein Postfach konfiguriert")
mail_invites.fetch_invites(db)
acc = mail_invites.get_account(db)
return {"ok": True, "last_error": acc.last_error if acc else None}
+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},
)
+5 -3
View File
@@ -108,8 +108,10 @@ def _settings_dict(s: models.UserSettings) -> dict:
"line_contrast": s.line_contrast or 3,
"hour_height": s.hour_height or 60,
"language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0",
# NULL stays NULL: the client resolves it against the admin instance
# default. Substituting a colour here would hide the inheritance.
"month_divider_color": s.month_divider_color,
"month_label_color": s.month_label_color,
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
@@ -186,7 +188,7 @@ def update_settings(
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon", "primary_color", "accent_color", "today_color", "month_divider_color", "month_label_color"}
update_data = data.model_dump(exclude_unset=True)
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
+12
View File
@@ -133,11 +133,23 @@ def refresh_ical_subscriptions(db: Session) -> None:
db.rollback()
def fetch_mail_invites(db: Session) -> None:
"""Read the watched mailbox for new invitations."""
import mail_invites
mail_invites.fetch_invites(db)
def install_default_jobs() -> None:
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
# Always registered: the mailbox is configured in the UI, so whether there
# is one to read can change without a restart. fetch_invites returns at once
# when none is set up.
mail_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
register("mail.invites", mail_minutes * 60, fetch_mail_invites)
@asynccontextmanager
+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()
+286
View File
@@ -0,0 +1,286 @@
"""Invitations arriving by mail.
The mailbox is never touched here — ingest_ics is fed directly, because what
matters is what an iCalendar payload from a stranger is allowed to do, not
whether imaplib works.
"""
import mail_invites
import models
from conftest import auth, register_admin
from database import SessionLocal
def _ics(uid="meet-1", sequence=0, method="REQUEST", attendee="admin@example.com",
summary="Projektbesprechung", start="20260601T090000Z", end="20260601T100000Z"):
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
f"METHOD:{method}\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"SEQUENCE:{sequence}\r\n"
f"SUMMARY:{summary}\r\n"
f"DTSTART:{start}\r\n"
f"DTEND:{end}\r\n"
"ORGANIZER;CN=Chefin:mailto:chefin@example.org\r\n"
f"ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{attendee}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
).encode()
def _admin_with_email(client, address="admin@example.com"):
token = register_admin(client)
db = SessionLocal()
try:
user = db.query(models.User).filter(models.User.username == "admin").first()
user.email = address
db.commit()
return token, user.id
finally:
db.close()
def _ingest(payload):
db = SessionLocal()
try:
return mail_invites.ingest_ics(db, payload)
finally:
db.close()
def test_parser_exposes_the_itip_fields():
import ical_io
parsed = ical_io.parse_ics(_ics(sequence=3))
assert parsed["method"] == "REQUEST"
item = parsed["events"][0]
assert item["sequence"] == 3
assert item["organizer_email"] == "chefin@example.org"
assert item["attendees"][0]["email"] == "admin@example.com"
assert item["attendees"][0]["partstat"] == "NEEDS-ACTION"
assert item["attendees"][0]["rsvp"] is True
def test_invitation_lands_as_pending(client):
token, _ = _admin_with_email(client)
assert _ingest(_ics()) is True
rows = client.get("/api/invites", headers=auth(token)).json()
assert len(rows) == 1
assert rows[0]["status"] == "pending"
assert rows[0]["title"] == "Projektbesprechung"
assert rows[0]["organizer_email"] == "chefin@example.org"
def test_a_mail_alone_creates_no_event(client):
"""The whole security model in one assertion."""
token, _ = _admin_with_email(client)
_ingest(_ics())
db = SessionLocal()
try:
assert db.query(models.LocalEvent).count() == 0
finally:
db.close()
def test_unknown_attendee_is_ignored(client):
register_admin(client) # no email set, so nothing matches
assert _ingest(_ics(attendee="someone-else@example.net")) is False
def test_from_header_cannot_route(client):
"""Routing is by ATTENDEE only; a sender can claim anything."""
token, _ = _admin_with_email(client)
assert _ingest(_ics(attendee="stranger@example.net")) is False
assert client.get("/api/invites", headers=auth(token)).json() == []
def test_same_sequence_is_ignored(client):
token, _ = _admin_with_email(client)
_ingest(_ics(summary="Erste Fassung"))
_ingest(_ics(summary="Zweite Fassung")) # forwarded twice
rows = client.get("/api/invites", headers=auth(token)).json()
assert len(rows) == 1
assert rows[0]["title"] == "Erste Fassung"
def test_higher_sequence_reopens_an_answered_invitation(client):
token, _ = _admin_with_email(client)
_ingest(_ics())
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
headers=auth(token))
_ingest(_ics(sequence=1, summary="Verschoben"))
rows = client.get("/api/invites", headers=auth(token)).json()
assert len(rows) == 1
assert rows[0]["status"] == "pending"
assert rows[0]["title"] == "Verschoben"
def test_accepting_creates_the_event(client):
token, _ = _admin_with_email(client)
_ingest(_ics())
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
res = client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
headers=auth(token))
assert res.status_code == 200
assert res.json()["status"] == "accepted"
db = SessionLocal()
try:
ev = db.query(models.LocalEvent).one()
assert ev.title == "Projektbesprechung"
assert ev.external_uid == "invite:meet-1"
assert ev.uid != "meet-1" # never the organiser's UID
finally:
db.close()
assert client.get("/api/invites", headers=auth(token)).json() == []
def test_invitation_cannot_overwrite_an_existing_event(client):
"""The test that matters: a colliding UID must not touch what is there."""
token, user_id = _admin_with_email(client)
db = SessionLocal()
try:
cal = models.LocalCalendar(user_id=user_id, name="Privat", color="#4285f4")
db.add(cal)
db.flush()
db.add(models.LocalEvent(calendar_id=cal.id, uid="meet-1", title="Mein Termin",
start="2026-06-01T09:00:00+00:00",
end="2026-06-01T10:00:00+00:00"))
db.commit()
finally:
db.close()
_ingest(_ics(uid="meet-1", summary="Fremder Termin"))
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
headers=auth(token))
db = SessionLocal()
try:
mine = db.query(models.LocalEvent).filter(models.LocalEvent.uid == "meet-1").one()
assert mine.title == "Mein Termin" # untouched
assert db.query(models.LocalEvent).count() == 2
finally:
db.close()
def test_declining_creates_nothing(client):
token, _ = _admin_with_email(client)
_ingest(_ics())
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
client.post(f"/api/invites/{invite_id}/respond", json={"action": "decline"},
headers=auth(token))
db = SessionLocal()
try:
assert db.query(models.LocalEvent).count() == 0
finally:
db.close()
def test_cancel_removes_an_accepted_event(client):
token, _ = _admin_with_email(client)
_ingest(_ics())
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
headers=auth(token))
_ingest(_ics(method="CANCEL", sequence=1))
db = SessionLocal()
try:
assert db.query(models.LocalEvent).count() == 0
finally:
db.close()
rows = client.get("/api/invites", headers=auth(token)).json()
assert rows[0]["status"] == "cancelled" # stays visible, not silently gone
def test_reply_and_publish_are_not_invitations(client):
_admin_with_email(client)
assert _ingest(_ics(method="REPLY")) is False
assert _ingest(_ics(method="PUBLISH")) is False
def test_oversized_payload_is_refused(client):
_admin_with_email(client)
padded = _ics(summary="X" * (mail_invites.MAX_ICS_BYTES + 10))
assert _ingest(padded) is False
# ── Mailbox configuration ─────────────────────────────────
def _mailbox_payload(**over):
body = {"imap_host": "imap.example.com", "imap_user": "einladungen@example.com",
"imap_password": "geheim", "imap_port": 993, "poll_minutes": 5}
body.update(over)
return body
def test_mailbox_password_is_never_returned(client):
token = register_admin(client)
client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token))
body = client.get("/api/invites/mailbox", headers=auth(token)).json()
assert body["imap_host"] == "imap.example.com"
assert body["has_imap_password"] is True
assert "imap_password" not in body
def test_editing_without_a_password_keeps_the_stored_one(client):
token = register_admin(client)
client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token))
client.put("/api/invites/mailbox",
json=_mailbox_payload(imap_password=None, imap_port=143),
headers=auth(token))
db = SessionLocal()
try:
acc = db.query(models.MailboxAccount).one()
assert acc.imap_port == 143
assert acc.imap_password == "geheim"
finally:
db.close()
def test_first_save_requires_a_password(client):
token = register_admin(client)
res = client.put("/api/invites/mailbox", json=_mailbox_payload(imap_password=None),
headers=auth(token))
assert res.status_code == 422
def test_only_admins_touch_the_mailbox(client):
from conftest import create_user
admin = register_admin(client)
user = create_user(client, admin, "lisa")
assert client.get("/api/invites/mailbox", headers=auth(user)).status_code == 403
assert client.put("/api/invites/mailbox", json=_mailbox_payload(),
headers=auth(user)).status_code == 403
def test_disabled_mailbox_counts_as_unconfigured(client):
token = register_admin(client)
client.put("/api/invites/mailbox", json=_mailbox_payload(enabled=False),
headers=auth(token))
assert client.get("/api/invites/status", headers=auth(token)).json()["imap_configured"] is False
db = SessionLocal()
try:
assert mail_invites.get_account(db) is None
finally:
db.close()
+2 -1
View File
@@ -19,9 +19,10 @@ def _run(fn):
def test_default_jobs_are_registered_with_sane_intervals():
scheduler.install_default_jobs()
names = {j.name: j for j in scheduler._jobs}
assert set(names) == {"attachments.sweep", "ical.refresh"}
assert set(names) == {"attachments.sweep", "ical.refresh", "mail.invites"}
assert names["attachments.sweep"].interval == 24 * 3600
assert names["ical.refresh"].interval == 15 * 60
assert names["mail.invites"].interval == 5 * 60
# Nothing runs immediately: a restart must not be slowed by maintenance.
assert all(j.next_run > time.monotonic() for j in scheduler._jobs)
+75
View File
@@ -0,0 +1,75 @@
"""Theme colours: a user without an own colour must inherit the admin default.
These five colours used to carry a hardcoded column default, which meant every
user row always held a concrete value — so the admin's instance default could
never apply to them, however it was configured.
"""
import models
from conftest import auth, create_user, register_admin
from database import SessionLocal
INHERITABLE = ["primary_color", "accent_color", "today_color",
"month_divider_color", "month_label_color"]
def db_session():
return SessionLocal()
def test_new_user_has_no_own_colours(client):
"""A fresh account inherits — nothing is baked into its row."""
admin_token = register_admin(client, "admin", "pw")
uid, token = create_user(client, admin_token, "hugo")
with db_session() as db:
s = db.query(models.UserSettings).filter(
models.UserSettings.user_id == uid).one()
for col in INHERITABLE:
assert getattr(s, col) is None, f"{col} should inherit, got {getattr(s, col)!r}"
body = client.get("/api/settings/", headers=auth(token)).json()
for col in INHERITABLE:
assert body[col] is None
def test_setup_admin_also_inherits(client):
"""The first-run admin is created on a different code path."""
token = register_admin(client, "admin", "pw")
body = client.get("/api/settings/", headers=auth(token)).json()
for col in INHERITABLE:
assert body[col] is None
def test_choosing_a_colour_is_stored(client):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
r = client.put("/api/settings/", headers=auth(token),
json={"primary_color": "#58B900"})
assert r.status_code == 200, r.text
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] == "#58B900"
def test_reset_clears_back_to_inheriting(client):
"""The reset button sends null, and null has to persist — otherwise
"reset" would just freeze today's admin default into the user's row."""
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
client.put("/api/settings/", headers=auth(token), json={"primary_color": "#58B900"})
r = client.put("/api/settings/", headers=auth(token), json={"primary_color": None})
assert r.status_code == 200, r.text
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] is None
def test_unrelated_save_does_not_invent_a_colour(client):
"""Saving some other setting must not write a colour into the row."""
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
client.put("/api/settings/", headers=auth(token), json={"week_start_day": "sunday"})
body = client.get("/api/settings/", headers=auth(token)).json()
assert body["week_start_day"] == "sunday"
for col in INHERITABLE:
assert body[col] is None
+233
View File
@@ -0,0 +1,233 @@
# Design contract (Web / iOS / Android)
> For the user-configurable **colours**, see [../THEME.md](../THEME.md). Colours come from
> the server and are already consistent across clients — this document covers everything
> below the colour level, which is not.
The three clients should read as one product. That means the same structure and the same
measurements, **not** the same pixels: each app keeps its platform's navigation, system font
and controls. A Material button on iOS would feel wrong, and a swipe-back gesture on Android
would feel broken.
This document is the reference the clients are checked against. When a value here and a value
in the code disagree, the code is wrong.
---
## Why this exists
Nothing below the colour level was ever decided once. Counted across the clients today:
| | Web | Android | iOS |
|---|---|---|---|
| Distinct spacing values | 8 | **40** | not audited |
| Distinct font sizes | 8 | **9** | system styles |
| Distinct corner radii | 8 | **9** | mixed |
| Screen gutters | — | **4** (12/16/20/28) | — |
The result is that the left text edge visibly jumps between screens, the same event pill has
four different radii, and nine list rows were each built separately with five different
paddings. That is what makes an app look hand-made.
---
## Spacing
A four-step ladder. Every margin, padding and gap is one of these — no other value is allowed
without a comment saying why.
| Token | Value | Use |
|---|---|---|
| `xs` | 4 | Inside a control; gap between an icon and its label |
| `sm` | 8 | Between related items in a list; vertical padding of a compact row |
| `md` | 12 | Vertical padding of a standard row |
| `lg` | **16** | **Screen gutter**, section spacing, dialog padding |
| `xl` | 24 | Between major sections |
| `xxl` | 32 | Above a primary action, around empty states |
**One screen gutter: `lg` (16).** Every screen, every sheet, every dialog. This is the single
change with the most visible effect — it is what stops the text edge from jumping.
| Platform | How |
|---|---|
| Web | `--space-xs … --space-xxl` in `:root`; existing `6px`/`10px` paddings collapse to `xs`/`sm` |
| Android | `ui/theme/Spacing.kt`, reached as `MaterialTheme.spacing.lg` |
| iOS | `enum Spacing { static let lg: CGFloat = 16 }` in the shared design file |
---
## Corner radii
Defined by role, not by number, so the same object cannot drift apart between views.
| Token | Value | Applies to |
|---|---|---|
| `event` | 4 | Event pills in month, week, day, agenda, all-day strip — **all of them** |
| `field` | 8 | Text fields, list rows, small cards, popovers |
| `sheet` | 20 | Modals, bottom sheets, day preview |
| `full` | pill | Buttons, chips, FAB |
Today the event pill is 3 in month view, 4 in week view, 3 in the all-day strip and 10 in the
agenda on Android; the web uses 3. All become `event`.
---
## Type scale
Six steps. **Nothing below 11.**
| Token | Size | Use | Material | iOS |
|---|---|---|---|---|
| `caption` | 11 | Timestamps, metadata, hints | `labelSmall` | `.caption2` |
| `label` | 12 | Field labels, chips, tab labels | `labelMedium` | `.caption` |
| `body` | 13 | Default reading size | `bodySmall` | `.footnote` |
| `bodyLarge` | 14 | Primary list text, input text | `bodyMedium` | `.subheadline` |
| `title` | 18 | Screen and section titles | `titleMedium` | `.headline` |
| `headline` | 22 | Page headline, month name | `headlineSmall` | `.title2` |
**Android must configure `Typography()`** — today it is the raw Material default
(`ui/theme/Theme.kt:68`), which is why every text size in the app is an ad-hoc `fontSize = N.sp`
override at the call site.
**The one documented exception:** the month grid's overflow counter (`+N`) and week-number
label may use **10**. They sit in a dense grid where 11 does not fit at the current row height.
The 8 and 9 values used today (five places) are removed outright. If the month grid is ever
made height-adaptive, this exception goes away too.
---
## Hit targets
Minimum **48 × 48** for anything tappable, on every platform. A smaller icon is fine; the
touch area around it is not.
The top app bar is **64** tall with 48 targets. Android currently uses 50 with 40 targets,
which is the first thing a user sees and reads as cramped.
Every actionable icon has an accessibility label. Decorative icons — and only those — get
none. Android has 30 `contentDescription = null` today, several of them on buttons
(month back/forward, calendar visibility toggle, remove-reminder, password reveal).
---
## Component inventory
Every client implements these, named the same way, with the same anatomy. If a screen needs
something that is not on this list, it goes on the list first.
### `ListRow`
`[leading icon or colour dot] [title, one line, ellipsised] [optional subtitle] [trailing control]`
Vertical padding `md`, horizontal `lg`, full-width tap area, `field` radius when it sits on a
surface. Android should build this on `ListItem`; today the app has **zero** `Card` and **zero**
`ListItem` — nine separate hand-rolled variants instead.
### `SettingsRow`
A `ListRow` whose trailing control is a switch, a dropdown or a colour swatch. One
implementation, not four.
### `EmptyState`
`[title] [one sentence of body] [optional action]`, centred, `xxl` above. Every list has one.
Today only the agenda does it properly (`AgendaView.kt:49-62`) — copy that.
### `LoadingState`
One spinner, centred, same size everywhere. Never a different shape per screen. The keys
`settings.loading`, `profile.loading` and `accounts.loading` already exist in the translation
tables and are never rendered.
### `Feedback`
**Success and failure appear in one place per platform** — a snackbar on Android, a toast on
web, a transient banner on iOS. Never as loose text in the page flow, and never as the last
item of a scrolling list, where the user who caused the error cannot see it.
---
## States
Every list and every screen defines all four. A missing state is a bug, not an omission.
| State | Rule |
|---|---|
| **Loading** | Shown from the first frame, never a blank screen |
| **Empty** | Title plus one sentence; an action when there is a sensible one |
| **Error** | In the feedback slot above, with what failed and what to do |
| **Partial** | Stale data stays visible. Never replace loaded content with an empty list because one source failed — see `CalendarViewModel.kt:377-416` |
---
## Event rendering
The one object all three clients draw, and the one most likely to drift.
| | Rule |
|---|---|
| Radius | `event` (4) everywhere |
| All-day | Own strip above the time grid, packed into lanes |
| Timed, overlapping | **Side by side, never stacked.** Android's week and day views currently draw every event at full column width, so one of two 10:00 meetings is completely invisible (`TimeGridView.kt:156-169`) |
| Overflow | `+N` when the lane count is exceeded, with a way to see the rest |
| Colour | Per-event colour, else calendar colour; the group view's server-resolved colour wins |
| Title | The server-decorated title (birthday age, group prefix) wins over the raw one |
---
## Widgets
Home-screen widgets are **always native**: iOS requires WidgetKit with SwiftUI views, Android
requires Glance or RemoteViews. No cross-platform framework changes this. What is shared is
the **selection and the content**, defined here once.
| Widget | Shows | iOS | Android |
|---|---|---|---|
| `today` | Today's events | small | 2×2 |
| `upNext` | The next few events | medium | 4×2 |
| `month` | Month grid with event dots | medium | 4×4 |
iOS ships 13 widgets today; these three are the ones Android mirrors. The rest stay
iOS-only — that is fine, as long as the three above look and behave the same.
**Data path, both platforms:** the app writes a snapshot into shared storage; the widget reads
it and performs **no networking of its own**. The snapshot format is defined by `CalendarrCore`
on iOS and is the reference for Android's equivalent.
---
## Wording
The translation keys in `android/…/ui/L10n.kt` and `ios/…/Models/Localization.swift` are
deliberately kept identical — Android even converts `%@` to `%s` so the same table works. That
is a contract: **a key means the same thing on every platform**, and a new key is added to all
of them.
Nothing user-visible is hard-coded. Current violations, all of which show a German string to
an English user:
- `ColorPickerDialog.kt` — `"Abbrechen"`, `"OK"`, `"Hex"`
- `NotificationScheduler.kt` / `.swift` — every notification body
- The nine watch complication names on iOS
- Date patterns built without a `Locale`, and `is24HourView = true` forced
---
## Deliberately not unified
| | Why |
|---|---|
| Navigation | Bottom sheets and a drawer on Android, navigation stack and sheets on iOS |
| System font | Roboto / SF — using one font on both would look wrong on one of them |
| Controls | Switches, pickers, date entry stay platform-native |
| Gestures | Swipe-back on iOS, system back on Android |
| Widget families | Each platform has its own sizes; only the three above must correspond |
---
## How to check
There is no automated check — both apps have **zero tests**. So it is done by eye, and these
are the questions that catch real drift:
1. Open every screen in turn. Does the **left text edge** stay put?
2. Put the two apps side by side on the same screen. Same order, same words, same rows?
3. System font size to maximum. Does anything clip? Do icons grow with their text?
4. Device language to English. Any German left? Trigger a reminder and read it.
5. Two events at the same time, week view. Are **both** visible?
6. Turn the server off and save something. Does the message appear in the feedback slot?
7. Every list: force it empty. Is there an empty state, or just nothing?
+20 -1
View File
@@ -399,7 +399,26 @@ a { color: var(--primary); text-decoration: none; }
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
/* ── Attachments ─────────────────────────────────────── */
.ev-attach-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.ev-attach-list:not(:empty) { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
/* Looks like the text inputs above it rather than a button: same ground,
border, radius and padding, just left-aligned and in the muted text colour.
NOT .form-hint for the caption below — a later rule turns that class into a
bordered callout box, which is right for a warning and far too loud here. */
.ev-attach-add {
display: block; width: 100%; text-align: left;
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 14px;
color: var(--text-2);
font-size: 14px; font-family: inherit;
cursor: pointer; outline: none;
transition: border-color var(--transition), color var(--transition);
}
.ev-attach-add:hover:not(:disabled) { border-color: var(--text-3); color: var(--text-1); }
.ev-attach-add:focus-visible { border-color: var(--primary); }
.ev-attach-add:disabled { opacity: .5; cursor: default; }
.ev-attach-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; }
.ev-attach-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border: 1px solid var(--border); border-radius: 8px; }
.ev-attach-row:hover { background: var(--hover-highlight); }
+50 -2
View File
@@ -235,6 +235,14 @@
</div>
<div id="group-list-items"></div>
</div>
<!-- Invitations awaiting a decision -->
<div class="cal-list hidden" id="invite-list">
<div class="cal-list-header">
<span data-i18n="invites_title">Anfragen</span>
</div>
<div id="invite-list-items"></div>
</div>
</div>
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button>
</aside>
@@ -400,8 +408,8 @@
<div class="form-group" id="ev-attachments-group" style="display:none">
<label data-i18n="attachments">Anhänge</label>
<div id="ev-attachments-list" class="ev-attach-list"></div>
<button type="button" class="btn btn-ghost btn-sm" id="ev-attachment-add" data-i18n="attachment_add">Datei hinzufügen</button>
<div class="form-hint" data-i18n="attachment_hint">Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.</div>
<button type="button" class="ev-attach-add" id="ev-attachment-add" data-i18n="attachment_add">Datei hinzufügen</button>
<div class="ev-attach-hint" data-i18n="attachment_hint">Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.</div>
</div>
</div>
<div class="modal-footer">
@@ -904,6 +912,46 @@
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div>
<!-- Postfach für Einladungen -->
<h4 class="panel-title" style="margin-top:28px"><span data-i18n="invites_mailbox">Einladungs-Postfach</span></h4>
<p class="panel-desc" data-i18n="invites_mailbox_desc">Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.</p>
<div id="mailbox-status" class="panel-desc"></div>
<div class="form-group">
<label data-i18n="invites_imap_host">IMAP-Server</label>
<input type="text" id="mb-imap-host" spellcheck="false" autocapitalize="none" placeholder="imap.example.com" />
</div>
<div class="form-group">
<label data-i18n="invites_imap_user">Benutzername</label>
<input type="text" id="mb-imap-user" spellcheck="false" autocapitalize="none" />
</div>
<div class="form-group">
<label data-i18n="invites_imap_password">Passwort</label>
<input type="password" id="mb-imap-password" autocomplete="new-password" data-i18n-placeholder="invites_password_keep" placeholder="Unverändert lassen" />
</div>
<div class="form-group">
<label data-i18n="invites_imap_port">Port</label>
<input type="number" id="mb-imap-port" value="993" min="1" max="65535" />
</div>
<div class="form-group">
<label data-i18n="invites_imap_folder">Ordner</label>
<input type="text" id="mb-imap-folder" value="INBOX" spellcheck="false" />
</div>
<div class="form-group">
<label data-i18n="invites_poll_minutes">Abrufintervall (Minuten)</label>
<input type="number" id="mb-poll-minutes" value="5" min="1" max="1440" />
</div>
<label class="toggle-label" style="margin-bottom:8px">
<input type="checkbox" id="mb-imap-ssl" checked /> <span data-i18n="invites_imap_ssl">SSL/TLS</span>
</label>
<label class="toggle-label" style="margin-bottom:8px">
<input type="checkbox" id="mb-enabled" checked /> <span data-i18n="invites_enabled">Aktiv</span>
</label>
<div class="admin-theme-actions">
<button class="btn btn-primary btn-sm" id="mb-save" data-i18n="save">Speichern</button>
<button class="btn btn-secondary btn-sm" id="mb-poll" data-i18n="invites_poll_now">Jetzt abrufen</button>
<button class="btn btn-ghost btn-sm" id="mb-delete" data-i18n="invites_mailbox_remove">Postfach entfernen</button>
</div>
<!-- Instanz-Standardtheme -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
+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;
+190 -25
View File
@@ -153,9 +153,11 @@ export async function initCalendar() {
// initial fetchAndRender) so a reload reopens settings instead of stripping it.
uiSettingsOpen = urlState.settings === true;
setLang(settings.language || 'de');
applyTheme(settings);
applyFavicon(settings.primary_color);
// state.settings, not the raw server response: for any key whose sync flag
// is off the effective value is this browser's local one, and using the raw
// value here silently ignored it (and clobbered the setLang above).
applyTheme(state.settings);
applyFavicon(state.settings.primary_color);
updateViewButtons();
renderCalendarList();
renderMiniCal();
@@ -177,6 +179,7 @@ export async function initCalendar() {
bindSwipeNavigation();
handleHAOAuthReturn();
loadGroups();
loadInvites();
try {
await fetchAndRender();
} catch (e) {
@@ -828,16 +831,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 });
});
@@ -982,7 +987,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 {
@@ -2355,12 +2364,8 @@ function renderAttachmentRows() {
const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
const pending = state.pendingAttachments || [];
if (!existing.length && !pending.length) {
const empty = document.createElement('div');
empty.className = 'form-hint';
empty.textContent = t('attachment_none');
list.appendChild(empty);
}
// Nothing when empty — the reminders list does the same, and the caption
// under the button already says what can go here.
existing.forEach(a => list.appendChild(attachmentRow({
name: a.filename, size: a.size_bytes, contentType: a.content_type,
thumbId: a.has_thumb ? a.id : null,
@@ -3713,7 +3718,7 @@ function populateSettings() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const usersNavBtn = document.getElementById('settings-nav-users');
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
if (user.is_admin) { loadUsers(); loadAdminPanel(); loadMailbox(); }
// Activate panel from URL or fall back to first visible
const urlTab = readUrlState().stab;
@@ -3763,8 +3768,13 @@ function settingRowHtml(def) {
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
} else if (def.type === 'color') {
const hex = normalizeHex(val, baseColor(def.key));
// No own value means the row inherits the admin instance default. The
// field still shows that colour, but saving must send null — writing the
// hex back would freeze today's admin default into the user's row and
// later admin changes would stop reaching them.
const inherits = val == null ? ' data-inherit="1"' : '';
valueHtml = `<div class="settings-color-ctl">
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}"${inherits} />
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
</div>`;
@@ -3819,20 +3829,23 @@ function wireSettingRow(def) {
state.settings[def.key] = color;
applyTheme(state.settings);
};
const own = () => { delete hex.dataset.inherit; };
prev.addEventListener('click', async () => {
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
if (picked) { hex.value = picked.toUpperCase(); own(); apply(picked); }
});
hex.addEventListener('input', () => {
const norm = normalizeHex(hex.value, null);
if (norm) apply(norm);
if (norm) { own(); apply(norm); }
});
hex.addEventListener('change', () => {
const norm = normalizeHex(hex.value, baseColor(def.key));
hex.value = norm; apply(norm);
hex.value = norm; own(); apply(norm);
});
reset.addEventListener('click', () => {
// Back to inheriting, not "copy the current admin default in".
const d = baseColor(def.key);
hex.dataset.inherit = '1';
hex.value = d; apply(d);
});
} else if (def.type === 'icon') {
@@ -3862,7 +3875,9 @@ function readAppearanceTable() {
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
} else if (def.type === 'color') {
const el = document.querySelector(`[data-shex="${def.key}"]`);
out[def.key] = normalizeHex(el && el.value, baseColor(def.key));
out[def.key] = (el && el.dataset.inherit === '1')
? null // inherit the admin default
: normalizeHex(el && el.value, baseColor(def.key));
} else if (def.type === 'icon') {
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
@@ -4369,7 +4384,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);
@@ -4424,7 +4439,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>`;
@@ -4444,7 +4459,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>
@@ -4557,7 +4572,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++;
}
@@ -4569,7 +4585,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>`;
@@ -4614,7 +4630,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; }
@@ -4705,6 +4723,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); }
});
@@ -4724,6 +4745,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); }
@@ -4751,6 +4773,148 @@ function renderCalendarTable() {
});
}
// ── Einladungs-Postfach (nur Admin) ──────────────────────
function mailboxFields() {
return {
enabled: document.getElementById('mb-enabled').checked,
imap_host: document.getElementById('mb-imap-host').value.trim(),
imap_user: document.getElementById('mb-imap-user').value.trim(),
imap_password: document.getElementById('mb-imap-password').value || null,
imap_port: parseInt(document.getElementById('mb-imap-port').value) || 993,
imap_ssl: document.getElementById('mb-imap-ssl').checked,
imap_folder: document.getElementById('mb-imap-folder').value.trim() || 'INBOX',
poll_minutes: parseInt(document.getElementById('mb-poll-minutes').value) || 5,
};
}
function renderMailboxStatus(cfg) {
const el = document.getElementById('mailbox-status');
if (!el) return;
if (!cfg) { el.textContent = t('invites_mailbox_none'); return; }
// A mailbox that stopped working has to say so, or invitations simply stop
// arriving and nothing explains why.
if (cfg.last_error) {
el.textContent = `${t('invites_last_error')}: ${cfg.last_error}`;
el.style.color = 'var(--danger, #ea4335)';
} else {
el.textContent = cfg.last_run
? `${t('invites_last_run')}: ${new Date(cfg.last_run).toLocaleString()}`
: t('invites_never_run');
el.style.color = '';
}
}
async function loadMailbox() {
const host = document.getElementById('mb-imap-host');
if (!host) return;
let cfg = null;
try { cfg = await api.get('/invites/mailbox'); } catch { return; }
renderMailboxStatus(cfg);
if (!cfg) return;
host.value = cfg.imap_host || '';
document.getElementById('mb-imap-user').value = cfg.imap_user || '';
document.getElementById('mb-imap-port').value = cfg.imap_port || 993;
document.getElementById('mb-imap-folder').value = cfg.imap_folder || 'INBOX';
document.getElementById('mb-poll-minutes').value = cfg.poll_minutes || 5;
document.getElementById('mb-imap-ssl').checked = cfg.imap_ssl !== false;
document.getElementById('mb-enabled').checked = cfg.enabled !== false;
// Left blank on purpose: an empty field means "keep what is stored".
document.getElementById('mb-imap-password').value = '';
}
function wireMailbox() {
const save = document.getElementById('mb-save');
if (!save) return;
save.addEventListener('click', async () => {
try {
const cfg = await api.put('/invites/mailbox', mailboxFields());
document.getElementById('mb-imap-password').value = '';
renderMailboxStatus(cfg);
showToast(t('settings_saved'));
} catch (e) { showToast(e.message, true); }
});
document.getElementById('mb-poll').addEventListener('click', async () => {
try {
const r = await api.post('/invites/poll', {});
showToast(r.last_error ? r.last_error : t('invites_polled'), !!r.last_error);
await loadMailbox();
await loadInvites();
} catch (e) { showToast(e.message, true); }
});
document.getElementById('mb-delete').addEventListener('click', async () => {
if (!await confirmModal(t('invites_mailbox_remove_confirm'), { danger: true })) return;
try {
await api.delete('/invites/mailbox');
document.querySelectorAll('#settings-panel-users input[id^="mb-"]').forEach(el => {
if (el.type === 'checkbox') el.checked = true; else el.value = '';
});
renderMailboxStatus(null);
showToast(t('settings_saved'));
} catch (e) { showToast(e.message, true); }
});
}
// ── Anfragen ─────────────────────────────────────────────
async function loadInvites() {
const box = document.getElementById('invite-list');
if (!box) return;
let rows = [];
try { rows = await api.get('/invites'); } catch { rows = []; }
box.classList.toggle('hidden', rows.length === 0);
const items = document.getElementById('invite-list-items');
items.innerHTML = '';
for (const inv of rows) {
const row = document.createElement('div');
row.className = 'cal-item';
const name = document.createElement('span');
name.className = 'cal-item-name';
// textContent throughout: this text came from a stranger's mail.
name.textContent = inv.title;
name.title = `${fmtInviteWhen(inv)}${inv.organizer_name ? ' · ' + inv.organizer_name : ''}`;
row.appendChild(name);
if (inv.status === 'cancelled') {
const tag = document.createElement('span');
tag.className = 'cal-badge';
tag.textContent = t('invite_cancelled');
row.appendChild(tag);
row.appendChild(inviteBtn(inv, 'dismiss', '✕'));
} else {
row.appendChild(inviteBtn(inv, 'accept', '✓'));
row.appendChild(inviteBtn(inv, 'decline', '✕'));
}
items.appendChild(row);
}
}
function fmtInviteWhen(inv) {
try {
const d = new Date(inv.start);
return inv.all_day ? d.toLocaleDateString() : d.toLocaleString();
} catch { return inv.start; }
}
function inviteBtn(inv, action, label) {
const b = document.createElement('button');
b.className = 'icon-btn mini-btn';
b.textContent = label;
b.title = t(`invite_${action}`);
b.addEventListener('click', async () => {
try {
if (action === 'dismiss') {
await api.delete(`/invites/${inv.id}`);
} else {
await api.post(`/invites/${inv.id}/respond`, { action });
showToast(t(action === 'accept' ? 'invite_accepted' : 'invite_declined'));
}
await loadInvites();
if (action === 'accept') fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
return b;
}
async function loadUsers() {
try {
const users = await api.get('/users/');
@@ -4971,6 +5135,7 @@ function bindSettingsModal() {
// Admin panel (default-theme editor + branding) — buttons bound once.
bindAdminPanel();
wireMailbox();
// Theme export / import (client-side .theme files).
const themeExportBtn = document.getElementById('cfg-theme-export');
+270 -2
View File
@@ -1,5 +1,31 @@
const translations = {
de: {
invites_title: 'Anfragen',
invite_accept: 'Annehmen',
invite_decline: 'Ablehnen',
invite_dismiss: 'Entfernen',
invite_cancelled: 'Zurückgezogen',
invite_accepted: 'Einladung angenommen',
invite_declined: 'Einladung abgelehnt',
invites_mailbox: 'Einladungs-Postfach',
invites_mailbox_desc: 'Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.',
invites_mailbox_none: 'Kein Postfach konfiguriert',
invites_mailbox_remove: 'Postfach entfernen',
invites_mailbox_remove_confirm: 'Postfach wirklich entfernen? Bereits empfangene Anfragen bleiben erhalten.',
invites_imap_host: 'IMAP-Server',
invites_imap_user: 'Benutzername',
invites_imap_password: 'Passwort',
invites_imap_port: 'Port',
invites_imap_folder: 'Ordner',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Abrufintervall (Minuten)',
invites_poll_now: 'Jetzt abrufen',
invites_polled: 'Postfach abgerufen',
invites_password_keep: 'Unverändert lassen',
invites_last_run: 'Zuletzt abgerufen',
invites_last_error: 'Letzter Fehler',
invites_never_run: 'Noch nicht abgerufen',
// Common
save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen',
create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten',
@@ -176,7 +202,6 @@ const translations = {
attachments: 'Anhänge',
attachment_add: 'Datei hinzufügen',
attachment_hint: 'Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.',
attachment_none: 'Keine Anhänge',
attachment_uploading: 'Wird hochgeladen …',
attachment_deleted: 'Anhang gelöscht',
attachment_delete_confirm: "Anhang „{name}“ löschen?",
@@ -203,6 +228,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',
@@ -433,6 +459,32 @@ const translations = {
},
ch: {
invites_title: 'Aafrage',
invite_accept: 'Aanäh',
invite_decline: 'Ablehne',
invite_dismiss: 'Entfärne',
invite_cancelled: 'Zruggzoge',
invite_accepted: 'Iiladig aagnoh',
invite_declined: 'Iiladig abglehnt',
invites_mailbox: 'Iiladigs-Poschtfach',
invites_mailbox_desc: 'Es Poschtfach, wo Termin-Iiladige akömed. Sie erschiined als Aafrag und wärded erscht zu Termin, wänn du sie aanimmsch. Zuegordnet wird über d Teilnehmer-Adräss vo dr Iiladig — trag d Adräss, unter dere du iiglade wirsch, i dim Profil ii.',
invites_mailbox_none: 'Kes Poschtfach konfiguriert',
invites_mailbox_remove: 'Poschtfach entfärne',
invites_mailbox_remove_confirm: 'Poschtfach würkli entfärne? Scho empfangeni Aafrage blibed erhalte.',
invites_imap_host: 'IMAP-Server',
invites_imap_user: 'Benutzername',
invites_imap_password: 'Passwort',
invites_imap_port: 'Port',
invites_imap_folder: 'Ordner',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Abruefintervall (Minute)',
invites_poll_now: 'Jetz abruefe',
invites_polled: 'Poschtfach abgruefe',
invites_password_keep: 'Unverändert la',
invites_last_run: 'Zletscht abgruefe',
invites_last_error: 'Letschte Fähler',
invites_never_run: 'No nid abgruefe',
// Common
save: 'Speicherä', cancel: 'Abbräche', close: 'Schlüsse',
create: 'Erstellä', delete: 'Löschä', edit: 'Bearbeitä',
@@ -597,6 +649,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ä',
@@ -827,6 +880,32 @@ const translations = {
},
en: {
invites_title: 'Requests',
invite_accept: 'Accept',
invite_decline: 'Decline',
invite_dismiss: 'Dismiss',
invite_cancelled: 'Withdrawn',
invite_accepted: 'Invitation accepted',
invite_declined: 'Invitation declined',
invites_mailbox: 'Invitation mailbox',
invites_mailbox_desc: 'A mailbox that invitations arrive in. They show up as requests and only become events once you accept them. An invitation is matched by its attendee address — put the address people invite you under in your profile.',
invites_mailbox_none: 'No mailbox configured',
invites_mailbox_remove: 'Remove mailbox',
invites_mailbox_remove_confirm: 'Remove the mailbox? Requests already received are kept.',
invites_imap_host: 'IMAP server',
invites_imap_user: 'Username',
invites_imap_password: 'Password',
invites_imap_port: 'Port',
invites_imap_folder: 'Folder',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Enabled',
invites_poll_minutes: 'Check every (minutes)',
invites_poll_now: 'Check now',
invites_polled: 'Mailbox checked',
invites_password_keep: 'Leave unchanged',
invites_last_run: 'Last checked',
invites_last_error: 'Last error',
invites_never_run: 'Not checked yet',
// Common
save: 'Save', cancel: 'Cancel', close: 'Close',
create: 'Create', delete: 'Delete', edit: 'Edit',
@@ -1003,7 +1082,6 @@ const translations = {
attachments: 'Attachments',
attachment_add: 'Add file',
attachment_hint: 'Max. 10 MB per file, 10 files. PDF, images, text.',
attachment_none: 'No attachments',
attachment_uploading: 'Uploading …',
attachment_deleted: 'Attachment deleted',
attachment_delete_confirm: "Delete attachment “{name}”?",
@@ -1030,6 +1108,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',
@@ -1260,6 +1339,32 @@ const translations = {
},
fr: {
invites_title: 'Demandes',
invite_accept: 'Accepter',
invite_decline: 'Refuser',
invite_dismiss: 'Retirer',
invite_cancelled: 'Annulée',
invite_accepted: 'Invitation acceptée',
invite_declined: 'Invitation refusée',
invites_mailbox: 'Boîte aux lettres des invitations',
invites_mailbox_desc: "Une boîte dans laquelle arrivent les invitations. Elles apparaissent comme demandes et ne deviennent des événements qu'une fois acceptées. L'attribution se fait par l'adresse du participant — indiquez dans votre profil l'adresse sous laquelle on vous invite.",
invites_mailbox_none: 'Aucune boîte configurée',
invites_mailbox_remove: 'Supprimer la boîte',
invites_mailbox_remove_confirm: 'Supprimer la boîte ? Les demandes déjà reçues sont conservées.',
invites_imap_host: 'Serveur IMAP',
invites_imap_user: "Nom d'utilisateur",
invites_imap_password: 'Mot de passe',
invites_imap_port: 'Port',
invites_imap_folder: 'Dossier',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Activé',
invites_poll_minutes: 'Intervalle (minutes)',
invites_poll_now: 'Relever maintenant',
invites_polled: 'Boîte relevée',
invites_password_keep: 'Laisser inchangé',
invites_last_run: 'Dernier relevé',
invites_last_error: 'Dernière erreur',
invites_never_run: 'Jamais relevée',
// Common
save: 'Enregistrer', cancel: 'Annuler', close: 'Fermer',
create: 'Créer', delete: 'Supprimer', edit: 'Modifier',
@@ -1424,6 +1529,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',
@@ -1654,6 +1760,32 @@ const translations = {
},
it: {
invites_title: 'Richieste',
invite_accept: 'Accetta',
invite_decline: 'Rifiuta',
invite_dismiss: 'Rimuovi',
invite_cancelled: 'Ritirata',
invite_accepted: 'Invito accettato',
invite_declined: 'Invito rifiutato',
invites_mailbox: 'Casella degli inviti',
invites_mailbox_desc: "Una casella in cui arrivano gli inviti. Compaiono come richieste e diventano eventi solo quando li accetti. L'abbinamento avviene tramite l'indirizzo del partecipante — inserisci nel profilo l'indirizzo con cui vieni invitato.",
invites_mailbox_none: 'Nessuna casella configurata',
invites_mailbox_remove: 'Rimuovi casella',
invites_mailbox_remove_confirm: 'Rimuovere la casella? Le richieste già ricevute restano.',
invites_imap_host: 'Server IMAP',
invites_imap_user: 'Nome utente',
invites_imap_password: 'Password',
invites_imap_port: 'Porta',
invites_imap_folder: 'Cartella',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Attiva',
invites_poll_minutes: 'Intervallo (minuti)',
invites_poll_now: 'Controlla ora',
invites_polled: 'Casella controllata',
invites_password_keep: 'Lascia invariato',
invites_last_run: 'Ultimo controllo',
invites_last_error: 'Ultimo errore',
invites_never_run: 'Mai controllata',
// Common
save: 'Salva', cancel: 'Annulla', close: 'Chiudi',
create: 'Crea', delete: 'Elimina', edit: 'Modifica',
@@ -1818,6 +1950,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',
@@ -2048,6 +2181,32 @@ const translations = {
},
nl: {
invites_title: 'Verzoeken',
invite_accept: 'Accepteren',
invite_decline: 'Weigeren',
invite_dismiss: 'Verwijderen',
invite_cancelled: 'Ingetrokken',
invite_accepted: 'Uitnodiging geaccepteerd',
invite_declined: 'Uitnodiging geweigerd',
invites_mailbox: 'Uitnodigingen-postbus',
invites_mailbox_desc: 'Een postbus waarin uitnodigingen binnenkomen. Ze verschijnen als verzoek en worden pas afspraken zodra je ze accepteert. Toewijzing gebeurt via het deelnemersadres — zet het adres waarop je wordt uitgenodigd in je profiel.',
invites_mailbox_none: 'Geen postbus ingesteld',
invites_mailbox_remove: 'Postbus verwijderen',
invites_mailbox_remove_confirm: 'Postbus verwijderen? Reeds ontvangen verzoeken blijven staan.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Gebruikersnaam',
invites_imap_password: 'Wachtwoord',
invites_imap_port: 'Poort',
invites_imap_folder: 'Map',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Actief',
invites_poll_minutes: 'Interval (minuten)',
invites_poll_now: 'Nu ophalen',
invites_polled: 'Postbus opgehaald',
invites_password_keep: 'Ongewijzigd laten',
invites_last_run: 'Laatst opgehaald',
invites_last_error: 'Laatste fout',
invites_never_run: 'Nog niet opgehaald',
// Common
save: 'Opslaan', cancel: 'Annuleren', close: 'Sluiten',
create: 'Aanmaken', delete: 'Verwijderen', edit: 'Bewerken',
@@ -2212,6 +2371,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',
@@ -2442,6 +2602,32 @@ const translations = {
},
da: {
invites_title: 'Anmodninger',
invite_accept: 'Accepter',
invite_decline: 'Afvis',
invite_dismiss: 'Fjern',
invite_cancelled: 'Trukket tilbage',
invite_accepted: 'Invitation accepteret',
invite_declined: 'Invitation afvist',
invites_mailbox: 'Postkasse til invitationer',
invites_mailbox_desc: 'En postkasse hvor invitationer ankommer. De vises som anmodninger og bliver først til begivenheder, når du accepterer dem. Tilknytning sker via deltageradressen — angiv i din profil den adresse, du bliver inviteret på.',
invites_mailbox_none: 'Ingen postkasse konfigureret',
invites_mailbox_remove: 'Fjern postkasse',
invites_mailbox_remove_confirm: 'Fjern postkassen? Allerede modtagne anmodninger bevares.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Brugernavn',
invites_imap_password: 'Adgangskode',
invites_imap_port: 'Port',
invites_imap_folder: 'Mappe',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Interval (minutter)',
invites_poll_now: 'Hent nu',
invites_polled: 'Postkasse hentet',
invites_password_keep: 'Lad være uændret',
invites_last_run: 'Sidst hentet',
invites_last_error: 'Seneste fejl',
invites_never_run: 'Endnu ikke hentet',
// Common
save: 'Gem', cancel: 'Annullér', close: 'Luk',
create: 'Opret', delete: 'Slet', edit: 'Redigér',
@@ -2606,6 +2792,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',
@@ -2836,6 +3023,32 @@ const translations = {
},
no: {
invites_title: 'Forespørsler',
invite_accept: 'Godta',
invite_decline: 'Avslå',
invite_dismiss: 'Fjern',
invite_cancelled: 'Trukket tilbake',
invite_accepted: 'Invitasjon godtatt',
invite_declined: 'Invitasjon avslått',
invites_mailbox: 'Postkasse for invitasjoner',
invites_mailbox_desc: 'En postkasse der invitasjoner kommer inn. De vises som forespørsler og blir først avtaler når du godtar dem. Tilknytning skjer via deltakeradressen — legg inn adressen du blir invitert på i profilen din.',
invites_mailbox_none: 'Ingen postkasse satt opp',
invites_mailbox_remove: 'Fjern postkasse',
invites_mailbox_remove_confirm: 'Fjerne postkassen? Allerede mottatte forespørsler beholdes.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Brukernavn',
invites_imap_password: 'Passord',
invites_imap_port: 'Port',
invites_imap_folder: 'Mappe',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Intervall (minutter)',
invites_poll_now: 'Hent nå',
invites_polled: 'Postkasse hentet',
invites_password_keep: 'La stå uendret',
invites_last_run: 'Sist hentet',
invites_last_error: 'Siste feil',
invites_never_run: 'Ikke hentet ennå',
// Common
save: 'Lagre', cancel: 'Avbryt', close: 'Lukk',
create: 'Opprett', delete: 'Slett', edit: 'Rediger',
@@ -3000,6 +3213,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',
@@ -3230,6 +3444,32 @@ const translations = {
},
sv: {
invites_title: 'Förfrågningar',
invite_accept: 'Acceptera',
invite_decline: 'Avböj',
invite_dismiss: 'Ta bort',
invite_cancelled: 'Återkallad',
invite_accepted: 'Inbjudan accepterad',
invite_declined: 'Inbjudan avböjd',
invites_mailbox: 'Postlåda för inbjudningar',
invites_mailbox_desc: 'En postlåda dit inbjudningar kommer. De visas som förfrågningar och blir händelser först när du accepterar dem. Kopplingen sker via deltagaradressen — ange i din profil den adress du blir inbjuden på.',
invites_mailbox_none: 'Ingen postlåda konfigurerad',
invites_mailbox_remove: 'Ta bort postlåda',
invites_mailbox_remove_confirm: 'Ta bort postlådan? Redan mottagna förfrågningar behålls.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Användarnamn',
invites_imap_password: 'Lösenord',
invites_imap_port: 'Port',
invites_imap_folder: 'Mapp',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Intervall (minuter)',
invites_poll_now: 'Hämta nu',
invites_polled: 'Postlådan hämtad',
invites_password_keep: 'Lämna oförändrat',
invites_last_run: 'Senast hämtad',
invites_last_error: 'Senaste fel',
invites_never_run: 'Inte hämtad ännu',
// Common
save: 'Spara', cancel: 'Avbryt', close: 'Stäng',
create: 'Skapa', delete: 'Radera', edit: 'Redigera',
@@ -3394,6 +3634,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',
@@ -3624,6 +3865,32 @@ const translations = {
},
fi: {
invites_title: 'Pyynnöt',
invite_accept: 'Hyväksy',
invite_decline: 'Hylkää',
invite_dismiss: 'Poista',
invite_cancelled: 'Peruttu',
invite_accepted: 'Kutsu hyväksytty',
invite_declined: 'Kutsu hylätty',
invites_mailbox: 'Kutsujen postilaatikko',
invites_mailbox_desc: 'Postilaatikko, johon kutsut saapuvat. Ne näkyvät pyyntöinä ja muuttuvat tapahtumiksi vasta kun hyväksyt ne. Kohdistus tehdään osallistujan osoitteen perusteella — lisää profiiliisi osoite, jolla sinut kutsutaan.',
invites_mailbox_none: 'Postilaatikkoa ei määritetty',
invites_mailbox_remove: 'Poista postilaatikko',
invites_mailbox_remove_confirm: 'Poistetaanko postilaatikko? Jo vastaanotetut pyynnöt säilyvät.',
invites_imap_host: 'IMAP-palvelin',
invites_imap_user: 'Käyttäjätunnus',
invites_imap_password: 'Salasana',
invites_imap_port: 'Portti',
invites_imap_folder: 'Kansio',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Käytössä',
invites_poll_minutes: 'Hakuväli (minuuttia)',
invites_poll_now: 'Hae nyt',
invites_polled: 'Postilaatikko haettu',
invites_password_keep: 'Jätä ennalleen',
invites_last_run: 'Viimeksi haettu',
invites_last_error: 'Viimeisin virhe',
invites_never_run: 'Ei vielä haettu',
// Common
save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje',
create: 'Luo', delete: 'Poista', edit: 'Muokkaa',
@@ -3788,6 +4055,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',
+1 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v95';
export const APP_VERSION = 'v97';
+1 -1
View File
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v41';
const CACHE_VERSION = 'calendarr-v43';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
@@ -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 {
+48 -1
View File
@@ -18,6 +18,48 @@ struct EventPerson: Hashable {
}
}
/// A file attached to an event.
///
/// Deliberately declared here rather than in a file of its own: a new .swift
/// file added outside Xcode is not a member of any target and simply would not
/// compile in, which is invisible until someone opens the project on a Mac.
struct EventAttachment: Identifiable, Hashable {
let id: Int
let filename: String
let contentType: String
let sizeBytes: Int
let hasThumbnail: Bool
let uploadedBy: EventPerson?
var isImage: Bool { contentType.hasPrefix("image/") }
/// SF Symbol for the file kind — no network, unlike the thumbnail.
var symbolName: String {
if contentType == "application/pdf" { return "doc.richtext" }
if isImage { return "photo" }
return "doc.text"
}
var displaySize: String {
ByteCountFormatter.string(fromByteCount: Int64(sizeBytes), countStyle: .file)
}
static func from(json: [String: Any]) -> EventAttachment? {
guard
let id = json["id"] as? Int,
let filename = json["filename"] as? String
else { return nil }
return EventAttachment(
id: id,
filename: filename,
contentType: json["content_type"] as? String ?? "application/octet-stream",
sizeBytes: json["size_bytes"] as? Int ?? 0,
hasThumbnail: json["has_thumb"] as? Bool ?? false,
uploadedBy: EventPerson.from(json["uploaded_by"])
)
}
}
/// A partial-sync failure reported alongside an otherwise-successful
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
/// expired credentials) even though it's still enabled. Distinct from a
@@ -70,6 +112,10 @@ struct CalEvent: Identifiable, Hashable {
// True for events from a birthday calendar — clients show a cake icon and the
// server bakes the age into `displayTitle`.
var isBirthday: Bool = false
// How many files hang off this event. The list payload carries only the
// count; the files themselves are fetched when the detail sheet opens.
// Absent on non-local sources and on busy-masked private events, so 0.
var attachmentCount: Int = 0
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor }
@@ -139,7 +185,8 @@ struct CalEvent: Identifiable, Hashable {
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false,
isBirthday: json["is_birthday"] as? Bool ?? false
isBirthday: json["is_birthday"] as? Bool ?? false,
attachmentCount: json["attachment_count"] as? Int ?? 0
)
}
}
+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
@@ -259,6 +259,10 @@ private let strings: [String: [String: String]] = [
"event.title_placeholder": "Titel",
"event.allday": "Ganztägig",
"event.private": "Privat",
"event.attachments": "Anhänge",
"event.attachment_add": "Datei hinzufügen",
"event.attachment_hint": "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
"event.attachment_failed": "Anhänge konnten nicht vollständig gespeichert werden.",
"event.start": "Start",
"event.end": "Ende",
"event.location": "Ort",
@@ -293,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",
@@ -652,6 +660,10 @@ private let strings: [String: [String: String]] = [
"event.title_placeholder": "Title",
"event.allday": "All-day",
"event.private": "Private",
"event.attachments": "Attachments",
"event.attachment_add": "Add file",
"event.attachment_hint": "Max. 10 MB per file, 10 files. PDF, images, text.",
"event.attachment_failed": "Attachments could not be saved completely.",
"event.start": "Start",
"event.end": "End",
"event.location": "Location",
@@ -686,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",
+78 -1
View File
@@ -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
}
@@ -673,6 +674,82 @@ class CalendarrAPI {
// MARK: – iCal import / export
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
// MARK: - Attachments
/// Files on an event. Only local events can have any; the server answers
/// 404 for anything else, which surfaces as `serverError`.
func listAttachments(eventUid: String) async throws -> [EventAttachment] {
let path = "/api/local/events/\(eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid)/attachments"
let data = try await request(path)
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] ?? []
return arr.compactMap { EventAttachment.from(json: $0) }
}
/// Upload one file. The body is built by hand, exactly like `importICS`.
func uploadAttachment(eventUid: String, fileURL: URL) async throws -> EventAttachment {
// A URL from `.fileImporter` is security-scoped: without this the read
// fails outside the app's own container.
let scoped = fileURL.startAccessingSecurityScopedResource()
defer { if scoped { fileURL.stopAccessingSecurityScopedResource() } }
let fileData = try Data(contentsOf: fileURL)
return try await uploadAttachment(eventUid: eventUid,
data: fileData,
filename: fileURL.lastPathComponent)
}
func uploadAttachment(eventUid: String, data fileData: Data, filename: String) async throws -> EventAttachment {
let encodedUid = eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid
guard let url = URL(string: baseURL + "/api/local/events/\(encodedUid)/attachments") else {
throw APIError.invalidURL
}
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var bodyData = Data()
let name = filename.isEmpty ? "anhang" : filename
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\n".data(using: .utf8)!)
// The server sniffs the bytes and ignores this header, but something
// has to be sent for the part to be well formed.
bodyData.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
bodyData.append(fileData)
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = bodyData
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 { throw APIError.unauthorized }
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
?? "Fehler \(status)"
throw APIError.serverError(msg)
}
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let att = EventAttachment.from(json: json) else {
throw APIError.serverError("Unerwartete Antwort")
}
return att
}
func deleteAttachment(id: Int) async throws {
_ = try await request("/api/local/attachments/\(id)", method: "DELETE")
}
/// Raw bytes, for saving or sharing.
func downloadAttachment(id: Int) async throws -> Data {
try await request("/api/local/attachments/\(id)")
}
/// Small JPEG preview, or nil when there is none. Never throws: a missing
/// thumbnail just means the row shows its type icon.
func attachmentThumbnail(id: Int) async -> Data? {
try? await request("/api/local/attachments/\(id)/thumb")
}
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
let fileData = try Data(contentsOf: fileURL)
@@ -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,12 @@
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.
private struct SharedFile: Identifiable {
let id = UUID()
let url: URL
}
struct EventDetailSheet: View {
let event: CalEvent
@@ -15,6 +23,10 @@ struct EventDetailSheet: View {
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var showCopySheet = false
@State private var attachments: [EventAttachment] = []
@State private var thumbnails: [Int: UIImage] = [:]
@State private var loadingAttachments = false
@State private var sharedFile: SharedFile?
private let timeFmt: DateFormatter = {
let f = DateFormatter()
@@ -111,6 +123,43 @@ struct EventDetailSheet: View {
}
}
// Attachments exist on local events only; the payload carries
// just the count, so the list is fetched when the sheet opens.
if event.source == "local" && event.attachmentCount > 0 {
Section(L10n.t("event.attachments", appLang)) {
if loadingAttachments && attachments.isEmpty {
ProgressView()
}
ForEach(attachments) { att in
Button {
Task { await shareAttachment(att) }
} label: {
HStack(spacing: 10) {
if let image = thumbnails[att.id] {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
Image(systemName: att.symbolName)
.frame(width: 32)
.foregroundStyle(.secondary)
}
Text(att.filename)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(att.displaySize)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
}
}
if !store.writableCalendars.isEmpty {
Section {
Button {
@@ -170,9 +219,42 @@ struct EventDetailSheet: View {
await onDone(nil, true)
}
}
.task(id: event.id) { await loadAttachments() }
.sheet(item: $sharedFile) { shared in
ActivityView(items: [shared.url])
}
}
}
private func loadAttachments() async {
guard event.source == "local", event.attachmentCount > 0 else {
attachments = []
return
}
loadingAttachments = true
defer { loadingAttachments = false }
guard let loaded = try? await api.listAttachments(eventUid: event.id) else { return }
attachments = loaded
// Thumbnails are small server-rendered JPEGs, so decoding them here is
// cheap and needs no image-loading dependency.
for att in loaded where att.hasThumbnail {
if let data = await api.attachmentThumbnail(id: att.id),
let image = UIImage(data: data) {
thumbnails[att.id] = image
}
}
}
/// Download to a temporary file and hand it to the share sheet, so the user
/// can save it to Files or open it in another app.
private func shareAttachment(_ att: EventAttachment) async {
guard let data = try? await api.downloadAttachment(id: att.id) else { return }
let name = att.filename.isEmpty ? "anhang" : att.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
guard (try? data.write(to: url)) != nil else { return }
sharedFile = SharedFile(url: url)
}
private func deleteEvent() async {
isDeleting = true
do {
@@ -183,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,4 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
import EventKit
struct EventEditorSheet: View {
let api: CalendarrAPI
@@ -23,11 +25,29 @@ struct EventEditorSheet: View {
@State private var isPrivate = false
@State private var reminders: [Int] = []
@State private var isSaving = false
// Files picked before the event exists are staged and uploaded after it is
// created; existing ones are removed only on save, so Cancel really cancels.
@State private var stagedFiles: [URL] = []
@State private var existingAttachments: [EventAttachment] = []
@State private var removedAttachmentIds: Set<Int> = []
@State private var showFileImporter = false
@State private var error = ""
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 }
}
@@ -37,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)
}
@@ -73,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 {
@@ -114,6 +136,46 @@ struct EventEditorSheet: View {
}
}
.disabled(remindersDisabled)
Section {
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
HStack {
Image(systemName: att.symbolName).foregroundStyle(.secondary)
Text(att.filename).lineLimit(1).truncationMode(.middle)
Spacer()
Text(att.displaySize).font(.caption).foregroundStyle(.secondary)
Button(role: .destructive) {
removedAttachmentIds.insert(att.id)
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.borderless)
}
}
ForEach(stagedFiles, id: \.self) { url in
HStack {
Image(systemName: "doc.badge.plus").foregroundStyle(Color.accentColor)
Text(url.lastPathComponent).lineLimit(1).truncationMode(.middle)
Spacer()
Button(role: .destructive) {
stagedFiles.removeAll { $0 == url }
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.borderless)
}
}
Button {
showFileImporter = true
} label: {
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))
}
}
Section(L10n.t("event.color_section", appLang)) {
@@ -161,6 +223,15 @@ struct EventEditorSheet: View {
}
}
.onAppear { setup() }
.task { await loadExistingAttachments() }
.fileImporter(isPresented: $showFileImporter,
allowedContentTypes: Self.allowedAttachmentTypes,
allowsMultipleSelection: true) { result in
guard case .success(let urls) = result else { return }
for url in urls where attachmentSlotsUsed < 10 {
stagedFiles.append(url)
}
}
.onChange(of: startDate) { oldStart, newStart in
guard newStart >= endDate else { return }
let duration = endDate.timeIntervalSince(oldStart)
@@ -169,6 +240,42 @@ struct EventEditorSheet: View {
}
}
/// Mirrors the server allowlist. `.webP` needs iOS 14, which is well below
/// the deployment target; markdown has no system-provided type.
private static var allowedAttachmentTypes: [UTType] {
var types: [UTType] = [.pdf, .png, .jpeg, .gif, .plainText, .commaSeparatedText]
if let webp = UTType("org.webmproject.webp") { types.append(webp) }
if let md = UTType("net.daringfireball.markdown") { types.append(md) }
return types
}
private var attachmentSlotsUsed: Int {
existingAttachments.filter { !removedAttachmentIds.contains($0.id) }.count + stagedFiles.count
}
private func loadExistingAttachments() async {
guard let ev = editingEvent, ev.source == "local", ev.attachmentCount > 0 else { return }
existingAttachments = (try? await api.listAttachments(eventUid: ev.id)) ?? []
}
/// Applied after the event itself was saved. A failure here leaves the
/// event in place — report it rather than claiming everything worked.
private func applyAttachmentChanges(uid: String) async {
var failed = 0
for id in removedAttachmentIds {
do { try await api.deleteAttachment(id: id) } catch { failed += 1 }
}
for url in stagedFiles {
do { _ = try await api.uploadAttachment(eventUid: uid, fileURL: url) }
catch { failed += 1 }
}
if failed > 0 {
error = L10n.t("event.attachment_failed", appLang)
}
stagedFiles = []
removedAttachmentIds = []
}
private func setup() {
if let ev = editingEvent {
title = ev.title
@@ -187,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
}
@@ -232,6 +341,7 @@ struct EventEditorSheet: View {
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
await applyAttachmentChanges(uid: ev.id)
case "homeassistant":
// No update API exists – delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -240,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,
@@ -249,10 +363,12 @@ struct EventEditorSheet: View {
} else {
switch cal.source {
case "local":
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
let created = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
// Only now does a uid exist to attach the staged files to.
await applyAttachmentChanges(uid: created.id)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
@@ -261,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,39 +88,44 @@ struct CalendarFilterContent: View {
@ViewBuilder
private func row(_ cal: CalRow) -> some View {
let isVisible = !hidden.contains(cal.id)
Button {
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
store.setCalendarHidden(cal.id, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: cal.colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(cal.name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
if cal.readOnly {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
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)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: cal.colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(cal.name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
if cal.readOnly {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .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())
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
.buttonStyle(.plain)
// 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))