M4 Calendar: publish public .ics links, subscriptions, multi-calendar settings

Calendar (builtin plugin):
- Publish read-only public .ics feed per calendar via vault-sealed token
  (full-detail or free/busy-only); unauthenticated GET /public/{token},
  404 on bad/absent token. Migration calendar0004 adds publish_token,
  publish_busy, subscription_url + partial unique index.
- Subscribe to external iCal (.ics) URLs (SSRF-guarded) as read-only
  calendars; CSRF-guarded POST /subscriptions.
- Settings > Calendars: create/edit/delete, publish toggles with live
  shareable link + copy, Add-account stubs (Google/iCloud/CalDAV, deferred),
  Subscriptions section; default calendar + default reminders; auto-detected
  time zone; color-dropdown contrast fix; 3x-larger edit arrow.
- Left panel: shared Mail-parity sidebar template (.wm-side-*), calendar
  search panel, font/spacing tuning, per-section "+" create shortcut.

Frontend: CalendarOut publish fields, api publish/subscribe, settings keys.
Backend: settings default_calendar + reminder lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-01 20:22:53 +02:00
co-authored by Claude Opus 4.8
parent 8c634f7967
commit e2bef3c41d
20 changed files with 1329 additions and 88 deletions
+19
View File
@@ -26,6 +26,9 @@ class SettingsOut(BaseModel):
tz: str = "UTC"
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/
default_calendar: str = "" # calendar id new events default to (blank => first)
default_reminders_timed: list[int] = [] # minutes-before defaults for timed events
default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events
allowed_image_senders: list[str] = []
@@ -34,6 +37,9 @@ class SettingsPatch(BaseModel):
tz: str | None = None
time_format: str | None = None
jitsi_server: str | None = None
default_calendar: str | None = None
default_reminders_timed: list[int] | None = None
default_reminders_allday: list[int] | None = None
class QuotaOut(BaseModel):
@@ -56,6 +62,10 @@ def add_allowed_image_sender(user: User, email: str) -> None:
user.settings = {**user.settings, "allowed_image_senders": current}
def _int_list(value: object) -> list[int]:
return [int(x) for x in value] if isinstance(value, list) else []
def _settings_out(user: User) -> SettingsOut:
s = user.settings
return SettingsOut(
@@ -63,6 +73,9 @@ def _settings_out(user: User) -> SettingsOut:
tz=str(s.get("tz", "UTC")),
time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")),
default_calendar=str(s.get("default_calendar", "")),
default_reminders_timed=_int_list(s.get("default_reminders_timed")),
default_reminders_allday=_int_list(s.get("default_reminders_allday")),
allowed_image_senders=sorted(allowed_image_senders(user)),
)
@@ -92,6 +105,12 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["time_format"] = body.time_format
if body.jitsi_server is not None:
updates["jitsi_server"] = body.jitsi_server.strip()
if body.default_calendar is not None:
updates["default_calendar"] = body.default_calendar
if body.default_reminders_timed is not None:
updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed]
if body.default_reminders_allday is not None:
updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday]
user.settings = updates
await db.commit()
return _settings_out(user)