Compare commits
14
Commits
ab0c64a9e1
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa795fde76 | ||
|
|
28c24bc3cd | ||
|
|
a153eea8f9 | ||
|
|
93297319a3 | ||
|
|
2bdf134d9c | ||
|
|
96cbbb0dc5 | ||
|
|
25f015a15b | ||
|
|
c811486ea7 | ||
|
|
2db4b42fa1 | ||
|
|
75ae038529 | ||
|
|
5977ad0928 | ||
|
|
10686cf383 | ||
|
|
4816a33980 | ||
|
|
9f864617c2 |
@@ -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>
|
||||
|
||||
|
||||
+24
-3
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -5,6 +5,9 @@ pluginManagement {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
Binary file not shown.
+3
-3
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user