Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

reloadVisible had no callers left and is gone.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 05:54:05 +02:00
34 changed files with 760 additions and 96 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.
@@ -357,7 +357,7 @@ class CalendarRepository @Inject constructor(
val body = jsonBody("reminders_enabled" to enabled)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
"local" -> api.setLocalCalendarReminders(calendarId, body).ensureSuccess()
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
@@ -192,6 +192,11 @@ interface CalendarrApi {
@PUT("api/local/calendars/{id}/color")
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// Reminder-only update that works for owners AND share recipients
// (recipients get their own per-user mute; owners flip the calendar's flag).
@PUT("api/local/calendars/{id}/reminders")
suspend fun setLocalCalendarReminders(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
@@ -490,6 +490,11 @@ class CalendarViewModel @Inject constructor(
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
// Needed for the reminder flag, which exists on all five sources. A
// failed fetch yields an empty list, which simply reconciles nothing —
// never a wrongly cleared mute.
val local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
val ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList())
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
fun apply(source: String, id: Int, serverHidden: Boolean) {
@@ -500,9 +505,25 @@ class CalendarViewModel @Inject constructor(
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
if (banished == settingsStore.banishedCalendarKeys) return false
settingsStore.banishedCalendarKeys = banished
_state.update { it.copy(banishedKeys = banished) }
// Reminders were only ever pushed app -> server, so a mute set on the
// web or on iOS never arrived here.
val muted = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
fun applyReminders(source: String, id: Int, enabled: Boolean) {
val key = calendarKey(source, id.toString())
if (enabled) muted.remove(key) else muted.add(key)
}
local.forEach { applyReminders("local", it.id, it.remindersEnabled) }
ical.forEach { applyReminders("ical", it.id, it.remindersEnabled) }
caldav.forEach { acc -> acc.calendars?.forEach { applyReminders("caldav", it.id, it.remindersEnabled) } }
google.forEach { acc -> acc.calendars?.forEach { applyReminders("google", it.id, it.remindersEnabled) } }
ha.forEach { acc -> acc.calendars?.forEach { applyReminders("homeassistant", it.id, it.remindersEnabled) } }
val banishedChanged = banished != settingsStore.banishedCalendarKeys
val mutedChanged = muted != settingsStore.reminderDisabledCalendarKeys
if (!banishedChanged && !mutedChanged) return false
if (banishedChanged) settingsStore.banishedCalendarKeys = banished
if (mutedChanged) settingsStore.reminderDisabledCalendarKeys = muted
_state.update { it.copy(banishedKeys = banished, reminderDisabledKeys = muted) }
return true
}
@@ -473,8 +473,10 @@ private fun ReminderRow(
}
}
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
* should not offer files the upload would reject. */
// Mirrors the server allowlist. The catch-all wildcard type is deliberately
// absent — the picker should not offer files the upload would reject.
// (Written as line comments: spelling that wildcard inside a block comment
// closes it early, which is what used to break this file.)
private val ATTACHMENT_MIME_TYPES = arrayOf(
"application/pdf",
"image/png", "image/jpeg", "image/webp", "image/gif",
+1 -1
View File
@@ -19,7 +19,7 @@ Repo: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git
Builds:
- versionName: 1.1.0
versionCode: 2
commit: v1.1.0
commit: dd347a07f1c7c5fc55cc8bfb7fdfc5c5c020793f
subdir: app
gradle:
- yes
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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