Compare commits

..

3 Commits

Author SHA1 Message Date
Scarriffle
58faf3876c fix: hartcodiertes Blau bei "heute"-Spalte & Admin-Badge entfernt
- .month-col.today nutzt jetzt --today-color statt fixem Blau
  (Spaltenhintergrund passt nun zum Tageskreis / Theme-Farbe)
- .badge-admin nutzt --primary-dim statt fixem Blau

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:07:20 +02:00
Scarriffle
639d7f3c9c style: Feinschliff – Event-Popup, KW-Anzeige, Termin-Formular
- Event-Popup: Kopf neu ausgerichtet (Titel umbricht sauber, Aktions-
  Icons oben rechts statt gequetscht), breiter, weicherer Rahmen
- Kalenderwoche: KW-Badge in Wochen- & Monatsansicht vertikal zentriert
- Formulare: Feld-Labels nicht mehr in Großbuchstaben (moderner),
  Modal-Kopf kräftiger, weichere Trennlinien in Modals

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:50:44 +02:00
Scarriffle
a60c27f66f style: UI modernisiert – weniger Tabellen-Look, mehr Tiefe
- Größere Radien (Karten 14px, Inputs 9px, Event-Chips 6px)
- Geschichtete Dark-Flächen + weiche Schatten statt harter 1px-Linien
- Primär-Buttons mit dezentem Verlauf/Glow, segmentierter View-Switcher
- Sanfte Hover-/Press-/Modal-Animationen (respektiert prefers-reduced-motion)
- Fokus-Ringe für Buttons/Inputs (A11y), feinerer System-Font-Stack
- Fix: --bg-2 / --bg-card definiert (Quartalskarten & Wiederholungs-Buttons)

Theme-Variablen (Farben/Kontrast aus den Einstellungen) bleiben unangetastet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:37:54 +02:00
47 changed files with 944 additions and 12569 deletions

View File

@@ -1,66 +0,0 @@
# Theme parameters (Web)
Calendarr's web client lets you customise the colour theme under
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
it across your devices or keep it device-local — see
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
You can also **export** the current theme to a `<date>_<time>.theme` file and
**import** one later. A `.theme` file is plain JSON:
```json
{
"_format": "calendarr-theme",
"_version": 1,
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
"exported_at": "2026-07-20T14:33:00.000Z",
"settings": {
"primary_color": "#4285F4",
"hover_highlight_color": "#2A2A38",
"...": "..."
}
}
```
A theme may be **partial** — delete any keys you don't want and only the
remaining ones are applied. Importing writes **only the parameters present in
the file** (keys whose sync toggle is on are also pushed to the server; the rest
update this browser only); omitted parameters are left untouched. If the file
contains parameters this version doesn't recognise, you're asked whether to
import the rest anyway.
## Colour parameters
Source of truth for the defaults: `DEFAULT_COLORS` in
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
untouched reproduces the previous automatic look; setting it overrides that.
| Key | What it colours | Default |
|---|---|---|
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#58B900` |
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
| `bg_color` | App background | `#000000` |
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#2B2B2B` |
| `line_color` | Borders and grid lines | `#3B3B3D` |
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#477650` |
Notes:
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
the chosen colour (so content stays readable). The colour you pick is the base
hue; the swatch shows the full colour.
- `day_bg_color` defaults to the app background. If you set a custom
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
- The other clients (iOS / Android) currently ignore the fine-grained element
colours; they are stored and synced by the server but only the web client
renders them.

View File

@@ -1,63 +0,0 @@
# Settings sync contract (Web / iOS / Android)
> For a human-facing description of each theme colour parameter (and the
> `.theme` import/export format), see [../THEME.md](../THEME.md).
Per-setting, cross-device synchronisation of user settings. The **server is the
sole authority** for *which* settings sync; clients must not duplicate that logic.
This document is the shared contract all three clients implement identically.
## Canonical keys & default flags
Source of truth: `DEFAULT_SYNC` in `backend/routers/settings_router.py`. Keys use
the server's snake_case field names.
| Key | Kind | Default sync |
|---|---|---|
| `default_view` | enum | ON |
| `week_start_day` | enum | ON |
| `dim_past_events` | bool | ON |
| `hour_height` | enum(int) | ON |
| `primary_color` `accent_color` `today_color` `text_color` `line_color` `bg_color` `month_divider_color` `month_label_color` | color hex | ON |
| `default_event_duration_minutes` | enum(int) | ON |
| `default_reminder_minutes` | enum(int, null=off) | ON |
| `language` | enum | **OFF** |
| `share_calendar_icon` | icon key | **OFF** |
| `cache_months` | enum(int) | **OFF** |
| `month_view_paged` | bool | **OFF** |
Settings **not** in this list are never synced by this mechanism:
- Account-wide settings (`private_event_visibility`, `group_visible_calendar_id`,
`directory_hidden`) — one value per account, always identical everywhere; they
keep their existing dedicated endpoints/UI, not a sync toggle.
- Platform-exclusive device prefs (e.g. iOS `liquid_glass`) — stay device-local.
- Identity/security, calendar/account management, admin.
## API
- `GET /api/settings/` returns every value **plus** `sync_flags`: a fully-resolved
`{key: bool}` map covering exactly the keys above (stored overrides on top of
`DEFAULT_SYNC`). Clients read this map verbatim — no client-side defaults.
- `PUT /api/settings/` accepts a partial `sync_flags` map (merged account-wide,
unknown keys ignored, untouched flags preserved) and partial value fields
(`exclude_unset`; `text_color`/`line_color`/`bg_color`/… treated as
nullable-reset per `NULLABLE_OVERRIDES`).
## Client rules
Each client keeps a **local copy** of every syncable value (UserDefaults /
DataStore-SharedPreferences / localStorage) so that "not synced" works per device.
1. **On login / launch / foreground:** `GET /api/settings/` → values + `sync_flags`.
2. **Pull:** for each syncable key, if `sync_flags[key]` is ON, adopt the server
value into the local copy; if OFF, keep the local value.
3. **Push (debounced, read-modify-write):** start from the current server snapshot,
overwrite only keys whose flag is ON with the local value, `PUT`. Never push a
key whose flag is OFF.
4. **Toggle a flag ON:** set the flag true **and** push this device's current local
value (it becomes the shared value). **OFF:** set false; keep the local value.
5. **Global "share everything":** set all syncable flags true and push all local
values. Global off: set all false.
The flag map itself is always account-wide and always fetched fresh; it is what a
client consults to decide what to send/receive.

View File

@@ -1,63 +0,0 @@
"""Shared helpers for CalDAV publishing of local calendars.
Publishing is opt-in per calendar: a published calendar gets a secret
``dav_token`` and is reachable as a two-way CalDAV collection at
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
changes; each event carries an ``etag`` that changes on write. Rotating the
token revokes existing subscriptions.
"""
from __future__ import annotations
import os
import secrets
import uuid
def new_token() -> str:
"""A URL-safe, unguessable token used as the CalDAV collection path."""
return secrets.token_urlsafe(24)
def new_tag() -> str:
"""A fresh ctag/etag value."""
return uuid.uuid4().hex
def bump_dav(cal, event=None) -> None:
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
Safe to call unconditionally on every local-event write — it only refreshes
opaque change tags, so unpublished calendars are unaffected.
"""
if cal is not None:
cal.dav_ctag = new_tag()
if event is not None:
event.etag = new_tag()
def public_base(request) -> str:
"""Public origin (scheme://host) as clients actually reach us.
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
ones.
"""
env = os.environ.get("PUBLIC_BASE_URL")
if env:
return env.rstrip("/")
h = request.headers
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
return f"{proto}://{host}"
def caldav_url(request, token: str) -> str:
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
return f"{public_base(request)}/dav/{token}/"
def caldav_login_url(request) -> str:
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
return f"{public_base(request)}/caldav/"

View File

@@ -1,205 +0,0 @@
"""iCal (.ics) import/export for local calendars.
Reuses the already-installed ``icalendar`` library. The parser produces dicts
matching the LocalEvent storage shape (ISO strings, comma-separated EXDATE);
the generator emits a VCALENDAR with ORGANIZER, RRULE, etc.
"""
from __future__ import annotations
import logging
import uuid
from datetime import date, datetime, timedelta, timezone
from icalendar import Calendar, Event, vCalAddress, vRecur, vText
logger = logging.getLogger(__name__)
def _rrule_to_str(component) -> str | None:
prop = component.get("RRULE")
if not prop:
return None
return prop.to_ical().decode("utf-8")
def _exdate_to_csv(component) -> str | None:
"""Collect EXDATE values as comma-separated YYYYMMDD strings."""
exdate = component.get("EXDATE")
if not exdate:
return None
items = exdate if isinstance(exdate, list) else [exdate]
out = []
for ex in items:
dts = getattr(ex, "dts", None) or []
for d in dts:
val = d.dt
if isinstance(val, datetime):
out.append(val.strftime("%Y%m%d"))
elif isinstance(val, date):
out.append(val.strftime("%Y%m%d"))
return ",".join(out) if out else None
def _organizer_name(component) -> str | None:
org = component.get("ORGANIZER")
if not org:
return None
# CN parameter holds the display name; fall back to the mailto address.
try:
cn = org.params.get("CN")
if cn:
return str(cn)
except Exception:
pass
raw = str(org)
if raw.lower().startswith("mailto:"):
return raw[7:]
return raw or None
def parse_ics(raw: bytes) -> dict:
"""Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}.
Raises ValueError if the payload is not a parseable calendar at all.
"""
try:
cal = Calendar.from_ical(raw)
except Exception as e:
raise ValueError(f"Datei ist kein gültiges iCal-Format: {e}") from e
events = []
errors = []
for component in cal.walk():
if component.name != "VEVENT":
continue
try:
uid = str(component.get("UID") or uuid.uuid4())
title = str(component.get("SUMMARY", "") or "")
location = str(component.get("LOCATION", "") or "") or None
description = str(component.get("DESCRIPTION", "") or "") or None
dtstart_prop = component.get("DTSTART")
if dtstart_prop is None:
errors.append(f"VEVENT {uid}: kein DTSTART, übersprungen")
continue
dtstart = dtstart_prop.dt
dtend_prop = component.get("DTEND")
duration_prop = component.get("DURATION")
all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
if all_day:
if dtend_prop:
dtend = dtend_prop.dt
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(days=1)
start_str = dtstart.isoformat()
end_str = (dtend.isoformat() if isinstance(dtend, date)
else (dtstart + timedelta(days=1)).isoformat())
else:
if dtstart.tzinfo is None:
dtstart = dtstart.replace(tzinfo=timezone.utc)
if dtend_prop:
dtend = dtend_prop.dt
if isinstance(dtend, date) and not isinstance(dtend, datetime):
dtend = datetime.combine(dtend, datetime.min.time(), tzinfo=timezone.utc)
elif dtend.tzinfo is None:
dtend = dtend.replace(tzinfo=timezone.utc)
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(hours=1)
start_str = dtstart.isoformat()
end_str = dtend.isoformat()
events.append({
"uid": uid,
"title": title,
"start": start_str,
"end": end_str,
"all_day": all_day,
"location": location,
"description": description,
"rrule": _rrule_to_str(component),
"exdate": _exdate_to_csv(component),
"organizer": _organizer_name(component),
})
except Exception as exc:
logger.warning("Skipping malformed VEVENT: %s", exc)
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
return {"events": events, "errors": errors}
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
params = {}
for part in rrule_str.split(";"):
if "=" not in part:
continue
key, val = part.split("=", 1)
params[key] = val.split(",") if "," in val else val
return vRecur(params)
def _parse_iso(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def build_ics(calendar, events, *, name_cache: dict | None = None) -> str:
"""Build a VCALENDAR string for a local calendar and its events."""
cal = Calendar()
cal.add("prodid", "-//Calendarr//EN")
cal.add("version", "2.0")
cal.add("x-wr-calname", calendar.name)
for ev in events:
item = Event()
item.add("uid", ev.uid)
item.add("summary", ev.title or "")
item.add("dtstamp", datetime.now(timezone.utc))
if ev.all_day:
try:
start = date.fromisoformat(ev.start[:10])
end = date.fromisoformat(ev.end[:10])
except ValueError:
continue
if end <= start:
end = start + timedelta(days=1)
item.add("dtstart", start)
item.add("dtend", end)
else:
try:
item.add("dtstart", _parse_iso(ev.start))
item.add("dtend", _parse_iso(ev.end))
except ValueError:
continue
if ev.location:
item.add("location", ev.location)
if ev.description:
item.add("description", ev.description)
if ev.color:
item.add("x-calendarr-color", ev.color)
if ev.rrule:
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
# ORGANIZER from the creator (local user or imported name).
organizer_name = None
if getattr(ev, "creator_id", None) and name_cache:
organizer_name = name_cache.get(ev.creator_id)
if not organizer_name:
organizer_name = getattr(ev, "creator_name_external", None)
if organizer_name:
organizer = vCalAddress("mailto:noreply@calendarr.local")
organizer.params["CN"] = vText(organizer_name.replace('"', ""))
item.add("organizer", organizer)
cal.add_component(item)
return cal.to_ical().decode("utf-8")

View File

@@ -1,240 +0,0 @@
"""Shared builders for local-event API dicts.
Every local event returned by the API (the local router, the unified event
merge in caldav_router, and the group combined view) must look identical and
carry the additive collaboration fields: ``creator``, ``private``, ``type``,
and — in the group view — ``owner`` and ``is_group_event``.
Centralising this avoids the three near-duplicate dict constructions that used
to live in caldav_router.py.
"""
import logging
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional
from dateutil.rrule import rrulestr
from sqlalchemy.orm import Session
import models
logger = logging.getLogger(__name__)
def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]:
"""Build the ``creator`` payload for an event.
Returns ``{"id": int, "display_name": username}`` for a local creator,
``{"id": None, "display_name": "<name> (importiert)"}`` for an imported
event, or ``None`` when no creator info exists (legacy events).
``name_cache`` maps user_id -> username to avoid per-event DB lookups; the
creator relationship is used as a fallback.
"""
if ev.creator_id:
display = None
if name_cache is not None:
display = name_cache.get(ev.creator_id)
if display is None and ev.creator is not None:
display = ev.creator.display_name or ev.creator.username
if display is not None:
return {"id": ev.creator_id, "display_name": display}
if ev.creator_name_external:
return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
return None
def private_visibility_for(db: Session, user_id: int) -> str:
"""A user's chosen visibility for their private events ('hidden' | 'busy')."""
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
return (s.private_event_visibility if s else None) or "busy"
# Only these fields survive 'busy' anonymisation — a whitelist, so no content
# field (title/location/description/creator/calendar name/recurrence) can leak.
_BUSY_KEEP = {
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
"source", "type", "owner", "is_group_event", "display_color", "read_only",
}
def mask_busy_event(event: dict) -> dict:
"""Anonymise a private event for 'busy' visibility: keep only timing /
identity / render fields, drop ALL content."""
masked = {k: event[k] for k in _BUSY_KEEP if k in event}
masked["title"] = "Beschäftigt"
masked["location"] = ""
masked["description"] = ""
masked["calendar_name"] = ""
masked["creator"] = None
masked["color"] = None
masked["rrule"] = None
masked["exdate"] = None
masked["private"] = True
return masked
def apply_event_privacy(
event: dict, *, owner_id, is_private: bool, requester_id: int, visibility: str
) -> Optional[dict]:
"""Enforce another user's private-event visibility on a built event dict.
Returns the event unchanged for the requester's own events or non-private
events, ``None`` when the owner chose 'hidden', or a busy-masked copy when
the owner chose 'busy'. Used by BOTH the merge read and the combined view so
the privacy rule can never drift between them.
"""
if not is_private or owner_id == requester_id:
return event
if visibility == "hidden":
return None
return mask_busy_event(event)
def build_local_event_dict(
ev: models.LocalEvent,
cal: models.LocalCalendar,
*,
start: Optional[str] = None,
end: Optional[str] = None,
all_day: Optional[bool] = None,
rrule: Optional[str] = ...,
creator: Optional[dict] = None,
owner: Optional[dict] = None,
is_group_event: bool = False,
read_only: bool = False,
) -> dict:
"""Build the unified dict for a single local event (or occurrence).
``start``/``end``/``all_day`` override the stored values (used when emitting
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
by the group combined view. ``read_only`` marks events the requester may not
edit (someone else's calendar), so clients can hide edit/delete.
"""
d = {
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start if start is None else start,
"end": ev.end if end is None else end,
"allDay": ev.all_day if all_day is None else all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule if rrule is ... else rrule,
"exdate": ev.exdate,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
"source": "local",
"type": "local",
"creator": creator,
"private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
}
# Birthday calendars: the server owns the presentation. It flags the event so
# clients can show a cake icon, appends the age computed for THIS occurrence
# (so it stays correct as years pass), and — when the calendar defines a
# "notify N days before" — injects a reminder so the mobile schedulers fire
# it. Web has no notification delivery, so the reminder is display-only there.
if getattr(cal, "is_birthday", False):
d["is_birthday"] = True
display = ev.title
if ev.birth_year:
try:
occ_year = int(str(d["start"])[:4])
age = occ_year - int(ev.birth_year)
if age >= 0:
display = f"{ev.title} ({age})"
except (ValueError, TypeError):
pass
d["display_title"] = display
if not d["reminders"] and cal.birthday_notify_days_before is not None:
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
if owner is not None:
d["owner"] = owner
if is_group_event:
d["is_group_event"] = True
if read_only:
d["read_only"] = True
return d
def expand_recurring_local(
ev: models.LocalEvent,
local_cal: models.LocalCalendar,
range_start,
range_end,
*,
creator: Optional[dict] = None,
owner: Optional[dict] = None,
is_group_event: bool = False,
read_only: bool = False,
) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range."""
results = []
excluded = set()
if ev.exdate:
for d in ev.exdate.split(","):
d = d.strip()
if d:
excluded.add(d)
try:
ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.replace("Z", "+00:00")
if ev.all_day:
ev_start = dt_date.fromisoformat(ev_start_str[:10])
ev_end = dt_date.fromisoformat(ev_end_str[:10])
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_start = occ.date()
occ_key = occ_start.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ_start + duration
results.append(build_local_event_dict(
ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
))
else:
ev_start = dt_datetime.fromisoformat(ev_start_str)
ev_end = dt_datetime.fromisoformat(ev_end_str)
if ev_start.tzinfo is None:
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
if ev_end.tzinfo is None:
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
if r_start.tzinfo is None:
r_start = r_start.replace(tzinfo=dt_timezone.utc)
if r_end.tzinfo is None:
r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_key = occ.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ + duration
results.append(build_local_event_dict(
ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
))
except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event.
results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
))
return results

View File

@@ -4,20 +4,15 @@ import sys
from pathlib import Path from pathlib import Path
import uvicorn import uvicorn
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text from sqlalchemy import text
# How long the browser may keep static assets before revalidating.
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
NO_CACHE = "no-cache, no-store, must-revalidate"
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -102,20 +97,6 @@ def _migrate():
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN reminders TEXT"))
conn.commit()
logging.info("Migration: added reminders to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_reminder_minutes INTEGER"))
conn.commit()
logging.info("Migration: added default_reminder_minutes to user_settings")
except Exception:
pass
try: try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'")) conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
conn.commit() conn.commit()
@@ -128,277 +109,19 @@ def _migrate():
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
# ── Collaboration features (sharing, groups, creator, private) ──
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'"))
conn.commit()
logging.info("Migration: added private_event_visibility to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER"))
conn.commit()
logging.info("Migration: added creator_id to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT"))
conn.commit()
logging.info("Migration: added creator_name_external to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added is_private to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER"))
conn.commit()
logging.info("Migration: added group_visible_calendar_id to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
conn.commit()
logging.info("Migration: added display_name to users")
except Exception:
pass
# Backfill display_name from username for existing rows (only where empty).
try:
conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
conn.commit()
logging.info("Migration: added icon to groups")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)"))
conn.commit()
logging.info("Migration: added color to group_members")
except Exception:
pass
# Per-calendar reminder toggle (default ON = unchanged behaviour for existing data).
for tbl in ("calendars", "local_calendars", "google_calendars",
"homeassistant_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN reminders_enabled BOOLEAN DEFAULT 1"))
conn.commit()
logging.info("Migration: added reminders_enabled to %s", tbl)
except Exception:
pass
# Synced default duration (minutes) for newly created events.
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60"))
conn.commit()
logging.info("Migration: added default_event_duration_minutes to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN share_calendar_icon VARCHAR(16)"))
conn.commit()
logging.info("Migration: added share_calendar_icon to user_settings")
except Exception:
pass
# Per-setting cross-device sync: value columns for the two newly syncable
# device-local prefs, plus the JSON map of which settings sync.
for col, ddl in (
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Fine-grained web element theme colours (all optional overrides).
for col in (
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color",
"day_bg_color", "today_bg_color",
):
try:
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
# from the sidebar, matching caldav/google/ha.
for tbl in ("local_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to %s", tbl)
except Exception:
pass
# CalDAV publishing of local calendars (opt-in, secret token URL).
for col, ddl in (
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
conn.commit()
logging.info("Migration: added etag to local_events")
except Exception:
pass
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
try:
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
conn.commit()
logging.info("Migration: added color to calendar_shares")
except Exception:
pass
# Hide a user from sharing/group pickers (admin management still shows them).
try:
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added directory_hidden to users")
except Exception:
pass
# Birthday calendars: flag + per-calendar "notify N days before".
for col, ddl in (
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
# Birthday events: stable external id (Contacts dedup) + birth year.
for col, ddl in (
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_events", col)
except Exception:
pass
# One-time cleanup of duplicate imported events sharing the same
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
# older build without idempotent upsert. Keep the earliest row.
# Idempotent: after cleanup there is nothing left to delete.
try:
conn.execute(text(
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
"GROUP BY calendar_id, external_uid)"
))
conn.commit()
logging.info("Migration: de-duplicated local_events by external_uid")
except Exception:
pass
# Hard guarantee: the DB itself forbids two events with the same
# external_uid in one calendar (imported entries only; NULLs unconstrained).
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
))
conn.commit()
logging.info("Migration: unique index on (calendar_id, external_uid)")
except Exception:
pass
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Force ≤ 2h browser cache for static assets and disable cache for the
entry HTML / SW / version file. API responses are left alone (handlers
decide their own caching)."""
response = await call_next(request)
path = request.url.path
# Never cache: entry HTML, manifest, service worker, version marker
if (
path in ("/", "/index.html", "/manifest.json", "/sw.js")
or path == "/static/js/version.js"
):
response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# JS/CSS must revalidate on every load so a deploy takes effect on the next
# reload (returns a cheap 304 when unchanged). Without this, a fresh
# no-cache index.html could pair with stale 2h-cached scripts.
elif path.startswith("/static/js/") or path.startswith("/static/css/"):
response.headers["Cache-Control"] = NO_CACHE
# 2h cache for the rest of the frontend (icons, fonts, images, …)
elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML;
# don't let the browser cache that either.
elif not path.startswith("/api/"):
response.headers["Cache-Control"] = NO_CACHE
return response
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -7,19 +7,13 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# Login name: always lowercase, unique, used for authentication.
username = Column(String(50), unique=True, nullable=False) username = Column(String(50), unique=True, nullable=False)
# Human-facing name with original casing; editable. Falls back to username.
display_name = Column(String(100), nullable=True)
email = Column(String(100), unique=True, nullable=True) email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
avatar_filename = Column(String(255), nullable=True) avatar_filename = Column(String(255), nullable=True)
totp_secret = Column(String(32), nullable=True) totp_secret = Column(String(32), nullable=True)
totp_enabled = Column(Boolean, default=False) totp_enabled = Column(Boolean, default=False)
# When true, the user is hidden from sharing/group picker directories
# (/users/directory). Admin user management (/users/) still shows them.
directory_hidden = Column(Boolean, default=False, nullable=False)
caldav_accounts = relationship( caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan" "CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
@@ -40,11 +34,6 @@ class User(Base):
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
) )
@property
def display(self) -> str:
"""The name to show users: display_name if set, else the login name."""
return self.display_name or self.username
class CalDAVAccount(Base): class CalDAVAccount(Base):
__tablename__ = "caldav_accounts" __tablename__ = "caldav_accounts"
@@ -74,8 +63,6 @@ class Calendar(Base):
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False) sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("CalDAVAccount", back_populates="calendars") account = relationship("CalDAVAccount", back_populates="calendars")
@@ -97,82 +84,10 @@ class UserSettings(Base):
language = Column(String(5), default="de") language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0") month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0") month_label_color = Column(String(7), default="#7090c0")
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
# NULL = derive from bg_color. Device-local by default (not synced).
surface_color = Column(String(7), nullable=True)
# Fine-grained web element colours (all optional overrides; NULL = derive the
# previous default). See frontend THEME.md for what each one paints.
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
day_selected_color = Column(String(7), nullable=True) # selected day background
day_bg_color = Column(String(7), nullable=True) # normal day background
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
# How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy")
# The single local calendar this user shares into all their groups
# (combined view shows only this calendar per member). NULL = share nothing.
group_visible_calendar_id = Column(Integer, nullable=True)
# Default reminder in minutes-before-start applied to all events client-side
# (0 = at start time). NULL = no default reminder.
default_reminder_minutes = Column(Integer, nullable=True)
# Default duration (in minutes) applied to a newly created event's end time.
default_event_duration_minutes = Column(Integer, default=60)
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
share_calendar_icon = Column(String(16), nullable=True)
# How many months around the visible range clients preload/cache. Device-local
# by default (only shared when its sync flag is on).
cache_months = Column(Integer, default=3)
# Whether the month view uses horizontal paging (swipe) instead of a vertical
# scroll feed. Device-local by default (only shared when its sync flag is on).
month_view_paged = Column(Boolean, default=False)
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
# the single authority for which settings each client sends/fetches.
sync_flags = Column(Text, nullable=True)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
class InstanceSettings(Base):
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
Applies to everyone; a user's own settings still override the default theme."""
__tablename__ = "instance_settings"
id = Column(Integer, primary_key=True) # always 1
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
# client's built-in defaults. A user's own colour wins over this.
default_theme = Column(Text, nullable=True)
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
logo_filename = Column(String(255), nullable=True)
favicon_filename = Column(String(255), nullable=True)
class AppPassword(Base):
"""Per-device app-specific password for CalDAV (Basic Auth).
Keeps MFA intact: accounts with 2FA can't use their normal password over
CalDAV (clients can't send a TOTP code), so they authenticate with one of
these revocable app passwords instead. Only the bcrypt hash is stored.
"""
__tablename__ = "app_passwords"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String(100), nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(String(50), nullable=True)
last_used_at = Column(String(50), nullable=True)
user = relationship("User")
class LocalCalendar(Base): class LocalCalendar(Base):
__tablename__ = "local_calendars" __tablename__ = "local_calendars"
@@ -181,22 +96,6 @@ class LocalCalendar(Base):
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853") color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
# collection reachable via a secret token URL. dav_ctag changes on every
# event write so clients detect changes; rotating the token revokes access.
caldav_published = Column(Boolean, default=False, nullable=False)
dav_token = Column(String(64), nullable=True, unique=True)
dav_ctag = Column(String(32), nullable=True)
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
is_birthday = Column(Boolean, default=False, nullable=False)
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
# Injected as an event reminder on read so the mobile schedulers fire it.
birthday_notify_days_before = Column(Integer, nullable=True)
user = relationship("User", back_populates="local_calendars") user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -217,24 +116,8 @@ class LocalEvent(Base):
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True) rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
# Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start).
reminders = Column(Text, nullable=True)
# Creator: set server-side from the auth token on create, never from the client.
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For imported events without a local user (from the .ics ORGANIZER field).
creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False)
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
etag = Column(String(32), nullable=True)
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
external_uid = Column(String(255), nullable=True, index=True)
# Birth year for birthday events; NULL = year unknown (no age shown).
birth_year = Column(Integer, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User")
class ICalSubscription(Base): class ICalSubscription(Base):
@@ -246,10 +129,6 @@ class ICalSubscription(Base):
url = Column(String(1000), nullable=False) url = Column(String(1000), nullable=False)
color = Column(String(7), default="#46bdc6") color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still subscribed; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this subscription generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
refresh_minutes = Column(Integer, default=60) refresh_minutes = Column(Integer, default=60)
last_fetched = Column(DateTime, nullable=True) last_fetched = Column(DateTime, nullable=True)
cached_ics = Column(Text, nullable=True) cached_ics = Column(Text, nullable=True)
@@ -302,8 +181,6 @@ class GoogleCalendar(Base):
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False) sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("GoogleAccount", back_populates="calendars") account = relationship("GoogleAccount", back_populates="calendars")
@@ -337,116 +214,5 @@ class HomeAssistantCalendar(Base):
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False) sidebar_hidden = Column(Boolean, default=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
account = relationship("HomeAssistantAccount", back_populates="calendars") account = relationship("HomeAssistantAccount", back_populates="calendars")
# ── Collaboration: sharing & groups (local calendars only) ────────────────
class CalendarShare(Base):
"""A local calendar shared with another Calendarr user."""
__tablename__ = "calendar_shares"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
)
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)
permission = Column(String(20), default="read") # 'read' | 'read_write'
created_at = Column(String(50), nullable=True) # ISO 8601
calendar = relationship("LocalCalendar")
user = relationship("User")
class CalendarColorPref(Base):
"""A user's personal colour 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). NULL/absent = the owner's colour."""
__tablename__ = "calendar_color_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_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)
color = Column(String(16), nullable=False)
class Group(Base):
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
icon = Column(String(16), nullable=True) # emoji shown for the group
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(String(50), nullable=True) # ISO 8601
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
group_calendar = relationship(
"GroupCalendar", back_populates="group", uselist=False,
cascade="all, delete-orphan",
)
class GroupMember(Base):
__tablename__ = "group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String(10), default="member") # 'owner' | 'member'
color = Column(String(7), nullable=True) # this member's colour within the group
joined_at = Column(String(50), nullable=True) # ISO 8601
group = relationship("Group", back_populates="members")
user = relationship("User")
class BirthdaySyncDevice(Base):
"""A device that has synced Contacts birthdays into the user's birthday
calendar. Powers the web "birthdays come from these devices" list. One row
per (user, device); the client sends a stable device_id + human name."""
__tablename__ = "birthday_sync_devices"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
device_id = Column(String(64), nullable=False)
device_name = Column(String(120), nullable=False)
last_sync = Column(String(50), nullable=True) # ISO 8601
count = Column(Integer, default=0)
user = relationship("User")
class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar."""
__tablename__ = "group_calendars"
__table_args__ = (
UniqueConstraint("group_id", name="uq_group_calendar_group"),
UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
group = relationship("Group", back_populates="group_calendar")
calendar = relationship("LocalCalendar")

View File

@@ -1,176 +0,0 @@
"""Central access control for local calendars.
Local calendars are visible/writable to a user if any of the following holds:
- the user owns the calendar (LocalCalendar.user_id),
- the calendar is shared with the user (calendar_shares; write needs 'read_write'),
- the calendar is a group calendar and the user is a member of that group
(members get read & write).
These helpers replace the scattered owner-only filters so sharing and groups
work consistently across every local-calendar endpoint and the event merge read.
"""
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
import models
def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]:
return (
db.query(models.CalendarShare)
.filter(
models.CalendarShare.calendar_id == calendar_id,
models.CalendarShare.user_id == user_id,
)
.first()
)
def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool:
gc = (
db.query(models.GroupCalendar)
.filter(models.GroupCalendar.calendar_id == calendar_id)
.first()
)
if not gc:
return False
member = (
db.query(models.GroupMember)
.filter(
models.GroupMember.group_id == gc.group_id,
models.GroupMember.user_id == user_id,
)
.first()
)
return member is not None
def accessible_local_calendar(
db: Session,
user: models.User,
calendar_id: int,
*,
require_write: bool = False,
) -> models.LocalCalendar:
"""Return the calendar if the user may access it, else raise 404/403.
404 when the calendar does not exist or is not visible to the user (so we
don't leak existence). 403 when it is visible (read) but write is required.
"""
cal = (
db.query(models.LocalCalendar)
.filter(models.LocalCalendar.id == calendar_id)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if cal.user_id == user.id:
return cal # owner: full access
if _is_group_calendar_member(db, calendar_id, user.id):
return cal # group members get read & write
share = _share_for(db, calendar_id, user.id)
if share is None:
raise HTTPException(404, "Calendar not found")
if require_write and share.permission != "read_write":
raise HTTPException(403, "You only have read access to this calendar")
return cal
def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
"""Return the calendar only if the user owns it, else raise 404."""
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
return cal
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
"""Calendars that co-members of the user's groups share into the group.
Each user designates ONE of their own calendars via
UserSettings.group_visible_calendar_id. This returns those calendars for
every co-member of any group the user belongs to (excluding the user's own).
The calendar must be owned by the designating member. Deduped (each calendar
appears once even across multiple shared groups).
"""
my_group_ids = (
db.query(models.GroupMember.group_id)
.filter(models.GroupMember.user_id == user.id)
)
co_member_ids = (
db.query(models.GroupMember.user_id)
.filter(
models.GroupMember.group_id.in_(my_group_ids),
models.GroupMember.user_id != user.id,
)
.distinct()
)
return (
db.query(models.LocalCalendar)
.join(
models.UserSettings,
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
)
.filter(
models.UserSettings.user_id.in_(co_member_ids),
# the designating member must own the calendar they share
models.LocalCalendar.user_id == models.UserSettings.user_id,
)
.all()
)
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
"""All local calendar ids the user may read: own + shared + group + co-member group-visible."""
ids: set[int] = set()
own = (
db.query(models.LocalCalendar.id)
.filter(models.LocalCalendar.user_id == user.id)
.all()
)
ids.update(r[0] for r in own)
shared = (
db.query(models.CalendarShare.calendar_id)
.filter(models.CalendarShare.user_id == user.id)
.all()
)
ids.update(r[0] for r in shared)
group_cals = (
db.query(models.GroupCalendar.calendar_id)
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
.filter(models.GroupMember.user_id == user.id)
.all()
)
ids.update(r[0] for r in group_cals)
# Calendars co-members share into shared groups (group-visible).
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
return list(ids)
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."""
return {
p.calendar_id: p.color
for p in db.query(models.CalendarColorPref).filter(
models.CalendarColorPref.user_id == user_id
)
}

View File

@@ -1,194 +0,0 @@
"""Instance-wide (singleton) settings: an admin-defined default theme plus a
custom logo and favicon. The GET endpoint is public (needed on the login screen,
before auth); all writes require admin. Branding files are stored under
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
import io
import json
import re
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_admin
from database import DATA_DIR, get_db
router = APIRouter()
BRANDING_DIR = DATA_DIR / "branding"
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# Colour keys an admin may set as the instance default theme. Keep in sync with
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
THEME_COLOR_KEYS = {
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
"line_color", "surface_color", "month_divider_color", "month_label_color",
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
}
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _get_or_create(db: Session) -> models.InstanceSettings:
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
if not inst:
inst = models.InstanceSettings(id=1)
db.add(inst)
db.commit()
db.refresh(inst)
return inst
def _mtime(filename: Optional[str]) -> int:
if not filename:
return 0
p = BRANDING_DIR / filename
try:
return int(p.stat().st_mtime)
except OSError:
return 0
def _public_dict(inst: models.InstanceSettings) -> dict:
theme = {}
if inst.default_theme:
try:
theme = json.loads(inst.default_theme) or {}
except (ValueError, TypeError):
theme = {}
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
return {
"default_theme": theme,
"has_logo": has_logo,
"has_favicon": has_favicon,
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
}
# ── Public read ───────────────────────────────────────────
@router.get("/")
def get_instance(db: Session = Depends(get_db)):
return _public_dict(_get_or_create(db))
@router.get("/logo")
def get_logo(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.logo_filename:
raise HTTPException(404, "No logo")
path = BRANDING_DIR / inst.logo_filename
if not path.exists():
raise HTTPException(404, "No logo")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
@router.get("/favicon")
def get_favicon(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.favicon_filename:
raise HTTPException(404, "No favicon")
path = BRANDING_DIR / inst.favicon_filename
if not path.exists():
raise HTTPException(404, "No favicon")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
# ── Admin writes ──────────────────────────────────────────
class ThemeUpdate(BaseModel):
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
@router.put("/theme")
def set_default_theme(
data: ThemeUpdate,
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
# Keep only known colour keys with valid hex values.
clean = {
k: v.upper()
for k, v in (data.default_theme or {}).items()
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
}
inst = _get_or_create(db)
inst.default_theme = json.dumps(clean) if clean else None
db.commit()
return {"ok": True, "default_theme": clean}
async def _save_branding(file: UploadFile, kind: str) -> str:
"""Validate + normalise an uploaded image and store it. Returns the filename."""
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
raw = await file.read()
if len(raw) > MAX_BRANDING_SIZE:
raise HTTPException(400, "File too large (max 5 MB)")
try:
img = Image.open(io.BytesIO(raw)).convert("RGBA")
except Exception:
raise HTTPException(400, "Invalid image")
if kind == "favicon":
img = img.resize((128, 128), Image.LANCZOS)
else: # logo: keep aspect ratio, cap the longest edge at 256px
img.thumbnail((256, 256), Image.LANCZOS)
filename = f"{kind}.png"
img.save(str(BRANDING_DIR / filename), "PNG")
return filename
@router.post("/logo")
async def upload_logo(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.logo_filename = await _save_branding(file, "logo")
db.commit()
return {"ok": True}
@router.delete("/logo")
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.logo_filename:
p = BRANDING_DIR / inst.logo_filename
if p.exists():
p.unlink()
inst.logo_filename = None
db.commit()
return {"ok": True}
@router.post("/favicon")
async def upload_favicon(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.favicon_filename = await _save_branding(file, "favicon")
db.commit()
return {"ok": True}
@router.delete("/favicon")
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.favicon_filename:
p = BRANDING_DIR / inst.favicon_filename
if p.exists():
p.unlink()
inst.favicon_filename = None
db.commit()
return {"ok": True}

View File

@@ -32,12 +32,7 @@ class LoginRequest(BaseModel):
def _user_dict(user: models.User) -> dict: def _user_dict(user: models.User) -> dict:
return { return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"is_admin": user.is_admin,
}
@router.get("/setup-required") @router.get("/setup-required")
@@ -51,7 +46,6 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
raise HTTPException(400, "Setup already completed") raise HTTPException(400, "Setup already completed")
user = models.User( user = models.User(
username=req.username.lower(), username=req.username.lower(),
display_name=req.username.strip(), # keep the original casing for display
email=req.email, email=req.email,
password_hash=get_password_hash(req.password), password_hash=get_password_hash(req.password),
is_admin=True, is_admin=True,
@@ -119,7 +113,6 @@ def me(current_user: models.User = Depends(get_current_user)):
return { return {
"id": current_user.id, "id": current_user.id,
"username": current_user.username, "username": current_user.username,
"display_name": current_user.display_name or current_user.username,
"email": current_user.email, "email": current_user.email,
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None, "has_avatar": current_user.avatar_filename is not None,

View File

@@ -1,77 +0,0 @@
"""Birthday sync device tracking.
The iOS app reports, after each Contacts birthday sync, which device it was and
how many birthdays it manages. The web shows this as a "birthdays come from
these devices" list. Birthday events themselves are ordinary local events
(see local_router); this router only tracks the sync sources.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
class SyncReport(BaseModel):
device_id: str
device_name: str
count: int = 0
@router.post("/sync-report")
def report_sync(
data: SyncReport,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
row = (
db.query(models.BirthdaySyncDevice)
.filter(
models.BirthdaySyncDevice.user_id == current_user.id,
models.BirthdaySyncDevice.device_id == data.device_id,
)
.first()
)
now = datetime.now(timezone.utc).isoformat()
name = (data.device_name or "Gerät")[:120]
if row is None:
db.add(models.BirthdaySyncDevice(
user_id=current_user.id, device_id=data.device_id,
device_name=name, last_sync=now, count=data.count,
))
else:
row.device_name = name
row.last_sync = now
row.count = data.count
db.commit()
return {"ok": True}
@router.get("/devices")
def list_devices(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.BirthdaySyncDevice)
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
.order_by(models.BirthdaySyncDevice.last_sync.desc())
.all()
)
return [
{
"device_id": r.device_id,
"device_name": r.device_name,
"last_sync": r.last_sync,
"count": r.count,
}
for r in rows
]

View File

@@ -11,16 +11,8 @@ from sqlalchemy import or_
import caldav_client import caldav_client
import models import models
import permissions
from auth import get_current_user from auth import get_current_user
from database import get_db from database import get_db
from local_events_util import (
apply_event_privacy,
build_local_event_dict,
expand_recurring_local,
private_visibility_for,
resolve_creator,
)
from routers.ical_router import _refresh_if_needed, get_events_for_subscription from routers.ical_router import _refresh_if_needed, get_events_for_subscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,7 +34,6 @@ class CalendarUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
sidebar_hidden: Optional[bool] = None sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -85,13 +76,107 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
"enabled": c.enabled, "enabled": c.enabled,
"cal_id": c.cal_id, "cal_id": c.cal_id,
"sidebar_hidden": bool(c.sidebar_hidden), "sidebar_hidden": bool(c.sidebar_hidden),
"reminders_enabled": bool(c.reminders_enabled),
} }
for c in a.calendars for c in a.calendars
], ],
} }
def _expand_recurring_local(ev, local_cal, range_start, range_end):
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
results = []
# Parse excluded dates
excluded = set()
if ev.exdate:
for d in ev.exdate.split(","):
d = d.strip()
if d:
excluded.add(d)
try:
ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.replace("Z", "+00:00")
if ev.all_day:
ev_start = dt_date.fromisoformat(ev_start_str[:10])
ev_end = dt_date.fromisoformat(ev_end_str[:10])
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_start = occ.date()
occ_key = occ_start.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ_start + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ_start.isoformat(),
"end": occ_end.isoformat(),
"allDay": True,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
else:
ev_start = dt_datetime.fromisoformat(ev_start_str)
ev_end = dt_datetime.fromisoformat(ev_end_str)
if ev_start.tzinfo is None:
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
if ev_end.tzinfo is None:
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
if r_start.tzinfo is None:
r_start = r_start.replace(tzinfo=dt_timezone.utc)
if r_end.tzinfo is None:
r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_key = occ.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ.isoformat(),
"end": occ_end.isoformat(),
"allDay": False,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to single event
results.append({
"id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
"start": ev.start, "end": ev.end, "allDay": ev.all_day,
"location": ev.location or "", "description": ev.description or "",
"color": ev.color, "rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
"calendarColor": local_cal.color, "source": "local",
})
return results
def _normalize_url(url: str) -> str: def _normalize_url(url: str) -> str:
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash.""" """Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
parsed = urlparse(url) parsed = urlparse(url)
@@ -272,8 +357,6 @@ def update_calendar(
calendar.name = data.name calendar.name = data.name
if data.sidebar_hidden is not None: if data.sidebar_hidden is not None:
calendar.sidebar_hidden = data.sidebar_hidden calendar.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None:
calendar.reminders_enabled = data.reminders_enabled
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -300,7 +383,6 @@ def get_events(
end_dt = end_dt.replace(tzinfo=timezone.utc) end_dt = end_dt.replace(tzinfo=timezone.utc)
all_events = [] all_events = []
sync_errors = []
accounts = ( accounts = (
db.query(models.CalDAVAccount) db.query(models.CalDAVAccount)
.filter( .filter(
@@ -334,59 +416,17 @@ def get_events(
logger.error( logger.error(
"Error fetching calendar %s: %s", calendar.id, exc "Error fetching calendar %s: %s", calendar.id, exc
) )
sync_errors.append({
"source": "caldav",
"name": f"{account.username} {calendar.name}",
"calendar_id": calendar.id,
"message": "Sync fehlgeschlagen",
})
# ── Local calendar events (own + shared + group calendars) ───────────── # ── Local calendar events ─────────────────────────────
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
local_calendars = ( local_calendars = (
db.query(models.LocalCalendar) db.query(models.LocalCalendar)
.filter( .filter(
models.LocalCalendar.id.in_(readable_ids), models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.enabled == True, models.LocalCalendar.enabled == True,
) )
.all() .all()
) if readable_ids else [] )
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
# A personal calendar shared WITH the current user is relabelled under the
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and
# flagged read_only unless the share grants write. Group calendars are
# excluded — they keep their own name and stay writable for members.
shares_by_cal = {
s.calendar_id: s
for s in db.query(models.CalendarShare).filter(
models.CalendarShare.user_id == current_user.id
)
}
group_cal_ids = {
r[0] for r in db.query(models.GroupCalendar.calendar_id).filter(
models.GroupCalendar.calendar_id.in_(readable_ids)
)
} if readable_ids else set()
# Per-user colour overrides for calendars the user doesn't own (any share path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Cache each owner's private-event visibility (one lookup per owner, not per event).
vis_cache: dict = {}
def vis_for(uid: int) -> str:
if uid not in vis_cache:
vis_cache[uid] = private_visibility_for(db, uid)
return vis_cache[uid]
for local_cal in local_calendars: for local_cal in local_calendars:
# Decoration for a personal calendar shared with (not owned by) me.
is_shared_personal = (
local_cal.user_id != current_user.id
and local_cal.id not in group_cal_ids
)
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
shared_color = color_prefs.get(local_cal.id)
local_events = ( local_events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -401,34 +441,25 @@ def get_events(
.all() .all()
) )
for ev in local_events: for ev in local_events:
creator = resolve_creator(ev, name_cache=name_cache)
# A private event belonging to someone else (shared calendar or group
# calendar) must honour that owner's private_event_visibility, exactly
# like the group combined view — otherwise private titles/locations
# leak through the ordinary calendar read.
owner_id = ev.creator_id or local_cal.user_id
is_priv = bool(ev.is_private)
foreign_private = is_priv and owner_id != current_user.id
visibility = vis_for(owner_id) if foreign_private else "busy"
if foreign_private and visibility == "hidden":
continue
if ev.rrule: if ev.rrule:
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator) all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
else: else:
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] all_events.append({
for b in built: "id": ev.uid,
if shared_owner_name: "url": f"local://{ev.uid}",
b["calendar_name"] = shared_owner_name "title": ev.title,
if shared_read_only: "start": ev.start,
b["read_only"] = True "end": ev.end,
if shared_color: "allDay": ev.all_day,
b["calendarColor"] = shared_color "location": ev.location or "",
b = apply_event_privacy( "description": ev.description or "",
b, owner_id=owner_id, is_private=is_priv, "color": ev.color,
requester_id=current_user.id, visibility=visibility, "rrule": None,
) "calendar_id": f"local-{local_cal.id}",
if b is not None: "calendar_name": local_cal.name,
all_events.append(b) "calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ────────────────────────── # ── iCal subscription events ──────────────────────────
ical_subs = ( ical_subs = (
@@ -453,18 +484,13 @@ def get_events(
.filter(models.GoogleAccount.user_id == current_user.id) .filter(models.GoogleAccount.user_id == current_user.id)
.all() .all()
) )
google_errors = []
for g_acc in google_accounts: for g_acc in google_accounts:
try: try:
g_events, g_errors = get_google_events(g_acc, start_dt, end_dt, db) all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
all_events.extend(g_events)
sync_errors.extend(g_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
sync_errors.append({ google_errors.append({"email": g_acc.email})
"source": "google",
"name": g_acc.email,
"message": "Sync fehlgeschlagen",
})
# ── Home Assistant events ───────────────────────────── # ── Home Assistant events ─────────────────────────────
from routers.homeassistant_router import get_ha_events from routers.homeassistant_router import get_ha_events
@@ -475,18 +501,11 @@ def get_events(
) )
for ha_acc in ha_accounts: for ha_acc in ha_accounts:
try: try:
ha_events, ha_errors = get_ha_events(ha_acc, start_dt, end_dt, db) all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
all_events.extend(ha_events)
sync_errors.extend(ha_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
sync_errors.append({
"source": "homeassistant",
"name": ha_acc.name,
"message": "Sync fehlgeschlagen",
})
return {"events": all_events, "errors": sync_errors} return {"events": all_events, "errors": google_errors}
@router.post("/events") @router.post("/events")

View File

@@ -1,491 +0,0 @@
"""Minimal two-way CalDAV server for published local calendars.
Two ways to reach a published local calendar:
1. Secret token URL (no login) — ``/dav/{token}/``.
2. Username + password (HTTP Basic Auth) with principal discovery —
``/caldav/`` advertises the user's calendar-home-set and lists every
published calendar as ``/caldav/{id}/``. This is what account-based clients
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
getetag per event), avoiding deletion tombstones.
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
round-tripped (parse_ics ignores them).
"""
from __future__ import annotations
import base64
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import func
from sqlalchemy.orm import Session
import dav_util
import ical_io
import models
from auth import verify_password
from database import get_db
router = APIRouter()
# XML namespaces used across WebDAV / CalDAV.
NS_DAV = "DAV:"
NS_CAL = "urn:ietf:params:xml:ns:caldav"
NS_CS = "http://calendarserver.org/ns/"
NS_ICAL = "http://apple.com/ns/ical/"
_NS_DECL = (
'xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
'xmlns:CS="http://calendarserver.org/ns/" '
'xmlns:ICAL="http://apple.com/ns/ical/"'
)
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
_MULTISTATUS_CT = "application/xml; charset=utf-8"
_LOGIN_HREF = "/caldav/"
# ── Helpers ───────────────────────────────────────────────
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
if not token:
return None
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.dav_token == token,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
"""Validate an HTTP Basic Authorization header against a Calendarr account.
Accepts an app-specific password (always) or the account password (only when
MFA is off — otherwise the account password would bypass 2FA, which CalDAV
clients can't satisfy).
"""
hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
except Exception:
return None
username, sep, password = raw.partition(":")
if not sep:
return None
# Login names are stored lowercase; match case-insensitively like the web login.
user = (
db.query(models.User)
.filter(func.lower(models.User.username) == username.lower())
.first()
)
if not user:
return None
# 1) App-specific passwords — always allowed, MFA-safe.
for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all():
try:
if verify_password(password, ap.password_hash):
ap.last_used_at = datetime.now(timezone.utc).isoformat()
db.commit()
return user
except Exception:
continue
# 2) Account password — only when 2FA is disabled.
if not user.totp_enabled:
try:
if verify_password(password, user.password_hash):
return user
except Exception:
return None
return None
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.all()
)
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
return (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
def _etag(ev: models.LocalEvent) -> str:
return ev.etag or "0"
def _resource_name(ev: models.LocalEvent) -> str:
return f"{quote(ev.uid, safe='')}.ics"
def _event_href(base: str, ev: models.LocalEvent) -> str:
return f"{base}{_resource_name(ev)}"
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
if owner:
return {owner.id: (owner.display_name or owner.username)}
return {}
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
# ── XML builders ──────────────────────────────────────────
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
principal_href: str | None = None,
home_href: str | None = None) -> str:
principal_href = principal_href or base
home_href = home_href or base
return f""" <D:response>
<D:href>{base}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
<D:supported-report-set>
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
</D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _principal_propstat(user: models.User) -> str:
href = _LOGIN_HREF
name = user.display_name or user.username
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
<D:displayname>{xml_escape(name)}</D:displayname>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
ics: str | None = None) -> str:
href = _event_href(base, ev)
data = ""
if with_data and ics is not None:
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _multistatus(body: str) -> Response:
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers (shared by token and Basic-Auth paths) ──
def _handle_options() -> Response:
return Response(status_code=200, headers={
"DAV": "1, 2, 3, calendar-access",
"Allow": _ALLOW,
})
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
name = resource.rsplit("/", 1)[-1]
if name.endswith(".ics"):
name = name[:-4]
uid = unquote(name)
return (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
db: Session, *, principal_href: str | None = None,
home_href: str | None = None) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(base, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(base, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
ics = _build_ics(cal, [ev], db)
headers = {"ETag": f'"{_etag(ev)}"'}
return Response(
content=b"" if head else ics,
media_type="text/calendar; charset=utf-8",
headers=headers,
)
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
try:
parsed = ical_io.parse_ics(body)
except ValueError:
return Response(status_code=400)
items = parsed.get("events") or []
if not items:
return Response(status_code=400)
item = items[0]
# Key by the VEVENT UID; fall back to the resource name.
uid = item.get("uid")
if not uid:
name = resource.rsplit("/", 1)[-1]
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
# Scope to THIS calendar — never touch another calendar's/user's event that
# happens to share the UID (local_events.uid is globally unique).
ev = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
created = ev is None
if created:
# If the UID already exists elsewhere, the global UNIQUE constraint would
# 500 on commit — reject cleanly with 409 instead.
if db.query(models.LocalEvent.id).filter(models.LocalEvent.uid == uid).first():
return Response(status_code=409)
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
db.add(ev)
ev.title = item.get("title") or "(ohne Titel)"
ev.start = item["start"]
ev.end = item["end"]
ev.all_day = item.get("all_day", False)
ev.location = item.get("location")
ev.description = item.get("description")
ev.rrule = item.get("rrule")
ev.exdate = item.get("exdate")
dav_util.bump_dav(cal, ev)
db.commit()
db.refresh(ev)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
dav_util.bump_dav(cal)
db.delete(ev)
db.commit()
return Response(status_code=204)
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
resource: str, db: Session, *,
principal_href: str | None = None,
home_href: str | None = None) -> Response:
"""Serve a single calendar collection; auth/ownership already checked."""
method = request.method.upper()
if method == "PROPFIND":
depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
if method == "REPORT":
return _handle_report(cal, base, await request.body(), db)
if method in ("GET", "HEAD"):
if not resource:
ics = _build_ics(cal, _events(cal, db), db)
return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD"))
if method == "PUT":
return _handle_put(cal, resource, await request.body(), db)
if method == "DELETE":
if not resource:
return Response(status_code=403) # don't delete the collection itself
return _handle_delete(cal, resource, db)
return Response(status_code=405, headers={"Allow": _ALLOW})
# ── Token path (no login): /dav/{token}/… ─────────────────
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
base = f"/dav/{token}/"
return await _dispatch_collection(request, cal, base, resource, db)
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, "", db)
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, resource, db)
# ── Basic-Auth path (username/password): /caldav/… ────────
async def _dispatch_home(request: Request, db: Session) -> Response:
"""Principal + calendar-home-set: lists the user's published calendars."""
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
if request.method.upper() != "PROPFIND":
return Response(status_code=405, headers={"Allow": _ALLOW})
depth = request.headers.get("Depth", "0")
parts = [_principal_propstat(user)]
if depth != "0":
for cal in _published_calendars(user, db):
parts.append(_collection_propstat(
cal, f"/caldav/{cal.id}/",
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
return _multistatus("\n".join(parts))
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == cal_id,
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
if not cal:
return Response(status_code=404)
base = f"/caldav/{cal.id}/"
return await _dispatch_collection(request, cal, base, resource, db,
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
async def wellknown_caldav(request: Request):
if request.method.upper() == "OPTIONS":
return _handle_options()
# Point discovery at the principal/home collection.
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
async def caldav_home(request: Request, db: Session = Depends(get_db)):
return await _dispatch_home(request, db)
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, "", db)
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, resource, db)

View File

@@ -142,7 +142,6 @@ def _account_dict(a: models.GoogleAccount) -> dict:
"color": c.color or "#4285f4", "color": c.color or "#4285f4",
"enabled": c.enabled, "enabled": c.enabled,
"sidebar_hidden": bool(c.sidebar_hidden), "sidebar_hidden": bool(c.sidebar_hidden),
"reminders_enabled": bool(c.reminders_enabled),
} }
for c in a.calendars for c in a.calendars
if not _is_system_calendar(c.cal_id) if not _is_system_calendar(c.cal_id)
@@ -345,7 +344,6 @@ class GoogleCalendarUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
sidebar_hidden: Optional[bool] = None sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None
@router.put("/calendars/{calendar_id}") @router.put("/calendars/{calendar_id}")
@@ -374,43 +372,21 @@ def update_calendar(
gcal.name = data.name gcal.name = data.name
if data.sidebar_hidden is not None: if data.sidebar_hidden is not None:
gcal.sidebar_hidden = data.sidebar_hidden gcal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None:
gcal.reminders_enabled = data.reminders_enabled
db.commit() db.commit()
return {"ok": True} return {"ok": True}
# ── Events ─────────────────────────────────────────────── # ── Events ───────────────────────────────────────────────
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple: def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
"""Fetch events from all enabled Google calendars for an account. """Fetch events from all enabled Google calendars for an account."""
Returns (events, errors) — errors is a list of
{"source": "google", "name": ..., "message": ...} dicts for any
calendar that failed to sync. Never includes raw exception text.
"""
all_events = []
errors = []
try: try:
token = _refresh_access_token(account, db) token = _refresh_access_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("Token refresh failed for Google account %s: %s", account.email, exc) logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
for gcal in account.calendars: raise
if not gcal.enabled or gcal.sidebar_hidden:
continue
if _is_system_calendar(gcal.cal_id):
continue
errors.append({
"source": "google",
"name": f"{account.email} {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
all_events = []
for gcal in account.calendars: for gcal in account.calendars:
if not gcal.enabled or gcal.sidebar_hidden: if not gcal.enabled or gcal.sidebar_hidden:
continue continue
@@ -429,14 +405,8 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4")) all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
except Exception as exc: except Exception as exc:
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc) logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
errors.append({
"source": "google",
"name": f"{account.email} {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors return all_events
class GoogleEventCreate(BaseModel): class GoogleEventCreate(BaseModel):

View File

@@ -1,460 +0,0 @@
"""Groups: shared group calendar + combined member-calendar overlay view.
A group has members and exactly one group calendar (a local calendar owned by
the creator, linked via group_calendars). Members get read/write on the group
calendar (enforced by permissions.accessible_local_calendar). The combined view
overlays every member's local calendars plus the group calendar, applying each
member's private-event visibility setting.
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
logger = logging.getLogger(__name__)
router = APIRouter()
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
# Distinct per-member colours (server-defined so every client shows the same).
MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"]
def _next_member_color(db: Session, group_id: int) -> str:
n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count()
return MEMBER_PALETTE[n % len(MEMBER_PALETTE)]
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class GroupCreate(BaseModel):
name: str
member_ids: List[int] = []
icon: Optional[str] = None
class GroupUpdate(BaseModel):
name: Optional[str] = None
icon: Optional[str] = None
class MemberAdd(BaseModel):
user_id: int
def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]:
return (
db.query(models.GroupMember)
.filter(
models.GroupMember.group_id == group_id,
models.GroupMember.user_id == user_id,
)
.first()
)
def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember:
m = _membership(db, group.id, user.id)
if not m:
raise HTTPException(403, "You are not a member of this group")
return m
def _require_owner(db: Session, group: models.Group, user: models.User) -> None:
m = _membership(db, group.id, user.id)
if not m or m.role != "owner":
raise HTTPException(403, "Only the group owner may do this")
def _get_group_or_404(db: Session, group_id: int) -> models.Group:
g = db.query(models.Group).filter(models.Group.id == group_id).first()
if not g:
raise HTTPException(404, "Group not found")
return g
def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
gc = (
db.query(models.GroupCalendar)
.filter(models.GroupCalendar.group_id == group_id)
.first()
)
return gc.calendar_id if gc else None
def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]:
if calendar_id is None:
return None
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
return cal.color if cal else None
@router.post("/")
def create_group(
data: GroupCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = models.Group(name=data.name, icon=(data.icon or None),
created_by=current_user.id, created_at=_now_iso())
db.add(group)
db.flush()
# Creator is owner; add the requested members (deduped, excluding creator).
# Each member gets a distinct colour from the palette by join order.
db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner",
color=MEMBER_PALETTE[0], joined_at=_now_iso()))
seen = {current_user.id}
idx = 1
for uid in data.member_ids:
if uid in seen:
continue
if not db.query(models.User).filter(models.User.id == uid).first():
continue
db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member",
color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso()))
seen.add(uid)
idx += 1
# Auto-create the group calendar (a local calendar owned by the creator).
cal = models.LocalCalendar(
user_id=current_user.id,
name=f"{data.name} (Gruppe)",
color=PALETTE[group.id % len(PALETTE)],
)
db.add(cal)
db.flush()
db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id))
db.commit()
db.refresh(group)
return _group_detail(db, group, current_user)
@router.get("/")
def list_groups(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
memberships = (
db.query(models.GroupMember)
.filter(models.GroupMember.user_id == current_user.id)
.all()
)
out = []
for m in memberships:
group = db.query(models.Group).filter(models.Group.id == m.group_id).first()
if not group:
continue
member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count()
gcal_id = _group_calendar_id(db, group.id)
out.append({
"id": group.id,
"name": group.name,
"icon": group.icon,
"role": m.role,
"member_count": member_count,
"group_calendar_id": gcal_id,
"group_calendar_color": _group_calendar_color(db, gcal_id),
})
return out
def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict:
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all()
member_dicts = []
for i, m in enumerate(members):
u = db.query(models.User).filter(models.User.id == m.user_id).first()
# Whether this member actually shares a calendar into the group (owns a
# calendar designated as their group_visible). Lets clients hide phantom
# empty rows for members who share nothing.
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
shares_calendar = False
if s and s.group_visible_calendar_id is not None:
shares_calendar = db.query(models.LocalCalendar.id).filter(
models.LocalCalendar.id == s.group_visible_calendar_id,
models.LocalCalendar.user_id == m.user_id,
).first() is not None
member_dicts.append({
"id": m.user_id,
"display_name": (u.display_name or u.username) if u else None,
"role": m.role,
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
"shares_calendar": shares_calendar,
})
gcal_id = _group_calendar_id(db, group.id)
return {
"id": group.id,
"name": group.name,
"icon": group.icon,
"created_by": group.created_by,
"members": member_dicts,
"group_calendar_id": gcal_id,
"group_calendar_color": _group_calendar_color(db, gcal_id),
}
@router.put("/{group_id}")
def update_group(
group_id: int,
data: GroupUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
if data.name is not None and data.name.strip():
group.name = data.name.strip()
if data.icon is not None:
group.icon = data.icon or None
db.commit()
return _group_detail(db, group, current_user)
@router.get("/{group_id}")
def get_group(
group_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_member(db, group, current_user)
return _group_detail(db, group, current_user)
@router.post("/{group_id}/members")
def add_member(
group_id: int,
data: MemberAdd,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
if not db.query(models.User).filter(models.User.id == data.user_id).first():
raise HTTPException(404, "User not found")
if _membership(db, group_id, data.user_id):
return {"ok": True} # already a member
db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member",
color=_next_member_color(db, group_id), joined_at=_now_iso()))
db.commit()
return {"ok": True}
class MemberColorUpdate(BaseModel):
color: str
@router.put("/{group_id}/members/{user_id}/color")
def set_member_color(
group_id: int,
user_id: int,
data: MemberColorUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
# Owner may recolour anyone; a member may recolour themselves.
if user_id != current_user.id:
_require_owner(db, group, current_user)
else:
_require_member(db, group, current_user)
m = _membership(db, group_id, user_id)
if not m:
raise HTTPException(404, "Member not found")
m.color = data.color
db.commit()
return {"ok": True}
@router.delete("/{group_id}/members/{user_id}")
def remove_member(
group_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
# Owner can remove anyone; a member may remove themselves (leave).
if user_id != current_user.id:
_require_owner(db, group, current_user)
else:
_require_member(db, group, current_user)
target = _membership(db, group_id, user_id)
if not target:
raise HTTPException(404, "Member not found")
if target.role == "owner":
raise HTTPException(422, "The owner cannot be removed; delete the group instead")
db.delete(target)
db.commit()
return {"ok": True}
@router.delete("/{group_id}")
def delete_group(
group_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
# Remove the group calendar (and its events) too.
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
if gc:
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
if cal:
db.delete(cal) # cascades to events
db.delete(group) # cascades to members + group_calendar link
db.commit()
return {"ok": True}
def _first_name(name: Optional[str]) -> str:
if not name:
return ""
return name.split(" ", 1)[0]
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
owner: Optional[dict], me_id: int) -> str:
"""Server-side display title for the combined view. The former owner/creator
first-name prefix ("Guido: …") was dropped: each member already has a
distinct colour, so the prefix was redundant noise. We still return a
non-empty `display_title` (== raw title) so the clients' legacy fallback —
which rebuilds a prefix when `display_title` is empty — never kicks in.
`display_color` continues to carry the per-person colour."""
return title
@router.get("/{group_id}/combined")
def combined_events(
group_id: int,
start: str = Query(...),
end: str = Query(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_member(db, group, current_user)
try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(400, "Invalid date format — use ISO 8601")
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
visibility_cache: dict[int, str] = {}
def visibility_for(user_id: int) -> str:
if user_id not in visibility_cache:
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy"
return visibility_cache[user_id]
group_cal_id = _group_calendar_id(db, group_id)
group_cal_color = _group_calendar_color(db, group_cal_id)
# Server-defined colours so every client renders members/group consistently.
member_color = {
m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)])
for i, m in enumerate(members)
}
all_events: list[dict] = []
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
owner_user = name_cache.get(owner_id)
owner = {"id": owner_id, "display_name": owner_user}
# Editable by the requester iff it's the shared group calendar (all members
# may write) or the requester's own calendar; everyone else's is read-only.
read_only = not (is_group or owner_id == current_user.id)
events = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
or_(
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
models.LocalEvent.rrule != None,
),
)
.all()
)
for ev in events:
creator_owner_id = ev.creator_id or owner_id
# Private filtering for events that belong to someone else.
if ev.is_private and creator_owner_id != current_user.id:
vis = visibility_for(creator_owner_id)
if vis == "hidden":
continue
creator = None
if ev.creator_id and name_cache.get(ev.creator_id):
creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]}
elif ev.creator_name_external:
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule:
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)
else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)]
for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
b = mask_busy_event(b)
# Colour to render with: the group calendar's colour for group
# events, otherwise the owning member's group colour.
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
# Decorated title (group icon / owner name) computed server-side
# so all clients render identically; raw `title` kept for editing.
# A birthday event already carries an age display_title from
# build_local_event_dict — keep it rather than clobber the age.
if not b.get("is_birthday"):
b["display_title"] = _decorate_title(
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
owner=owner, me_id=current_user.id,
)
all_events.append(b)
# Each member shares exactly one calendar into their groups, chosen in their
# settings (group_visible_calendar_id). Only that calendar is overlaid.
for m in members:
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == m.user_id)
.first()
)
visible_id = settings.group_visible_calendar_id if settings else None
if visible_id is None or visible_id == group_cal_id:
continue
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == visible_id,
models.LocalCalendar.user_id == m.user_id, # must be the member's own
)
.first()
)
if cal:
emit_calendar(cal, m.user_id, is_group=False)
# The group calendar itself.
if group_cal_id is not None:
group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first()
if group_cal:
emit_calendar(group_cal, group_cal.user_id, is_group=True)
return {"events": all_events}

View File

@@ -313,32 +313,13 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
} }
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple: def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
"""Fetch events from all enabled HA calendars for an account.
Returns (events, errors) — errors is a list of
{"source": "homeassistant", "name": ..., "calendar_id": ..., "message": ...}
dicts for any calendar that failed to sync. Never includes raw exception text.
"""
all_events = [] all_events = []
errors = []
try: try:
token = _get_valid_token(account, db) token = _get_valid_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("HA token error for %s: %s", account.name, exc) logger.error("HA token error for %s: %s", account.name, exc)
for cal in account.calendars: raise
if not cal.enabled or cal.sidebar_hidden:
continue
errors.append({
"source": "homeassistant",
"name": f"{account.name} {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
for cal in account.calendars: for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden: if not cal.enabled or cal.sidebar_hidden:
continue continue
@@ -349,13 +330,7 @@ def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
except Exception as exc: except Exception as exc:
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc) logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
errors.append({ return all_events
"source": "homeassistant",
"name": f"{account.name} {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
# ── Serialization ───────────────────────────────────────── # ── Serialization ─────────────────────────────────────────
@@ -374,7 +349,6 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
"color": c.color or HA_DEFAULT_COLOR, "color": c.color or HA_DEFAULT_COLOR,
"enabled": c.enabled, "enabled": c.enabled,
"sidebar_hidden": bool(c.sidebar_hidden), "sidebar_hidden": bool(c.sidebar_hidden),
"reminders_enabled": bool(c.reminders_enabled),
} }
for c in a.calendars for c in a.calendars
], ],
@@ -401,7 +375,6 @@ class HACalendarUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
sidebar_hidden: Optional[bool] = None sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None
# ── Endpoints ───────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────
@@ -650,8 +623,6 @@ def update_calendar(
cal.name = data.name cal.name = data.name
if data.sidebar_hidden is not None: if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden cal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -1,9 +1,6 @@
import ipaddress
import logging import logging
import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urljoin, urlparse
import requests as http_requests import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -31,9 +28,7 @@ class SubscriptionUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
refresh_minutes: Optional[int] = None refresh_minutes: Optional[int] = None
reminders_enabled: Optional[bool] = None
def _sub_dict(sub: models.ICalSubscription) -> dict: def _sub_dict(sub: models.ICalSubscription) -> dict:
@@ -43,74 +38,17 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
"url": sub.url, "url": sub.url,
"color": sub.color, "color": sub.color,
"enabled": sub.enabled, "enabled": sub.enabled,
"sidebar_hidden": bool(sub.sidebar_hidden),
"reminders_enabled": bool(sub.reminders_enabled),
"refresh_minutes": sub.refresh_minutes, "refresh_minutes": sub.refresh_minutes,
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None, "last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
} }
_MAX_ICS_BYTES = 5 * 1024 * 1024
_MAX_REDIRECTS = 5
def _host_is_public(host: str) -> bool:
"""False if the host resolves to any private/loopback/link-local address."""
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return False
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_public_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Nur http(s)-URLs sind erlaubt.")
if not parsed.hostname:
raise ValueError("Ungültige URL.")
if not _host_is_public(parsed.hostname):
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
def _fetch_ics(url: str) -> str: def _fetch_ics(url: str) -> str:
"""Download .ics content, blocking SSRF to internal/private hosts. """Download .ics content from a URL."""
Redirects are followed manually so every hop is re-validated (a public URL
can otherwise 30x-redirect into the internal network). Response size is
capped. (Residual risk: DNS rebinding between check and connect.)
"""
if url.startswith("webcal://"):
url = "https://" + url[len("webcal://"):]
try: try:
for _ in range(_MAX_REDIRECTS + 1): resp = http_requests.get(url, timeout=30, allow_redirects=True)
_validate_public_url(url) resp.raise_for_status()
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True) return resp.text
if resp.is_redirect and resp.headers.get("location"):
url = urljoin(url, resp.headers["location"])
resp.close()
continue
resp.raise_for_status()
resp.encoding = "utf-8"
chunks, total = [], 0
for chunk in resp.iter_content(8192, decode_unicode=True):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > _MAX_ICS_BYTES:
resp.close()
raise ValueError("Datei zu groß (max. 5 MB).")
return "".join(chunks)
raise ValueError("Zu viele Weiterleitungen.")
except http_requests.RequestException as e: except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}") raise ValueError(f"Fehler beim Abrufen der URL: {e}")
@@ -275,12 +213,8 @@ def update_subscription(
sub.color = data.color sub.color = data.color
if data.enabled is not None: if data.enabled is not None:
sub.enabled = data.enabled sub.enabled = data.enabled
if data.sidebar_hidden is not None:
sub.sidebar_hidden = data.sidebar_hidden
if data.refresh_minutes is not None: if data.refresh_minutes is not None:
sub.refresh_minutes = data.refresh_minutes sub.refresh_minutes = data.refresh_minutes
if data.reminders_enabled is not None:
sub.reminders_enabled = data.reminders_enabled
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -1,43 +1,26 @@
import uuid import uuid
from datetime import datetime, timezone from typing import Optional
from typing import List, Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import dav_util
import ical_io
import models import models
import permissions
from auth import get_current_user from auth import get_current_user
from database import get_db from database import get_db
from local_events_util import build_local_event_dict, resolve_creator
router = APIRouter() router = APIRouter()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class CalendarCreate(BaseModel): class CalendarCreate(BaseModel):
name: str name: str
color: str = "#34a853" color: str = "#34a853"
is_birthday: bool = False
birthday_notify_days_before: Optional[int] = None
class CalendarUpdate(BaseModel): class CalendarUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None
caldav_published: Optional[bool] = None
is_birthday: Optional[bool] = None
birthday_notify_days_before: Optional[int] = None
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -50,10 +33,6 @@ class EventCreate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None rrule: Optional[str] = None
private: bool = False
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
birth_year: Optional[int] = None # birthday events; NULL = year unknown
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -66,147 +45,50 @@ class EventUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None rrule: Optional[str] = None
exdate: Optional[str] = None exdate: Optional[str] = None
private: Optional[bool] = None
reminders: Optional[List[int]] = None
external_uid: Optional[str] = None
birth_year: Optional[int] = None
class ShareCreate(BaseModel): def _cal_dict(cal: models.LocalCalendar) -> dict:
user_id: int return {
permission: str = "read"
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None,
color_override: Optional[str] = None,
request: Optional[Request] = None) -> dict:
d = {
"id": cal.id, "id": cal.id,
# A shared calendar is labelled by the person/group it comes from — the "name": cal.name,
# owner's real calendar name must never reach recipients. "color": cal.color,
"name": shared_by if (not owned and shared_by is not None) else cal.name,
# A recipient's own colour for a shared calendar wins over the owner's.
"color": color_override or cal.color,
"enabled": cal.enabled, "enabled": cal.enabled,
"sidebar_hidden": bool(cal.sidebar_hidden),
"reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published),
"is_birthday": bool(cal.is_birthday),
"birthday_notify_days_before": cal.birthday_notify_days_before,
"type": "local",
"owned": owned,
} }
# Only the owner may publish; expose the subscribe URLs only when active.
if owned and cal.caldav_published and cal.dav_token:
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
if shared_by is not None:
d["shared_by"] = shared_by
if permission is not None:
d["permission"] = permission
return d
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) return {
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start,
"end": ev.end,
"allDay": ev.all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"exdate": ev.exdate,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
"source": "local",
}
# ── Calendar CRUD ───────────────────────────────────────── # ── Calendar CRUD ─────────────────────────────────────────
@router.get("/calendars") @router.get("/calendars")
def list_calendars( def list_calendars(
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Map calendar_id -> group name for every group the user belongs to, so we cals = (
# can flag group calendars as such even when the user owns them (the creator
# owns the group calendar — it must still be marked group:true).
group_cal_map = {
cal_id: name
for cal_id, name in (
db.query(models.GroupCalendar.calendar_id, models.Group.name)
.join(models.Group, models.Group.id == models.GroupCalendar.group_id)
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
.filter(models.GroupMember.user_id == current_user.id)
.all()
)
}
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Own calendars
own = (
db.query(models.LocalCalendar) db.query(models.LocalCalendar)
.filter(models.LocalCalendar.user_id == current_user.id) .filter(models.LocalCalendar.user_id == current_user.id)
.all() .all()
) )
result = [] return [_cal_dict(c) for c in cals]
for c in own:
d = _cal_dict(c, owned=True, request=request)
if c.id in group_cal_map:
d["group"] = True
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
result.append(d)
seen_ids = {c.id for c in own}
# Calendars shared with this user
shares = (
db.query(models.CalendarShare)
.filter(models.CalendarShare.user_id == current_user.id)
.all()
)
for share in shares:
cal = share.calendar
if cal is None or cal.id in seen_ids:
continue
seen_ids.add(cal.id)
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict(
cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission,
color_override=color_prefs.get(cal.id),
)
if cal.id in group_cal_map:
d["group"] = True
result.append(d)
# Group calendars reached via membership (read_write) that aren't already
# listed, so members can select/see the group calendar.
for cal_id, group_name in group_cal_map.items():
if cal_id in seen_ids:
continue
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first()
if not cal:
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))
d["group"] = True
result.append(d)
# Calendars co-members share into shared groups (group_visible_calendar_id).
# Read-only, shown under the owner's name. Deduped against everything above
# so a calendar already shared directly / as a group calendar isn't doubled.
for cal in permissions.co_member_group_visible_calendars(db, current_user):
if cal.id in seen_ids:
continue
seen_ids.add(cal.id)
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict(
cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None,
permission="read",
color_override=color_prefs.get(cal.id),
request=request,
)
d["group_shared"] = True
result.append(d)
return result
@router.post("/calendars") @router.post("/calendars")
@@ -215,27 +97,10 @@ def create_calendar(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Only ONE birthday calendar per account — hard server guarantee. If the
# user already has one, "create birthday calendar" is idempotent: return the
# existing one instead of creating a second.
if data.is_birthday:
existing = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
)
.first()
)
if existing is not None:
return _cal_dict(existing)
cal = models.LocalCalendar( cal = models.LocalCalendar(
user_id=current_user.id, user_id=current_user.id,
name=data.name, name=data.name,
color=data.color, color=data.color,
is_birthday=data.is_birthday,
birthday_notify_days_before=data.birthday_notify_days_before,
) )
db.add(cal) db.add(cal)
db.commit() db.commit()
@@ -247,7 +112,6 @@ def create_calendar(
def update_calendar( def update_calendar(
calendar_id: int, calendar_id: int,
data: CalendarUpdate, data: CalendarUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -267,112 +131,8 @@ def update_calendar(
cal.color = data.color cal.color = data.color
if data.enabled is not None: if data.enabled is not None:
cal.enabled = data.enabled cal.enabled = data.enabled
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled
if data.is_birthday is not None:
# Never let a second calendar be marked as the birthday calendar.
if data.is_birthday and not cal.is_birthday:
other = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
models.LocalCalendar.id != cal.id,
)
.first()
)
if other is not None:
raise HTTPException(422, "A birthday calendar already exists")
cal.is_birthday = data.is_birthday
if data.birthday_notify_days_before is not None:
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
# send SQL NULL through an Optional that also means "unchanged").
cal.birthday_notify_days_before = (
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
)
if data.caldav_published is not None:
cal.caldav_published = data.caldav_published
if data.caldav_published:
# First publish: mint a token + initial ctag so clients can sync.
if not cal.dav_token:
cal.dav_token = dav_util.new_token()
if not cal.dav_ctag:
cal.dav_ctag = dav_util.new_tag()
else:
# Unpublishing revokes access: drop the token so the URL 404s.
cal.dav_token = None
db.commit() db.commit()
db.refresh(cal) return {"ok": True}
return _cal_dict(cal, owned=True, request=request)
class CalendarColorUpdate(BaseModel):
color: str
@router.put("/calendars/{calendar_id}/color")
def set_calendar_color(
calendar_id: int,
data: CalendarColorUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Set a calendar's colour. The owner changes the calendar's colour for
everyone; anyone else who can see the calendar (direct share, group calendar,
or a co-member's group-visible calendar) sets only their OWN per-user colour.
Recipients may recolour but never rename a shared calendar."""
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.color = data.color
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.CalendarColorPref)
.filter(
models.CalendarColorPref.calendar_id == calendar_id,
models.CalendarColorPref.user_id == current_user.id,
)
.first()
)
if pref:
pref.color = data.color
else:
db.add(models.CalendarColorPref(
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
db.commit()
return {"ok": True, "color": data.color}
@router.post("/calendars/{calendar_id}/dav-token/rotate")
def rotate_dav_token(
calendar_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if not cal.caldav_published:
raise HTTPException(422, "Calendar is not published")
cal.dav_token = dav_util.new_token()
cal.dav_ctag = dav_util.new_tag()
db.commit()
db.refresh(cal)
return _cal_dict(cal, owned=True, request=request)
@router.delete("/calendars/{calendar_id}") @router.delete("/calendars/{calendar_id}")
@@ -404,73 +164,33 @@ def create_event(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Owner, shared (read_write), or group-member calendars are writable. cal = (
cal = permissions.accessible_local_calendar( db.query(models.LocalCalendar)
db, current_user, data.calendar_id, require_write=True .filter(
models.LocalCalendar.id == data.calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
) )
if not cal:
raise HTTPException(404, "Calendar not found")
# Idempotent on external_uid: repeated syncs of the same contact (stable ev = models.LocalEvent(
# "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row calendar_id=cal.id,
# with this external_uid already exists in this calendar, update it in place uid=str(uuid.uuid4()),
# instead of inserting a new one. Enforced server-side, so the client can title=data.title,
# never duplicate — even without the reconcile read. start=data.start,
existing = None end=data.end,
if data.external_uid: all_day=data.allDay,
existing = ( location=data.location,
db.query(models.LocalEvent) description=data.description,
.filter( color=data.color,
models.LocalEvent.calendar_id == cal.id, rrule=data.rrule,
models.LocalEvent.external_uid == data.external_uid, )
) db.add(ev)
.first()
)
reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if existing is not None:
ev = existing
ev.title = data.title
ev.start = data.start
ev.end = data.end
ev.all_day = data.allDay
ev.location = data.location
ev.description = data.description
ev.color = data.color
ev.rrule = data.rrule
ev.is_private = data.private
ev.reminders = reminders
ev.birth_year = data.birth_year
else:
ev = models.LocalEvent(
calendar_id=cal.id,
uid=str(uuid.uuid4()),
title=data.title,
start=data.start,
end=data.end,
all_day=data.allDay,
location=data.location,
description=data.description,
color=data.color,
rrule=data.rrule,
is_private=data.private,
reminders=reminders,
external_uid=data.external_uid,
birth_year=data.birth_year,
creator_id=current_user.id, # server-side, never from the client
)
db.add(ev)
dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
return _event_dict(ev, cal, db) return _event_dict(ev, cal)
def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent:
ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if not ev:
raise HTTPException(404, "Event not found")
# Raises 404/403 unless the user may write this event's calendar.
permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True)
return ev
@router.put("/events/{uid}") @router.put("/events/{uid}")
@@ -480,9 +200,17 @@ def update_event(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
ev = _writable_event(db, current_user, uid) ev = (
if data.private is not None: db.query(models.LocalEvent)
ev.is_private = data.private .join(models.LocalCalendar)
.filter(
models.LocalEvent.uid == uid,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not ev:
raise HTTPException(404, "Event not found")
if data.title is not None: if data.title is not None:
ev.title = data.title ev.title = data.title
if data.start is not None: if data.start is not None:
@@ -505,14 +233,6 @@ def update_event(
if data.exdate not in dates: if data.exdate not in dates:
dates.append(data.exdate) dates.append(data.exdate)
ev.exdate = ",".join(dates) ev.exdate = ",".join(dates)
if data.reminders is not None:
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if data.external_uid is not None:
ev.external_uid = data.external_uid
if data.birth_year is not None:
# -1 is the client's sentinel for "clear" (year became unknown).
ev.birth_year = None if data.birth_year < 0 else data.birth_year
dav_util.bump_dav(ev.calendar, ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -523,259 +243,17 @@ def delete_event(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
ev = _writable_event(db, current_user, uid) ev = (
dav_util.bump_dav(ev.calendar) db.query(models.LocalEvent)
.join(models.LocalCalendar)
.filter(
models.LocalEvent.uid == uid,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not ev:
raise HTTPException(404, "Event not found")
db.delete(ev) db.delete(ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.get("/calendars/{calendar_id}/birthdays")
def list_birthday_entries(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
reconcile against the address book by ``external_uid``. Contact-sourced rows
carry an ``external_uid``; manually added birthdays have ``None`` and must be
left untouched by the importer."""
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
events = (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
out = []
for ev in events:
month = day = None
try:
parts = (ev.start or "")[:10].split("-")
if len(parts) == 3:
month, day = int(parts[1]), int(parts[2])
except (ValueError, IndexError):
pass
out.append({
"uid": ev.uid,
"external_uid": ev.external_uid,
"title": ev.title,
"month": month,
"day": day,
"birth_year": ev.birth_year,
})
return out
# ── Sharing (owner only) ──────────────────────────────────
@router.get("/calendars/{calendar_id}/shares")
def list_shares(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
permissions.is_calendar_owner(db, current_user, calendar_id)
shares = (
db.query(models.CalendarShare)
.filter(models.CalendarShare.calendar_id == calendar_id)
.all()
)
out = []
for s in shares:
u = db.query(models.User).filter(models.User.id == s.user_id).first()
out.append({
"user_id": s.user_id,
"display_name": (u.display_name or u.username) if u else None,
"permission": s.permission,
"created_at": s.created_at,
})
return out
@router.post("/calendars/{calendar_id}/shares")
def add_share(
calendar_id: int,
data: ShareCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
permissions.is_calendar_owner(db, current_user, calendar_id)
if data.permission not in ("read", "read_write"):
raise HTTPException(422, "permission must be 'read' or 'read_write'")
target = db.query(models.User).filter(models.User.id == data.user_id).first()
if not target:
raise HTTPException(404, "User not found")
if target.id == current_user.id:
raise HTTPException(422, "Cannot share a calendar with yourself")
share = (
db.query(models.CalendarShare)
.filter(
models.CalendarShare.calendar_id == calendar_id,
models.CalendarShare.user_id == data.user_id,
)
.first()
)
if share:
share.permission = data.permission # update existing
else:
share = models.CalendarShare(
calendar_id=calendar_id,
user_id=data.user_id,
permission=data.permission,
created_at=_now_iso(),
)
db.add(share)
db.commit()
return {"ok": True}
@router.delete("/calendars/{calendar_id}/shares/{user_id}")
def remove_share(
calendar_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
permissions.is_calendar_owner(db, current_user, calendar_id)
share = (
db.query(models.CalendarShare)
.filter(
models.CalendarShare.calendar_id == calendar_id,
models.CalendarShare.user_id == user_id,
)
.first()
)
if not share:
raise HTTPException(404, "Share not found")
db.delete(share)
db.commit()
return {"ok": True}
# ── iCal Import / Export (local calendars only) ───────────
def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict:
parsed = ical_io.parse_ics(raw)
imported = 0
skipped = 0
errors = list(parsed["errors"])
# local_events.uid is globally unique. Dedupe against the DB AND within this
# file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a
# UID (RECURRENCE-ID overrides), which would otherwise violate the constraint.
seen_uids: set[str] = set()
for item in parsed["events"]:
uid = item.get("uid") or str(uuid.uuid4())
if uid in seen_uids:
skipped += 1
continue
seen_uids.add(uid)
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if existing:
skipped += 1
continue
ev = models.LocalEvent(
calendar_id=cal.id,
uid=uid,
title=item.get("title") or "(ohne Titel)",
start=item["start"],
end=item["end"],
all_day=item.get("all_day", False),
location=item.get("location"),
description=item.get("description"),
rrule=item.get("rrule"),
exdate=item.get("exdate"),
creator_name_external=item.get("organizer"),
etag=dav_util.new_tag(),
)
db.add(ev)
imported += 1
if imported:
dav_util.bump_dav(cal)
try:
db.commit()
except Exception as exc:
db.rollback()
raise ValueError(f"Import fehlgeschlagen: {exc}")
return {"imported": imported, "skipped": skipped, "errors": errors}
# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM).
MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars
async def _read_capped(file: UploadFile) -> bytes:
raw = await file.read(MAX_ICS_BYTES + 1)
if len(raw) > MAX_ICS_BYTES:
raise HTTPException(413, "Datei zu groß (max. 5 MB)")
return raw
@router.post("/calendars/{calendar_id}/import")
async def import_calendar(
calendar_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
raw = await _read_capped(file)
try:
return _import_ics_into(cal, raw, db)
except ValueError as e:
raise HTTPException(422, str(e))
@router.post("/import")
async def import_generic(
file: UploadFile = File(...),
calendar_id: Optional[int] = Form(None),
create_calendar: bool = Form(False),
calendar_name: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Read (capped) first so an oversized upload can't leave an empty calendar.
raw = await _read_capped(file)
if create_calendar:
cal = models.LocalCalendar(
user_id=current_user.id,
name=(calendar_name or "Importiert")[:120],
)
db.add(cal)
db.commit()
db.refresh(cal)
elif calendar_id is not None:
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
else:
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
try:
result = _import_ics_into(cal, raw, db)
except ValueError as e:
raise HTTPException(422, str(e))
result["calendar_id"] = cal.id
return result
@router.get("/calendars/{calendar_id}/export")
def export_calendar(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
events = (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
# Resolve creator display names for ORGANIZER.
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
return Response(
content=ics,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
)

View File

@@ -1,8 +1,5 @@
import io import io
import re
import base64 import base64
import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -11,13 +8,11 @@ import qrcode
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from PIL import Image from PIL import Image
from pydantic import BaseModel, Field from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
import models import models
from auth import create_access_token, get_current_user, get_password_hash, verify_password from auth import get_current_user, get_password_hash, verify_password
from database import DATA_DIR, get_db from database import DATA_DIR, get_db
router = APIRouter() router = APIRouter()
@@ -30,17 +25,7 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ─────────────────────────────────────────────── # ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Length caps (SQLite ignores VARCHAR limits, so enforce here). email: Optional[str] = None
email: Optional[str] = Field(default=None, max_length=120)
display_name: Optional[str] = Field(default=None, max_length=80)
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
def _strip_controls(s: str) -> str:
"""Remove control characters (defends against injected newlines / NULs that
could be reflected in other clients' calendar/sharing/group views)."""
return re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
@@ -62,12 +47,10 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
return { return {
"id": current_user.id, "id": current_user.id,
"username": current_user.username, "username": current_user.username,
"display_name": current_user.display_name or current_user.username,
"email": current_user.email, "email": current_user.email,
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None, "has_avatar": current_user.avatar_filename is not None,
"totp_enabled": current_user.totp_enabled, "totp_enabled": current_user.totp_enabled,
"directory_hidden": bool(current_user.directory_hidden),
} }
@@ -77,51 +60,10 @@ def update_profile(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
result = {"ok": True}
if data.email is not None: if data.email is not None:
email = _strip_controls(data.email) current_user.email = data.email or None
if email:
if "@" not in email or "." not in email.split("@")[-1]:
raise HTTPException(422, "Invalid email address")
clash = (
db.query(models.User)
.filter(func.lower(models.User.email) == email.lower(),
models.User.id != current_user.id)
.first()
)
if clash:
raise HTTPException(400, "Email already in use")
current_user.email = email
else:
current_user.email = None
if data.display_name is not None:
dn = _strip_controls(data.display_name)
current_user.display_name = dn or current_user.username
if data.username is not None:
new_login = _strip_controls(data.username).lower()
if not new_login:
raise HTTPException(422, "Login name cannot be empty")
if new_login != current_user.username:
taken = (
db.query(models.User)
.filter(func.lower(models.User.username) == new_login,
models.User.id != current_user.id)
.first()
)
if taken:
raise HTTPException(400, "Username already taken")
current_user.username = new_login
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit()
# The JWT 'sub' is the login name — renaming it invalidates the old
# token, so hand back a fresh one for the client to store.
result["access_token"] = create_access_token({"sub": new_login})
return result
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit() db.commit()
return result return {"ok": True}
# ── Avatar ──────────────────────────────────────────────── # ── Avatar ────────────────────────────────────────────────
@@ -176,11 +118,7 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
@router.get("/avatar/{user_id}") @router.get("/avatar/{user_id}")
def get_user_avatar( def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user or not user.avatar_filename: if not user or not user.avatar_filename:
raise HTTPException(404, "Kein Profilbild") raise HTTPException(404, "Kein Profilbild")
@@ -276,74 +214,3 @@ def disable_totp(
current_user.totp_enabled = False current_user.totp_enabled = False
db.commit() db.commit()
return {"ok": True} return {"ok": True}
# ── App passwords (for CalDAV Basic Auth) ────────────────
class AppPasswordCreate(BaseModel):
label: str = Field(default="CalDAV", max_length=100)
def _app_pw_dict(ap: models.AppPassword) -> dict:
return {
"id": ap.id,
"label": ap.label,
"created_at": ap.created_at,
"last_used_at": ap.last_used_at,
}
@router.get("/app-passwords")
def list_app_passwords(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.AppPassword)
.filter(models.AppPassword.user_id == current_user.id)
.order_by(models.AppPassword.id.desc())
.all()
)
return [_app_pw_dict(r) for r in rows]
@router.post("/app-passwords")
def create_app_password(
data: AppPasswordCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Show the plaintext exactly once; only the hash is stored.
password = secrets.token_urlsafe(18)
ap = models.AppPassword(
user_id=current_user.id,
label=(data.label or "CalDAV")[:100],
password_hash=get_password_hash(password),
created_at=datetime.now(timezone.utc).isoformat(),
)
db.add(ap)
db.commit()
db.refresh(ap)
out = _app_pw_dict(ap)
out["password"] = password
return out
@router.delete("/app-passwords/{ap_id}")
def delete_app_password(
ap_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ap = (
db.query(models.AppPassword)
.filter(
models.AppPassword.id == ap_id,
models.AppPassword.user_id == current_user.id,
)
.first()
)
if not ap:
raise HTTPException(404, "App password not found")
db.delete(ap)
db.commit()
return {"ok": True}

View File

@@ -1,7 +1,6 @@
import json
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,56 +11,6 @@ from database import get_db
router = APIRouter() router = APIRouter()
# Which settings can sync across a user's devices, and the default flag applied to
# an existing/new account until the user overrides it. This map is the single
# authority: GET returns the fully-resolved flags so no client duplicates default
# logic. Rollout rule: settings that already lived on the server default to True;
# the four newly-syncable device-local prefs default to False.
DEFAULT_SYNC = {
"default_view": True,
"week_start_day": True,
"dim_past_events": True,
"hour_height": True,
"primary_color": True,
"accent_color": True,
"today_color": True,
"text_color": True,
"line_color": True,
"bg_color": True,
"month_divider_color": True,
"month_label_color": True,
"default_event_duration_minutes": True,
"default_reminder_minutes": True,
"language": False,
"share_calendar_icon": False,
"cache_months": False,
"month_view_paged": False,
"surface_color": False,
"hover_highlight_color": True,
"icon_inactive_color": True,
"icon_active_color": True,
"day_hover_color": True,
"day_selected_color": True,
"day_bg_color": True,
"today_bg_color": True,
}
def _resolve_sync_flags(s: models.UserSettings) -> dict:
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
top of DEFAULT_SYNC, junk keys dropped."""
stored = {}
if s.sync_flags:
try:
stored = json.loads(s.sync_flags) or {}
except (ValueError, TypeError):
stored = {}
return {
key: bool(stored[key]) if key in stored else default
for key, default in DEFAULT_SYNC.items()
}
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
default_view: Optional[str] = None default_view: Optional[str] = None
week_start_day: Optional[str] = None week_start_day: Optional[str] = None
@@ -75,25 +24,6 @@ class SettingsUpdate(BaseModel):
language: Optional[str] = None language: Optional[str] = None
month_divider_color: Optional[str] = None month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None month_label_color: Optional[str] = None
text_color: Optional[str] = None
line_color: Optional[str] = None
bg_color: Optional[str] = None
surface_color: Optional[str] = None
hover_highlight_color: Optional[str] = None
icon_inactive_color: Optional[str] = None
icon_active_color: Optional[str] = None
day_hover_color: Optional[str] = None
day_selected_color: Optional[str] = None
day_bg_color: Optional[str] = None
today_bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off
default_event_duration_minutes: Optional[int] = None
share_calendar_icon: Optional[str] = None
cache_months: Optional[int] = None
month_view_paged: Optional[bool] = None
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -110,25 +40,6 @@ def _settings_dict(s: models.UserSettings) -> dict:
"language": s.language or "de", "language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0", "month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0", "month_label_color": s.month_label_color or "#7090c0",
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
"surface_color": s.surface_color,
"hover_highlight_color": s.hover_highlight_color,
"icon_inactive_color": s.icon_inactive_color,
"icon_active_color": s.icon_active_color,
"day_hover_color": s.day_hover_color,
"day_selected_color": s.day_selected_color,
"day_bg_color": s.day_bg_color,
"today_bg_color": s.today_bg_color,
"private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes,
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
"share_calendar_icon": s.share_calendar_icon,
"cache_months": s.cache_months or 3,
"month_view_paged": bool(s.month_view_paged),
"sync_flags": _resolve_sync_flags(s),
} }
@@ -165,50 +76,8 @@ def update_settings(
settings = models.UserSettings(user_id=current_user.id) settings = models.UserSettings(user_id=current_user.id)
db.add(settings) db.add(settings)
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): for field, value in data.model_dump(exclude_none=True).items():
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'") setattr(settings, field, value)
# A birthday calendar must never become the group-visible ("personal")
# calendar — it may be shared directly, but not stand in as your calendar in
# group views. Clients filter it out of the picker; this is the safety net.
if data.group_visible_calendar_id:
bcal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.group_visible_calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if bcal is not None and bcal.is_birthday:
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
update_data = data.model_dump(exclude_unset=True)
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
# syncable keys are kept; a partial map leaves untouched flags as they were.
if "sync_flags" in update_data:
incoming = update_data.pop("sync_flags") or {}
current = {}
if settings.sync_flags:
try:
current = json.loads(settings.sync_flags) or {}
except (ValueError, TypeError):
current = {}
for key, val in incoming.items():
if key in DEFAULT_SYNC:
current[key] = bool(val)
settings.sync_flags = json.dumps(current)
for field, value in update_data.items():
if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None)
elif value is not None:
setattr(settings, field, value)
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -23,18 +23,8 @@ class ChangePasswordRequest(BaseModel):
password: str password: str
class SetAdminRequest(BaseModel):
is_admin: bool
def _user_dict(u: models.User) -> dict: def _user_dict(u: models.User) -> dict:
return { return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin}
"id": u.id,
"username": u.username,
"display_name": u.display_name or u.username,
"email": u.email,
"is_admin": u.is_admin,
}
@router.get("/") @router.get("/")
@@ -45,28 +35,6 @@ def list_users(
return [_user_dict(u) for u in db.query(models.User).all()] return [_user_dict(u) for u in db.query(models.User).all()]
@router.get("/directory")
def user_directory(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Lightweight list of all users (id + display_name) for sharing/group pickers.
Available to any authenticated user (unlike GET / which is admin-only).
Excludes the requesting user.
"""
users = (
db.query(models.User)
.filter(
models.User.id != current_user.id,
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
)
.order_by(models.User.username)
.all()
)
return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
@router.post("/") @router.post("/")
def create_user( def create_user(
req: CreateUserRequest, req: CreateUserRequest,
@@ -77,7 +45,6 @@ def create_user(
raise HTTPException(400, "Username already taken") raise HTTPException(400, "Username already taken")
user = models.User( user = models.User(
username=req.username.lower(), username=req.username.lower(),
display_name=req.username.strip(), # keep the original casing for display
email=req.email, email=req.email,
password_hash=get_password_hash(req.password), password_hash=get_password_hash(req.password),
is_admin=req.is_admin, is_admin=req.is_admin,
@@ -106,28 +73,6 @@ def delete_user(
return {"ok": True} return {"ok": True}
@router.put("/{user_id}/admin")
def set_admin(
user_id: int,
req: SetAdminRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_admin),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot change your own admin status")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
# Never leave the instance without an admin.
if user.is_admin and not req.is_admin:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
if admin_count <= 1:
raise HTTPException(400, "At least one admin must remain")
user.is_admin = req.is_admin
db.commit()
return {"ok": True}
@router.put("/{user_id}/password") @router.put("/{user_id}/password")
def change_password( def change_password(
user_id: int, user_id: int,

View File

@@ -1,61 +0,0 @@
"""Pytest fixtures: an isolated app + temp SQLite DB, wiped between tests."""
import os
import sys
import tempfile
from pathlib import Path
# Use a throwaway data dir BEFORE importing the app (database.py reads DATA_DIR
# at import time and builds the engine from it).
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp(prefix="calendarr-test-"))
os.environ.setdefault("SECRET_KEY", "test-secret-key")
BACKEND_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_DIR))
import pytest
from fastapi.testclient import TestClient
import main # noqa: E402 (creates tables + runs migrations against the temp DB)
import models # noqa: E402
from database import engine # noqa: E402
@pytest.fixture
def client():
return TestClient(main.app)
@pytest.fixture(autouse=True)
def clean_db():
"""Wipe every table before each test for isolation."""
with engine.begin() as conn:
for table in reversed(models.Base.metadata.sorted_tables):
conn.execute(table.delete())
yield
# ── Helpers ───────────────────────────────────────────────
def register_admin(client, username="admin", password="pw"):
r = client.post("/api/auth/setup", json={"username": username, "password": password})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def create_user(client, admin_token, username, password="pw"):
r = client.post(
"/api/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={"username": username, "password": password},
)
assert r.status_code == 200, r.text
uid = r.json()["id"]
# Log in to get the user's own token.
r2 = client.post("/api/auth/login", json={"username": username, "password": password})
assert r2.status_code == 200, r2.text
return uid, r2.json()["access_token"]
def auth(token):
return {"Authorization": f"Bearer {token}"}

View File

@@ -1,514 +0,0 @@
"""Tests for sharing, group permissions, the iCal parser, and private filtering."""
from conftest import register_admin, create_user, auth
RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
def _make_calendar(client, token, name="Cal"):
r = client.post("/api/local/calendars", headers=auth(token), json={"name": name})
assert r.status_code == 200, r.text
return r.json()["id"]
def _make_event(client, token, cal_id, title="Event", private=False,
start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"):
r = client.post("/api/local/events", headers=auth(token), json={
"calendar_id": cal_id, "title": title, "start": start, "end": end,
"private": private,
})
assert r.status_code == 200, r.text
return r.json()
# ── Sharing ───────────────────────────────────────────────
def test_share_read_then_read_write(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin, "Admins Kalender")
ev = _make_event(client, admin, cal_id, "Meeting")
# Creator field populated server-side.
assert ev["creator"]["display_name"] == "admin"
assert ev["type"] == "local"
# Share read-only with bob.
r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
json={"user_id": b_id, "permission": "read"})
assert r.status_code == 200, r.text
# Bob sees the shared calendar with shared_by.
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
shared = [c for c in cals if not c["owned"]]
assert len(shared) == 1
assert shared[0]["shared_by"] == "admin"
assert shared[0]["permission"] == "read"
# Bob sees the event in the merged read.
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
assert any(e["title"] == "Meeting" for e in events)
# Bob cannot write (read-only) -> 403.
r = client.post("/api/local/events", headers=auth(b_tok), json={
"calendar_id": cal_id, "title": "Nope",
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
})
assert r.status_code == 403, r.text
# Upgrade to read_write -> bob can write.
client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
json={"user_id": b_id, "permission": "read_write"})
r = client.post("/api/local/events", headers=auth(b_tok), json={
"calendar_id": cal_id, "title": "Bobs Eintrag",
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
})
assert r.status_code == 200, r.text
# Created by bob.
assert r.json()["creator"]["display_name"] == "bob"
def test_non_owner_cannot_manage_shares(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
# Bob (no access at all) cannot list shares -> 404 (existence hidden).
r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok))
assert r.status_code == 404
def test_unshared_calendar_invisible(client):
admin = register_admin(client)
_b_id, b_tok = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
_make_event(client, admin, cal_id, "Privat")
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
assert not any(e["title"] == "Privat" for e in events)
# ── Groups ────────────────────────────────────────────────
def test_group_create_and_members(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
c_id, c_tok = create_user(client, admin, "carol")
r = client.post("/api/groups/", headers=auth(admin),
json={"name": "Familie", "member_ids": [b_id]})
assert r.status_code == 200, r.text
group = r.json()
gid = group["id"]
assert group["group_calendar_id"] is not None
assert {m["display_name"] for m in group["members"]} == {"admin", "bob"}
# Both members see the group.
assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json())
# Carol is not a member.
assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json())
assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403
# Only owner adds members.
assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok),
json={"user_id": c_id}).status_code == 403
assert client.post(f"/api/groups/{gid}/members", headers=auth(admin),
json={"user_id": c_id}).status_code == 200
# Member can leave; owner cannot be removed.
assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200
admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"]
assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422
def test_group_members_can_write_group_calendar(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gcal = group["group_calendar_id"]
# Bob (member, not owner of the calendar) can create in the group calendar.
r = client.post("/api/local/events", headers=auth(b_tok), json={
"calendar_id": gcal, "title": "Teamtermin",
"start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00",
})
assert r.status_code == 200, r.text
def test_group_member_colors_and_display_color(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
gcal = group["group_calendar_id"]
# Each member has a server-assigned colour; the group exposes its calendar colour.
detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
assert all(m.get("color") for m in detail["members"])
assert detail.get("group_calendar_color")
# Owner can recolour a member.
r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin),
json={"color": "#123456"})
assert r.status_code == 200, r.text
detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"])
# Bob shares a calendar with an event; combined events carry display_color.
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
_make_event(client, admin, gcal, "Gruppentermin")
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
by_title = {e["title"]: e for e in evs}
assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour
assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"]
def test_group_calendar_listed_for_member(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gcal = group["group_calendar_id"]
# Bob (member, not owner) sees the group calendar in his local list, flagged.
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
gc = [c for c in cals if c["id"] == gcal]
assert gc and gc[0].get("group") is True
assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False
def test_combined_view_marks_owner_and_group_event(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
gcal = group["group_calendar_id"]
# Bob's own calendar + event; Bob designates it as his group-visible calendar.
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
# A group-calendar event.
_make_event(client, admin, gcal, "Gruppentermin")
events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
titles = {e["title"]: e for e in events}
assert "Bobs Termin" in titles
assert titles["Bobs Termin"]["owner"]["display_name"] == "bob"
assert titles["Bobs Termin"].get("is_group_event") is not True
assert "Gruppentermin" in titles
assert titles["Gruppentermin"]["is_group_event"] is True
# ── Private filtering ─────────────────────────────────────
def _combined_titles(client, token, gid):
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"]
return evs
def test_private_visibility_hidden_and_busy(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Geheimes", private=True,
start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00")
# Bob sees his own private event in full.
own = _combined_titles(client, b_tok, gid)
assert any(e["title"] == "Geheimes" for e in own)
# Default visibility = busy -> admin sees it as anonymous "Beschäftigt".
seen = _combined_titles(client, admin, gid)
busy = [e for e in seen if e["start"].startswith("2026-06-15")]
assert busy and all(e["title"] == "Beschäftigt" for e in busy)
assert all(e["location"] == "" and e["description"] == "" for e in busy)
# Switch bob to hidden -> admin no longer sees it at all.
client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"})
seen2 = _combined_titles(client, admin, gid)
assert not any(e["start"].startswith("2026-06-15") for e in seen2)
def test_member_calendar_hidden_until_designated(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
_make_event(client, b_tok, b_cal, "Bobs Termin")
# Not designated yet -> admin doesn't see Bob's calendar in the combined view.
seen = _combined_titles(client, admin, gid)
assert not any(e["title"] == "Bobs Termin" for e in seen)
# After Bob designates it, it appears.
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
seen2 = _combined_titles(client, admin, gid)
assert any(e["title"] == "Bobs Termin" for e in seen2)
def test_display_name_case_preserved_and_login_case_insensitive(client):
# Setup with mixed-case name: login name lowercased, display name kept.
r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"})
assert r.status_code == 200, r.text
user = r.json()["user"]
assert user["username"] == "guido"
assert user["display_name"] == "Guido"
# Login is case-insensitive (typing the display name "GUIDO" works).
r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"})
assert r2.status_code == 200, r2.text
tok = r2.json()["access_token"]
# /me reflects the cased display name.
me = client.get("/api/auth/me", headers=auth(tok)).json()
assert me["display_name"] == "Guido" and me["username"] == "guido"
def test_rename_login_name_returns_new_token(client):
admin = register_admin(client, "alice")
# Change display name (no token change).
r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."})
assert r.status_code == 200 and "access_token" not in r.json()
# Change login name -> fresh token, references survive (id is stable).
r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"})
assert r2.status_code == 200 and r2.json().get("access_token")
new_tok = r2.json()["access_token"]
me = client.get("/api/auth/me", headers=auth(new_tok)).json()
assert me["username"] == "alice2" and me["display_name"] == "Alice W."
def test_private_visibility_validation(client):
admin = register_admin(client)
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
assert r.status_code == 422
# ── iCal import/export ────────────────────────────────────
SAMPLE_ICS = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VEVENT
UID:evt-1@test
SUMMARY:Importiert 1
DTSTART:20260620T100000Z
DTEND:20260620T110000Z
LOCATION:Buero
ORGANIZER;CN=Max Mustermann:mailto:max@example.com
RRULE:FREQ=WEEKLY;BYDAY=MO
END:VEVENT
BEGIN:VEVENT
UID:evt-2@test
SUMMARY:Importiert 2
DTSTART;VALUE=DATE:20260621
DTEND;VALUE=DATE:20260622
END:VEVENT
END:VCALENDAR
"""
def test_ical_parser_roundtrip():
import ical_io
parsed = ical_io.parse_ics(SAMPLE_ICS)
assert len(parsed["events"]) == 2
ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test")
assert ev1["title"] == "Importiert 1"
assert ev1["location"] == "Buero"
assert ev1["organizer"] == "Max Mustermann"
assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test")
assert ev2["all_day"] is True
def test_import_dedupes_by_uid(client):
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "Import-Ziel")
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
assert r.status_code == 200, r.text
body = r.json()
assert body["imported"] == 2 and body["skipped"] == 0
# Re-import -> all skipped (UID dedupe).
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2
# Imported events carry the external creator name.
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
imported = [e for e in events if e["title"] == "Importiert 1"]
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
DUP_UID_ICS = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud
BEGIN:VEVENT
UID:recurring@nc
SUMMARY:Standup
DTSTART:20260601T090000Z
DTEND:20260601T091500Z
RRULE:FREQ=WEEKLY;BYDAY=MO
END:VEVENT
BEGIN:VEVENT
UID:recurring@nc
RECURRENCE-ID:20260608T090000Z
SUMMARY:Standup verschoben
DTSTART:20260608T100000Z
DTEND:20260608T101500Z
END:VEVENT
END:VCALENDAR
"""
def test_import_handles_duplicate_uid_in_file(client):
"""Nextcloud exports recurring events as multiple VEVENTs sharing a UID;
importing must not 500 on the unique constraint."""
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "NC")
files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")}
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
assert r.status_code == 200, r.text
body = r.json()
assert body["imported"] == 1 and body["skipped"] == 1
def test_export_contains_organizer_and_rrule(client):
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "Export-Test")
_make_event(client, admin, cal_id, "Wöchentlich")
# Add a recurring rule via update.
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
uid = next(e["id"] for e in events if e["title"] == "Wöchentlich")
client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"})
r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin))
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/calendar")
body = r.text
assert "BEGIN:VCALENDAR" in body
assert "ORGANIZER" in body and "admin" in body
assert "RRULE" in body
def test_import_export_only_local(client):
"""Import/export endpoints reject non-existent / inaccessible calendars."""
admin = register_admin(client)
_b, b_tok = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin, "Privat")
# Bob has no access -> 404 on export.
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
def test_group_visible_propagates_to_member_sidebar(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
# Bob designates a calendar as group-visible; admin (co-member) should see it.
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
shared = [c for c in cals if c["id"] == b_cal]
assert len(shared) == 1, shared
assert shared[0]["owned"] is False
assert shared[0]["shared_by"] == "bob"
assert shared[0]["permission"] == "read"
assert shared[0].get("group_shared") is True
# Its events appear in the normal merged read, read-only.
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
assert bob_ev and bob_ev[0].get("read_only") is True
def test_group_visible_absent_when_not_designated(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
assert not any(c["id"] == b_cal for c in cals)
def test_group_visible_not_duplicated_with_direct_share(client):
admin = register_admin(client)
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
# Also directly shared with admin (read_write) — must not double-list.
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
json={"user_id": admin_id, "permission": "read_write"})
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
matches = [c for c in cals if c["id"] == b_cal]
assert len(matches) == 1, matches
# The direct share wins (read_write, not flagged group_shared).
assert matches[0]["permission"] == "read_write"
assert matches[0].get("group_shared") is not True
# ── Hidden profile (directory opt-out) ────────────────────────────────────
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
assert any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
assert r.status_code == 200, r.text
assert not any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
# Admin user management still lists the hidden user.
assert any(u["id"] == b_id for u in
client.get("/api/users/", headers=auth(admin)).json())
def test_combined_view_read_only_for_other_members(client):
"""In the group combined view, events I may not edit carry read_only=True:
other members' calendars are read-only; the group calendar + my own aren't."""
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
gcal = group["group_calendar_id"]
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
_make_event(client, admin, gcal, "Gruppentermin")
# As admin: bob's event is read-only; the group calendar is editable.
by = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]}
assert by["Bobs Termin"].get("read_only") is True
assert by["Gruppentermin"].get("read_only") is not True
# As bob: his own event and the group calendar are both editable.
by_b = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]}
assert by_b["Bobs Termin"].get("read_only") is not True
assert by_b["Gruppentermin"].get("read_only") is not True

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2ea05a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/> <rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/> <line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/> <line x1="8" y1="2" x2="8" y2="6"/>

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#16713d"/> <rect width="512" height="512" fill="#4285f4"/>
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
<rect x="102" y="154" width="307" height="266" rx="26"/> <rect x="102" y="154" width="307" height="266" rx="26"/>
<line x1="102" y1="234" x2="409" y2="234"/> <line x1="102" y1="234" x2="409" y2="234"/>

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -1,14 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- Title wird beim Laden aus version.js dynamisch gesetzt --> <!-- APP_VERSION: update here + version.js on every release -->
<title>Calendarr</title> <title>Calendarr v11</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#16713d" /> <meta name="theme-color" content="#4285f4" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Calendarr" /> <meta name="apple-mobile-web-app-title" content="Calendarr" />
@@ -81,7 +80,7 @@
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
</div> </div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button>
</div> </div>
<!-- ─── MAIN APP ──────────────────────────────────────────── --> <!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -159,19 +158,10 @@
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-inner"> <div class="sidebar-inner">
<div class="create-split add-cal-dropdown-wrap"> <button class="btn btn-fab" id="btn-create-event">
<button class="btn btn-fab create-main" id="btn-create-event"> <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <span data-i18n="btn_create">Erstellen</span>
<span data-i18n="btn_create">Erstellen</span> </button>
</button>
<button class="btn btn-fab create-caret" id="btn-create-menu" data-i18n-title="birthday_new" title="Neuer Geburtstag" aria-haspopup="menu" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div class="add-cal-dropdown create-menu hidden" id="create-menu" role="menu">
<button data-action="event" data-i18n="create_event_option">Neuer Termin</button>
<button data-action="birthday" data-i18n="birthday_new">Neuer Geburtstag</button>
</div>
</div>
<!-- Mini Calendar --> <!-- Mini Calendar -->
<div class="mini-cal" id="mini-cal"> <div class="mini-cal" id="mini-cal">
@@ -189,25 +179,16 @@
<div class="mini-cal-days" id="mini-days"></div> <div class="mini-cal-days" id="mini-days"></div>
</div> </div>
<!-- Group members (shown only in the group overlay; hide/show people) -->
<div class="cal-list hidden" id="group-members">
<div class="cal-list-header">
<span data-i18n="group_members">Mitglieder</span>
</div>
<div id="group-members-items"></div>
</div>
<!-- Calendar List --> <!-- Calendar List -->
<div class="cal-list" id="cal-list"> <div class="cal-list" id="cal-list">
<div class="cal-list-header"> <div class="cal-list-header">
<span data-i18n="my_calendars">Meine Kalender</span> <span data-i18n="my_calendars">Meine Kalender</span>
<div class="add-cal-dropdown-wrap"> <div class="add-cal-dropdown-wrap">
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen"> <button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
</button> </button>
<div class="add-cal-dropdown hidden" id="add-cal-dropdown"> <div class="add-cal-dropdown hidden" id="add-cal-dropdown">
<button data-action="local">Lokaler Kalender</button> <button data-action="local">Lokaler Kalender</button>
<button data-action="birthday" data-i18n="birthday_calendar_type">Geburtstagskalender</button>
<button data-action="caldav">CalDAV-Konto</button> <button data-action="caldav">CalDAV-Konto</button>
<button data-action="ical">iCal-URL abonnieren</button> <button data-action="ical">iCal-URL abonnieren</button>
<button data-action="google">Google Kalender</button> <button data-action="google">Google Kalender</button>
@@ -217,30 +198,13 @@
</div> </div>
<div id="cal-list-items"></div> <div id="cal-list-items"></div>
</div> </div>
<!-- Groups -->
<div class="cal-list" id="group-list">
<div class="cal-list-header">
<span data-i18n="groups_title">Gruppen</span>
<button class="icon-btn mini-btn" id="btn-add-group" data-i18n-title="group_create" title="Gruppe erstellen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</div>
<div id="group-list-items"></div>
</div>
</div> </div>
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button> <button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button>
</aside> </aside>
<div id="sidebar-backdrop" class="sidebar-backdrop"></div> <div id="sidebar-backdrop" class="sidebar-backdrop"></div>
<!-- MAIN VIEW --> <!-- MAIN VIEW -->
<main class="main-view" id="main-view"> <main class="main-view" id="main-view">
<div id="group-view-banner" class="group-view-banner hidden">
<span id="group-view-label"></span>
<div style="flex:1"></div>
<button class="btn btn-primary btn-sm" id="group-view-new-event" data-i18n="group_new_event">+ Gruppentermin</button>
<button class="btn btn-ghost btn-sm" id="group-view-exit" data-i18n="group_exit">Gruppenansicht verlassen</button>
</div>
<div id="view-container"></div> <div id="view-container"></div>
</main> </main>
@@ -271,7 +235,7 @@
<input type="hidden" id="ev-start" /> <input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div> </div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
@@ -279,7 +243,7 @@
<input type="hidden" id="ev-end" /> <input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
@@ -289,7 +253,7 @@
<input type="hidden" id="ev-start-date" /> <input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div> </div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
@@ -297,7 +261,7 @@
<input type="hidden" id="ev-end-date" /> <input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
@@ -347,27 +311,13 @@
<input type="hidden" id="ev-rec-until" /> <input type="hidden" id="ev-rec-until" />
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button"> <div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Kalender</label> <label>Kalender</label>
<!-- Hidden native select stays the value holder + change-event source; <select id="ev-calendar"></select>
the custom dropdown below renders colour dots and group icons. -->
<select id="ev-calendar" class="cal-native-hidden"></select>
<div class="cal-select" id="ev-cal-select">
<button type="button" class="cal-select-trigger" id="ev-cal-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="cal-select-current" id="ev-cal-current"></span>
<svg class="cal-select-chevron" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div class="cal-select-menu hidden" id="ev-cal-menu" role="listbox"></div>
</div>
</div>
<div class="form-row" id="ev-private-row" style="display:none">
<label class="toggle-label">
<input type="checkbox" id="ev-private" /> <span data-i18n="event_private">Privat</span>
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Ort</label> <label>Ort</label>
@@ -384,12 +334,6 @@
<div class="ev-color-preview" id="ev-color-preview" title="Farbe wählen"></div> <div class="ev-color-preview" id="ev-color-preview" title="Farbe wählen"></div>
</div> </div>
</div> </div>
<div class="form-group" id="ev-reminders-group" style="display:none">
<label data-i18n="reminders">Benachrichtigungen</label>
<div id="ev-reminders-hint" class="form-hint" style="display:none" data-i18n="reminders_calendar_off">Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.</div>
<div id="ev-reminders-list" class="ev-reminders-list"></div>
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-danger hidden" id="ev-delete">Löschen</button> <button class="btn btn-danger hidden" id="ev-delete">Löschen</button>
@@ -400,68 +344,6 @@
</div> </div>
</div> </div>
<!-- Create Group Modal -->
<div id="modal-group" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3 id="group-modal-title" data-i18n="group_create">Gruppe erstellen</h3>
<button class="icon-btn modal-close" data-modal="modal-group">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label data-i18n="group_name">Name</label>
<input type="text" id="group-name" data-i18n-placeholder="group_name_ph" placeholder="Gruppenname" />
</div>
<div class="form-group">
<label data-i18n="group_icon">Icon</label>
<div id="group-icon-picker" class="group-icon-picker"></div>
</div>
<div class="form-group">
<label data-i18n="group_members">Mitglieder</label>
<div id="group-member-picker" class="share-user-picker"></div>
</div>
<div class="form-group" id="group-colors-group" style="display:none">
<label data-i18n="group_member_colors">Farben der Mitglieder</label>
<div id="group-member-colors"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger hidden" id="group-delete" data-i18n="group_delete">Gruppe löschen</button>
<div style="flex:1"></div>
<button class="btn btn-ghost" data-modal="modal-group" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-primary" id="group-save" data-i18n="save">Speichern</button>
</div>
</div>
</div>
<!-- Share Calendar Modal -->
<div id="modal-share" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3 data-i18n="share_title">Kalender teilen</h3>
<button class="icon-btn modal-close" data-modal="modal-share">&times;</button>
</div>
<div class="modal-body">
<h4 class="panel-title" data-i18n="share_current">Aktuelle Freigaben</h4>
<div id="share-current-list" class="accounts-list"></div>
<h4 class="panel-title" style="margin-top:20px" data-i18n="share_add">Benutzer hinzufügen</h4>
<div class="form-row" style="gap:8px;align-items:center">
<input type="text" id="share-user-search" data-i18n-placeholder="share_search" placeholder="Benutzer suchen…" style="flex:1" />
<select id="share-permission">
<option value="read" data-i18n="perm_read">Nur lesen</option>
<option value="read_write" data-i18n="perm_read_write">Lesen &amp; schreiben</option>
</select>
</div>
<div id="share-user-picker" class="share-user-picker"></div>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-primary" data-modal="modal-share" data-i18n="done">Fertig</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal --> <!-- Delete Confirm Modal -->
<div id="modal-delete-confirm" class="modal-overlay hidden"> <div id="modal-delete-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px"> <div class="modal-card" style="max-width:400px">
@@ -488,65 +370,27 @@
</div> </div>
</div> </div>
<!-- Generic Confirm Dialog (styled replacement for window.confirm) -->
<div id="modal-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 id="confirm-title">Bestätigen</h3>
<button class="icon-btn modal-close" data-modal="modal-confirm">&times;</button>
</div>
<div class="modal-body">
<p id="confirm-text"></p>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-ghost" id="confirm-cancel" data-modal="modal-confirm" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-danger" id="confirm-ok" data-i18n="delete">Löschen</button>
</div>
</div>
</div>
<!-- Event Detail Popup --> <!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden"> <div id="popup-event" class="event-popup hidden">
<div class="popup-header"> <div class="popup-header">
<div class="popup-color-dot" id="popup-color-dot"></div> <div class="popup-color-dot" id="popup-color-dot"></div>
<h4 id="popup-title"></h4> <h4 id="popup-title"></h4>
<div class="popup-toolbar"> <button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
<button class="popup-icon-btn" id="popup-edit" title="Bearbeiten" aria-label="Bearbeiten"> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> </button>
</button> <button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
<button class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren"> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg> </button>
</button> <button class="icon-btn popup-action" id="popup-delete" title="Löschen">
<button class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen"> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> </button>
</button> <button class="icon-btn popup-close" id="popup-close">&times;</button>
<button class="popup-icon-btn popup-icon-btn-close" id="popup-close" title="Schließen" aria-label="Schließen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
</div> </div>
<div class="popup-body"> <div class="popup-body">
<div class="popup-row" id="popup-row-time"> <div class="popup-time" id="popup-time"></div>
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg> <div class="popup-location" id="popup-location"></div>
<span id="popup-time"></span> <div class="popup-description" id="popup-description"></div>
</div> <div class="popup-calendar" id="popup-calendar"></div>
<div class="popup-row" id="popup-row-location" style="display:none">
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1112 6.5a2.5 2.5 0 010 5z"/></svg>
<span id="popup-location"></span>
</div>
<div class="popup-row popup-row-desc" id="popup-row-description" style="display:none">
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h12v2H3v-2z"/></svg>
<span id="popup-description"></span>
</div>
<div class="popup-row" id="popup-row-calendar">
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>
<span id="popup-calendar"></span>
</div>
<div class="popup-row" id="popup-row-creator" style="display:none">
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 12a5 5 0 100-10 5 5 0 000 10zm0 2c-5.33 0-8 2.67-8 6v2h16v-2c0-3.33-2.67-6-8-6z"/></svg>
<span id="popup-creator"></span>
</div>
</div> </div>
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div> <div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
</div> </div>
@@ -618,44 +462,6 @@
</div> </div>
</div> </div>
<!-- Birthday modal -->
<div id="modal-birthday" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 data-i18n="birthday_modal_title">Neuen Geburtstag hinzufügen</h3>
<button class="icon-btn modal-close" data-modal="modal-birthday">&times;</button>
</div>
<div class="modal-body">
<div id="birthday-no-cal" class="hidden">
<div class="form-hint" data-i18n="birthday_activate_hint">Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.</div>
<button class="btn btn-primary btn-sm" id="birthday-activate" data-i18n="birthday_activate">Geburtstagskalender aktivieren</button>
</div>
<div id="birthday-form">
<div class="form-group">
<label data-i18n="birthday_person">Name</label>
<input type="text" id="birthday-name" data-i18n-ph="birthday_person_ph" placeholder="Name der Person" />
</div>
<div class="form-group">
<label data-i18n="birthday_date">Geburtstag</label>
<div class="birthday-date-row">
<select id="birthday-day"></select>
<select id="birthday-month"></select>
<input type="number" id="birthday-year" min="1900" max="2100" data-i18n-ph="birthday_year_ph" placeholder="Jahr" />
</div>
<label class="checkbox-row" style="margin-top:10px;display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:normal;font-weight:400;cursor:pointer">
<input type="checkbox" id="birthday-year-unknown" style="width:16px;height:16px;flex:none;margin:0" />
<span data-i18n="birthday_year_unknown">Jahr unbekannt</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-birthday" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-primary" id="birthday-save" data-i18n="save">Speichern</button>
</div>
</div>
</div>
<!-- iCal Subscription Modal --> <!-- iCal Subscription Modal -->
<div id="modal-ical-sub" class="modal-overlay hidden"> <div id="modal-ical-sub" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px"> <div class="modal-card" style="max-width:480px">
@@ -757,109 +563,148 @@
<div class="settings-page-body"> <div class="settings-page-body">
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div> <div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
<nav class="settings-nav"> <nav class="settings-nav">
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button> <button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button> <button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button>
</nav> </nav>
<div class="settings-panels"> <div class="settings-panels">
<!-- Profil: Name, Privatsphäre, geteilter Kalender --> <!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) -->
<div class="settings-panel active" id="settings-panel-profile"> <div class="settings-panel active" id="settings-panel-general">
<h4 class="panel-title" data-i18n="profile_account">Konto</h4>
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
<div class="form-group"> <div class="form-group">
<label data-i18n="profile_display_name">Anzeigename</label> <select id="cfg-language">
<input type="text" id="cfg-display-name" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" /> <option value="de">Deutsch</option>
</div> <option value="en">English</option>
<div class="form-group"> </select>
<label data-i18n="profile_login_name">Login-Name</label>
<input type="text" id="cfg-login-name" spellcheck="false" autocapitalize="none" />
<p class="panel-desc" data-i18n="profile_login_name_desc">Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.</p>
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p> <div class="form-group">
<div class="form-group app-pw-create"> <label data-i18n="settings_primary_color">Primärfarbe</label>
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" /> <div class="ev-color-row">
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button> <input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
</div> <div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<div id="app-pw-new" class="app-pw-new hidden">
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
<div class="totp-secret-row">
<code id="app-pw-new-value"></code>
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button>
</div> </div>
</div> </div>
<div id="app-pw-list"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
<div class="form-group"> <div class="form-group">
<label data-i18n="settings_private_visibility">Private Termine für Gruppenmitglieder</label> <label data-i18n="settings_accent_color">Akzentfarbe</label>
<select id="cfg-private-visibility"> <div class="ev-color-row">
<option value="busy" data-i18n="private_visibility_busy">Als „Beschäftigt“ anzeigen</option> <input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option> <div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_label_color">Monatskürzel</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast">
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button>
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button>
<button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl" data-i18n="contrast_light">Hell</span></button>
<button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl" data-i18n="contrast_max">Maximum</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
<p class="panel-desc" data-i18n="settings_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
<div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast">
<button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl" data-i18n="line_barely">Kaum</span></button>
<button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl" data-i18n="line_subtle">Subtil</span></button>
<button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl" data-i18n="line_normal">Normal</span></button>
<button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl" data-i18n="line_strong">Stark</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<div class="form-group">
<label data-i18n="settings_default_view">Standardansicht</label>
<select id="cfg-default-view">
<option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="checkbox-row"> <label data-i18n="settings_week_start">Erster Wochentag</label>
<input type="checkbox" id="cfg-directory-hidden" /> <select id="cfg-week-start">
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span> <option value="monday" data-i18n="week_start_monday">Montag</option>
</label> <option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p> </select>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
<p class="panel-desc" data-i18n="settings_group_visible_desc">Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist</p>
<div class="form-group"> <div class="form-group">
<label data-i18n="settings_group_visible">Für Gruppen sichtbarer Kalender</label> <label class="toggle-label">
<div id="cfg-group-visible-list" class="cal-radio-list"></div> <input type="checkbox" id="cfg-dim-past" />
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
</div> </div>
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) --> <!-- Konten (CalDAV, Lokal, iCal, Google) -->
<div class="settings-panel" id="settings-panel-general">
<div class="sync-global-row">
<div class="sync-global-text">
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
</div>
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
</div>
<div class="settings-table" id="settings-appearance-table"></div>
<div class="theme-io-row">
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
<a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a>
<input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
</div>
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
<div class="settings-panel" id="settings-panel-accounts"> <div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4> <h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
<div class="accounts-add-row">
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-birthday" data-i18n="birthday_add_btn">+ Geburtstage</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
</div>
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="birthday_settings_title">Geburtstage</h4> <div class="accounts-section">
<div id="birthday-settings"></div> <div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading">Home Assistant</div>
<div id="accounts-ha-list"><span class="accounts-section-empty">Keine HA-Konten</span></div>
</div>
</div> </div>
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding --> <!-- Benutzerverwaltung -->
<div class="settings-panel" id="settings-panel-users"> <div class="settings-panel" id="settings-panel-users">
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4> <h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div> <div id="users-list"></div>
@@ -878,45 +723,6 @@
</label> </label>
<button class="btn btn-primary" id="new-user-save">Erstellen</button> <button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div> </div>
<!-- Instanz-Standardtheme -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
<div id="admin-theme-grid" class="admin-theme-grid"></div>
<div class="admin-theme-actions">
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
<button class="btn btn-secondary btn-sm" id="admin-theme-import" data-i18n="admin_theme_import">Theme importieren</button>
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
<input type="file" id="admin-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
<!-- Branding: Logo & Favicon -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
<div class="admin-brand-preview admin-brand-preview-logo" id="admin-logo-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_logo_dims">PNG/JPEG/WebP · Anzeige max. 150 × 40 px</span>
</div>
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_favicon_dims">PNG/JPEG/WebP · 128 × 128 px (quadratisch)</span>
</div>
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
</div> </div>
</div><!-- settings-panels --> </div><!-- settings-panels -->
@@ -949,21 +755,16 @@
<!-- Account Info --> <!-- Account Info -->
<div class="settings-section"> <div class="settings-section">
<h4 data-i18n="profile_account">Konto</h4> <h4>Konto</h4>
<div class="form-group"> <div class="form-group">
<label data-i18n="profile_display_name">Anzeigename</label> <label>Benutzername</label>
<input type="text" id="profile-display-name-input" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" /> <input type="text" id="profile-username" disabled class="input-disabled" />
</div>
<div class="form-group">
<label data-i18n="profile_login_name">Login-Name</label>
<input type="text" id="profile-username" spellcheck="false" autocapitalize="none" />
<p class="panel-desc" data-i18n="profile_login_name_desc">Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" /> <input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" />
</div> </div>
<button class="btn btn-primary btn-sm" id="profile-save-info" data-i18n="save">Speichern</button> <button class="btn btn-primary btn-sm" id="profile-save-info">Speichern</button>
</div> </div>
<!-- Password --> <!-- Password -->
@@ -1083,7 +884,7 @@
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p> <a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
</div> </div>
<div class="modal-footer" style="justify-content:space-between;align-items:center"> <div class="modal-footer" style="justify-content:space-between;align-items:center">
<span id="impressum-version" style="font-size:12px;color:var(--text-3)">Calendarr</span> <span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button> <button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
</div> </div>
</div> </div>

View File

@@ -43,31 +43,6 @@ async function uploadRequest(path, formData) {
const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData }); const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
// to the HTTP status so the message is diagnostic, not "unknown error".
const err = await res.json().catch(() => null);
const detail = (err && err.detail)
? err.detail
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
throw new Error(detail);
}
if (res.status === 204) return null;
return res.json();
}
async function downloadRequest(path, fallbackName) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
@@ -78,21 +53,8 @@ async function downloadRequest(path, fallbackName) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') })); const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
throw new Error(err.detail || `HTTP ${res.status}`); throw new Error(err.detail || `HTTP ${res.status}`);
} }
// Derive filename from Content-Disposition if present. if (res.status === 204) return null;
let filename = fallbackName || 'calendar.ics'; return res.json();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="?([^"]+)"?/);
if (m) filename = m[1];
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} }
export const api = { export const api = {
@@ -101,7 +63,6 @@ export const api = {
put: (path, body) => request('PUT', path, body), put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path), delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form), upload: (path, form) => uploadRequest(path, form),
download: (path, name) => downloadRequest(path, name),
login: (username, password, totp_code = null, remember_me = false) => login: (username, password, totp_code = null, remember_me = false) =>
request('POST', '/auth/login', { username, password, totp_code, remember_me }), request('POST', '/auth/login', { username, password, totp_code, remember_me }),

View File

@@ -1,13 +1,9 @@
import { api } from './api.js'; import { api } from './api.js';
import { initCalendar, showToast, openProfileModal } from './calendar.js'; import { initCalendar, showToast, openProfileModal } from './calendar.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { loadInstance } from './instance.js';
// ── Bootstrap ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
// setup screens are already branded. Public endpoint — no token needed.
loadInstance();
// Check if setup is required // Check if setup is required
let setupRequired = false; let setupRequired = false;
try { try {
@@ -27,22 +23,13 @@ async function boot() {
// Check if already logged in // Check if already logged in
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
let authed = false;
try { try {
await api.get('/auth/me'); // validate the TOKEN only await api.get('/auth/me'); // validate token
authed = true;
} catch (_) {
// The token is genuinely invalid/expired — clear it and show login.
localStorage.removeItem('token');
localStorage.removeItem('user');
}
if (authed) {
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
// try/catch on purpose: a later data/render error inside app init must
// never bounce a validly-authenticated user back to the login screen
// (that was the "logged out on every reload" bug).
await launchApp(); await launchApp();
return; return;
} catch (_) {
localStorage.removeItem('token');
localStorage.removeItem('user');
} }
} }
@@ -73,7 +60,7 @@ async function launchApp() {
// User dropdown menu // User dropdown menu
const dropdown = document.getElementById('user-dropdown'); const dropdown = document.getElementById('user-dropdown');
document.getElementById('dropdown-username').textContent = user.display_name || user.username || 'Benutzer'; document.getElementById('dropdown-username').textContent = user.username || 'Benutzer';
avatar.addEventListener('click', e => { avatar.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@@ -205,23 +192,8 @@ boot();
// ── Service Worker registration (PWA) ───────────────────── // ── Service Worker registration (PWA) ─────────────────────
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// Auto-update: when a new service worker takes control, reload once so the
// page runs the fresh assets. Guarded so it never loops and never fires on
// the very first install (when there was no previous controller).
let refreshing = false;
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing || !hadController) return;
refreshing = true;
window.location.reload();
});
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
// Check for a new SW now and hourly, so long-open tabs pick up releases.
reg.update();
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => {
console.warn('SW registration failed:', err); console.warn('SW registration failed:', err);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or "" * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
* mode : 'datetime' | 'date' * mode : 'datetime' | 'date'
*/ */
import { t, getLocale } from './i18n.js'; import { t } from './i18n.js';
const ITEM_H = 40; // px per scroll item const ITEM_H = 40; // px per scroll item
const VISIBLE = 3; // visible items in time scroller const VISIBLE = 3; // visible items in time scroller
@@ -48,10 +48,9 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
resolve(result); resolve(result);
} }
// Click outside (leaving the picker) → commit the current selection, // Click outside → cancel
// rather than discarding it. Cancel button still reverts.
overlay.addEventListener('mousedown', e => { overlay.addEventListener('mousedown', e => {
if (e.target === overlay) done(buildResult()); if (e.target === overlay) done(null);
}); });
// ── Calendar builder ────────────────────────────────── // ── Calendar builder ──────────────────────────────────
@@ -145,8 +144,7 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
render(); render();
}; };
// Day click → just select/highlight; committing happens on Save or when // Day click
// the user leaves the picker (clicks outside).
card.querySelectorAll('.dtp-day').forEach(el => { card.querySelectorAll('.dtp-day').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
selDate = new Date(parseInt(el.dataset.ts)); selDate = new Date(parseInt(el.dataset.ts));
@@ -250,16 +248,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
/** /**
* Format an ISO value for display in the UI * Format an ISO value for display in the UI
* mode: 'datetime' | 'date' * mode: 'datetime' | 'date'
* Formatting follows the active UI language via the i18n locale registry. * lang: 'de' | 'en'
*/ */
export function formatDtDisplay(isoStr, mode) { export function formatDtDisplay(isoStr, mode, lang = 'de') {
if (!isoStr) return '—'; if (!isoStr) return '—';
try { try {
const d = mode === 'datetime' const d = mode === 'datetime'
? new Date(isoStr.replace(' ', 'T')) ? new Date(isoStr.replace(' ', 'T'))
: new Date(isoStr + 'T00:00:00'); : new Date(isoStr + 'T00:00:00');
if (isNaN(d)) return isoStr; if (isNaN(d)) return isoStr;
const locale = getLocale(); const locale = lang === 'en' ? 'en-GB' : 'de-CH';
if (mode === 'datetime') { if (mode === 'datetime') {
return d.toLocaleString(locale, { return d.toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
// early as possible so the login screen is already branded. See
// backend/routers/admin_router.py.
import { setInstanceDefaults } from './settings-sync.js';
import { setCustomFavicon, applyFavicon } from './utils.js';
export let instanceConfig = {};
// Cache the bundled default logo markup so "remove logo" can restore it.
const originalLogoHtml = {};
function setBrandLogo(url) {
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
if (url) {
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
} else {
el.innerHTML = originalLogoHtml[key];
}
});
}
export function applyInstanceBranding(cfg) {
cfg = cfg || {};
setInstanceDefaults(cfg.default_theme || {});
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
applyFavicon(); // no arg → uses instance/built-in primary as fallback
}
// Memoised so boot() and initCalendar() share a single fetch; callers can await
// it to guarantee instance defaults are set before the first applyTheme().
let loadPromise = null;
export function loadInstance() {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
instanceConfig = res.ok ? await res.json() : {};
} catch (_) {
instanceConfig = {};
}
applyInstanceBranding(instanceConfig);
return instanceConfig;
})();
return loadPromise;
}
// Force a re-fetch (after an admin changes branding/theme).
export function reloadInstance() {
loadPromise = null;
return loadInstance();
}

View File

@@ -1,137 +0,0 @@
// Per-setting cross-device sync for the web client.
//
// The server is the sole authority for WHICH settings sync (it returns a fully
// resolved `sync_flags` map). This module owns the browser-local copy of every
// syncable value so that "not synced" works per browser, plus the declarative
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
import { LANGUAGES } from './i18n.js';
// Canonical default colours — the single source for the web. Reset writes these.
export const DEFAULT_COLORS = {
primary_color: '#58B900',
accent_color: '#45A148',
today_color: '#6FB669',
text_color: '#FFFFFF',
bg_color: '#000000',
line_color: '#3B3B3D',
surface_color: '#2B2B2B',
month_divider_color: '#95D25E',
month_label_color: '#95D25E',
// Fine-grained element colours (see THEME.md). day_selected/today_bg are
// applied as a subtle tint of the chosen colour; the rest are applied solid.
hover_highlight_color: '#2A2A38',
icon_inactive_color: '#90AA91',
icon_active_color: '#E8E8F0',
day_hover_color: '#2B382A',
day_selected_color: '#88EF9A',
day_bg_color: '#000000',
today_bg_color: '#477650',
};
// The syncable settings the web client exposes, grouped into table sections.
// `type`: 'select' | 'toggle' | 'color' | 'icon'.
// Option labels use i18n keys via `tk`, or a literal `label`.
export const SETTING_GROUPS = [
{
titleKey: 'settings_calendar_view',
rows: [
{ key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
{ v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
{ v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
{ v: 'agenda', tk: 'view_agenda' },
] },
{ key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
{ v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
] },
{ key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
{ key: 'month_view_paged', labelKey: 'settings_month_mode', type: 'select', opts: [
{ v: false, tk: 'settings_month_mode_scroll' }, { v: true, tk: 'settings_month_mode_paged' },
] },
{ key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
{ v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
{ v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
] },
{ key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
{ v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
{ v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
] },
],
},
{
titleKey: 'settings_language',
rows: [
// Derived from the i18n registry: adding a language there adds it here.
// Each language is listed under its own endonym, so the labels are never
// translated — a Finn looks for "Suomi", not "Finnisch".
{ key: 'language', labelKey: 'settings_language', type: 'select',
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
],
},
{
titleKey: 'settings_colors',
rows: [
{ key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
{ key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
{ key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
{ key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
{ key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
{ key: 'surface_color', labelKey: 'settings_surface_color', type: 'color' },
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
],
},
];
// Flat list of every syncable key the web manages (table order).
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
// the base a user inherits and the target that a per-colour "Reset" returns to.
// A user's own value always overrides it. See backend/routers/admin_router.py.
export let INSTANCE_DEFAULTS = {};
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
// The effective default for a colour key: admin instance default → built-in.
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
const LOCAL_KEY = 'settingsLocal';
export function loadLocal() {
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
export function saveLocal(obj) {
try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
}
// Effective value of a syncable key: synced → server value; otherwise the
// browser-local value (falling back to the server value if we have none yet).
export function effectiveValue(key, server, flags, local) {
if (flags[key]) return server[key];
return (key in local && local[key] != null) ? local[key] : server[key];
}
// Build the effective settings object: a copy of the raw server settings with
// each syncable key resolved to its effective value. As a side effect, mirror
// every effective value into the local copy so that flipping a flag OFF later
// retains the currently-visible value.
export function mergeEffective(server, flags, local) {
const eff = { ...server };
for (const key of SYNCABLE_KEYS) {
const val = effectiveValue(key, server, flags, local);
eff[key] = val;
if (val != null) local[key] = val;
}
saveLocal(local);
return eff;
}

View File

@@ -1,6 +1,3 @@
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
import { getLocale } from './i18n.js';
export function isToday(d) { export function isToday(d) {
const now = new Date(); const now = new Date();
return d.getFullYear() === now.getFullYear() && return d.getFullYear() === now.getFullYear() &&
@@ -19,28 +16,8 @@ export function isPast(ev) {
return end < new Date(); return end < new Date();
} }
// Title to render for an event: the server-decorated one (birthday age, group
// prefix) wins over the raw title, which stays untouched for editing.
export function eventTitle(ev) {
return ev.display_title || ev.title || '';
}
// Small inline cake icon for birthday events, sized to the surrounding text and
// tinted with the current text colour so it matches every bar it's dropped into.
export function birthdayIconSvg() {
return '<svg viewBox="0 0 24 24" aria-hidden="true" '
+ 'style="width:0.85em;height:0.85em;vertical-align:-0.1em;margin-right:2px;flex:0 0 auto">'
+ '<path fill="currentColor" d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97'
+ 'c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0'
+ 'l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16'
+ 'c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6'
+ 'c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13'
+ 'c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12'
+ 'c.01-1.66-1.33-3-2.99-3z"/></svg>';
}
export function formatDate(d, opts = {}) { export function formatDate(d, opts = {}) {
return d.toLocaleDateString(getLocale(), opts); return d.toLocaleDateString('de', opts);
} }
export function dateKey(d) { export function dateKey(d) {
@@ -99,201 +76,27 @@ const LINE_CONTRAST = {
4: { border: '#5a5a78', light: '#484860' }, 4: { border: '#5a5a78', light: '#484860' },
}; };
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
export function applyTheme(settings) { export function applyTheme(settings) {
const root = document.documentElement; const root = document.documentElement;
// Fallback chain for a colour that has no per-user value: admin instance root.style.setProperty('--primary', settings.primary_color || '#4285f4');
// default → built-in default (baseColor()). root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
const primary = settings.primary_color || baseColor('primary_color'); root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--primary', primary); root.style.setProperty('--today-color', settings.today_color || '#4285f4');
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default). const tc = TEXT_CONTRAST[settings.text_contrast || 3];
let textColor = settings.text_color || baseColor('text_color'); root.style.setProperty('--text-1', tc.t1);
let lineColor = settings.line_color || baseColor('line_color'); root.style.setProperty('--text-2', tc.t2);
let bgColor = settings.bg_color || baseColor('bg_color'); root.style.setProperty('--text-3', tc.t3);
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug const lc = LINE_CONTRAST[settings.line_contrast || 3];
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt), root.style.setProperty('--border', lc.border);
// erzwinge weiss-auf-schwarz, damit man nicht in einer unbedienbaren root.style.setProperty('--border-light', lc.light);
// Seite landet.
if (contrastRatio(textColor, bgColor) < 2.5) {
textColor = DEFAULT_TEXT_COLOR;
bgColor = DEFAULT_BG_COLOR;
}
root.style.setProperty('--text-1', textColor);
root.style.setProperty('--text-2', shadeHex(textColor, -0.25));
root.style.setProperty('--text-3', shadeHex(textColor, -0.55));
root.style.setProperty('--border', lineColor);
root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
// Surface family (sidebar / top bar / cards). Explicit surface_color drives
// it; otherwise derive from the app background as before.
const surfaceBase = settings.surface_color || shadeHex(bgColor, 0.10);
root.style.setProperty('--bg-app', bgColor);
root.style.setProperty('--bg-topbar', surfaceBase);
root.style.setProperty('--bg-sidebar', surfaceBase);
root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10));
root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20));
root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34));
const hh = settings.hour_height || 44; const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px'); root.style.setProperty('--hour-h', hh + 'px');
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color')); root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color')); root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
// Fine-grained element colours. Each is applied ONLY when the user set an
// explicit value; otherwise the :root default (which references a derived
// variable) stays in effect, so the look is unchanged until customised.
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
// into a subtle tint via color-mix; the rest are applied as-is.
// User value → admin instance default (if any). When neither is set the
// property stays unset so the derived :root default keeps the current look.
const setIf = (varName, key) => {
const value = settings[key] || INSTANCE_DEFAULTS[key];
if (value) root.style.setProperty(varName, value);
};
setIf('--hover-highlight', 'hover_highlight_color');
setIf('--icon-inactive-color', 'icon_inactive_color');
setIf('--icon-active-color', 'icon_active_color');
setIf('--day-hover-color', 'day_hover_color');
setIf('--day-selected-base', 'day_selected_color');
setIf('--day-bg', 'day_bg_color');
setIf('--today-bg-base', 'today_bg_color');
}
// Instance custom favicon URL (set by the branding loader). When present it
// overrides the primary-colour tinting.
let customFaviconUrl = null;
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
// Tint the favicon (and browser theme-colour) to the current primary colour so
// the tab icon reflects the user's theme. Called at load and after saving —
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
export function applyFavicon(primaryColor) {
const color = primaryColor || baseColor('primary_color');
let link = document.querySelector('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
if (customFaviconUrl) {
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
link.type = 'image/png';
link.href = customFaviconUrl;
} else {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
link.type = 'image/svg+xml';
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', color);
}
function luminance(hex) {
const c = (n) => {
const v = n / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const r = c(parseInt(hex.slice(1, 3), 16));
const g = c(parseInt(hex.slice(3, 5), 16));
const b = c(parseInt(hex.slice(5, 7), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(c1, c2) {
try {
const l1 = luminance(c1);
const l2 = luminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
} catch { return 21; }
}
// ── Safe HTML rendering for event descriptions ───────────────────────────
// External calendars (CalDAV, iCal) often store rich-text descriptions as raw
// HTML. We render a small allowlist of formatting tags so links/line breaks
// look right, but strip everything dangerous (scripts, event handlers, inline
// styles) — we never execute code from a description.
const ALLOWED_TAGS = new Set(['A', 'BR', 'P', 'DIV', 'B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'SPAN']);
const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"])/g;
function makeSafeLink(href, text) {
const a = document.createElement('a');
a.href = href;
a.textContent = text;
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
}
// Replace bare URLs inside a text node with clickable <a> elements.
function linkifyTextNode(node, out) {
const text = node.nodeValue;
let last = 0;
let m;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
if (m.index > last) out.appendChild(document.createTextNode(text.slice(last, m.index)));
out.appendChild(makeSafeLink(m[0], m[0]));
last = m.index + m[0].length;
}
if (last < text.length) out.appendChild(document.createTextNode(text.slice(last)));
}
// Recursively copy `src` into `dest`, keeping only allowlisted tags/attributes.
function sanitizeInto(src, dest) {
src.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
linkifyTextNode(node, dest);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tag = node.tagName;
if (!ALLOWED_TAGS.has(tag)) {
// Drop the tag but keep its (sanitized) contents.
sanitizeInto(node, dest);
return;
}
let el;
if (tag === 'A') {
const href = node.getAttribute('href') || '';
if (/^(https?:|mailto:)/i.test(href)) {
el = makeSafeLink(href, '');
} else {
// Unsafe/relative href: render as plain text container.
el = document.createElement('span');
}
} else {
el = document.createElement(tag.toLowerCase());
}
sanitizeInto(node, el);
dest.appendChild(el);
});
}
// Returns a sanitized HTML string for an event description, safe for innerHTML.
export function renderDescriptionHtml(raw) {
if (!raw) return '';
const hasHtml = /<[a-z][\s\S]*>/i.test(raw);
const doc = new DOMParser().parseFromString(
hasHtml ? raw : raw.replace(/\n/g, '<br>'), 'text/html');
const out = document.createElement('div');
sanitizeInto(doc.body, out);
return out.innerHTML;
} }
function hexToRgba(hex, alpha) { function hexToRgba(hex, alpha) {
@@ -302,24 +105,3 @@ function hexToRgba(hex, alpha) {
const b = parseInt(hex.slice(5,7), 16); const b = parseInt(hex.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`; return `rgba(${r},${g},${b},${alpha})`;
} }
// Brighten (positive amount) or darken (negative) a hex colour.
// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
// from a single user-picked colour so the whole UI stays in the same family.
function shadeHex(hex, amount) {
let r = parseInt(hex.slice(1,3), 16);
let g = parseInt(hex.slice(3,5), 16);
let b = parseInt(hex.slice(5,7), 16);
if (amount >= 0) {
r = Math.round(r + (255 - r) * amount);
g = Math.round(g + (255 - g) * amount);
b = Math.round(b + (255 - b) * amount);
} else {
const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
r = Math.round(r * a);
g = Math.round(g * a);
b = Math.round(b * a);
}
const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return '#' + h(r) + h(g) + h(b);
}

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v90'; export const APP_VERSION = 'v11';

View File

@@ -1,5 +1,5 @@
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js'; import { isPast } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t, getLang } from '../i18n.js';
export function renderAgenda(container, currentDate, events, onEventClick) { export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) { if (!events.length) {
@@ -45,13 +45,13 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"> return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
<div class="agenda-ev-color" style="background:${color}"></div> <div class="agenda-ev-color" style="background:${color}"></div>
<div class="agenda-ev-info"> <div class="agenda-ev-info">
<div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</div> <div class="agenda-ev-title">${escHtml(ev.title)}</div>
<div class="agenda-ev-meta">${timeStr}${locHtml}</div> <div class="agenda-ev-meta">${timeStr}${locHtml}</div>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
return `<div class="agenda-day" data-date="${key}"> return `<div class="agenda-day">
<div class="agenda-date ${todayCls}"> <div class="agenda-date ${todayCls}">
<div class="agenda-date-num">${date.getDate()}</div> <div class="agenda-date-num">${date.getDate()}</div>
<div class="agenda-date-label"> <div class="agenda-date-label">
@@ -65,19 +65,6 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
container.innerHTML = `<div class="agenda-view">${html}</div>`; container.innerHTML = `<div class="agenda-view">${html}</div>`;
// The agenda lists the whole cached range (past + future). Scroll so the
// current date (e.g. "today" after the Today button) sits at the top — or
// the next day with events if the current date itself has none.
const scrollEl = container.querySelector('.agenda-view');
if (scrollEl) {
const curKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth()+1).padStart(2,'0')}-${String(currentDate.getDate()).padStart(2,'0')}`;
const days = [...scrollEl.querySelectorAll('.agenda-day')];
const target = days.find(d => d.dataset.date >= curKey) || days[days.length - 1];
if (target) {
scrollEl.scrollTop += target.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top;
}
}
container.querySelectorAll('.agenda-event').forEach(el => { container.querySelectorAll('.agenda-event').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
@@ -94,7 +81,7 @@ function isTodayDate(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {

View File

@@ -1,5 +1,5 @@
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js'; import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap) const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const DAY_H = 30; // day-number row height const DAY_H = 30; // day-number row height
@@ -71,21 +71,13 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
return new Date(a.ev.start) - new Date(b.ev.start); return new Date(a.ev.start) - new Date(b.ev.start);
}); });
// Assign lanes using a 2D occupancy grid (per-column tracking). // Assign lanes (greedy interval packing)
// This prevents gaps: an event in cols 3-6 no longer blocks col 0-2 in the same lane. const lanes = [];
const occupiedGrid = []; // occupiedGrid[lane] = Set<col>
rowItems.forEach(item => { rowItems.forEach(item => {
const cols = Array.from({ length: item.span }, (_, i) => item.colStart + i); let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
let laneIdx = 0; if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
for (;;) { item.lane = laneIdx;
if (!occupiedGrid[laneIdx]) occupiedGrid[laneIdx] = new Set(); lanes[laneIdx].colEnd = item.colStart + item.span;
if (cols.every(c => !occupiedGrid[laneIdx].has(c))) {
cols.forEach(c => occupiedGrid[laneIdx].add(c));
item.lane = laneIdx;
break;
}
laneIdx++;
}
}); });
// Track overflow per column // Track overflow per column
@@ -110,15 +102,14 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const cL = continuesLeft ? 'continues-left' : ''; const cL = continuesLeft ? 'continues-left' : '';
const cR = continuesRight ? 'continues-right' : ''; const cR = continuesRight ? 'continues-right' : '';
const titleEsc = escHtml(eventTitle(ev)); const titleEsc = escHtml(ev.title);
const icon = ev.is_birthday ? birthdayIconSvg() : '';
const labelHtml = ev.allDay const labelHtml = ev.allDay
? icon + titleEsc ? titleEsc
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${titleEsc}`; : `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}" eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}" style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`; title="${escAttr(ev.title)}">${labelHtml}</div>`;
}); });
// "+N more" per column // "+N more" per column
@@ -158,11 +149,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom'); if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
const dividerCls = dividerClasses.join(' '); const dividerCls = dividerClasses.join(' ');
const monthLabel = isFirstOfMonth const monthLabel = isFirstOfMonth
? `<span class="month-marker">${monthsShort[cell.getMonth()]}</span>` ? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>`
: ''; : '';
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}"> colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
<div class="cell-day ${numCls}">${cell.getDate()}</div>
${monthLabel} ${monthLabel}
<div class="cell-day ${numCls}">${cell.getDate()}</div>
</div>`; </div>`;
}); });
@@ -199,20 +190,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
if (ev) onEventClick(ev, spanEl); if (ev) onEventClick(ev, spanEl);
return; return;
} }
// "+N more" → show overflow popup with all events for that day // "+N more" → navigate to day view
const moreEl = e.target.closest('.month-more'); const moreEl = e.target.closest('.month-more');
if (moreEl) { if (moreEl) {
e.stopPropagation(); e.stopPropagation();
const dayDate = new Date(moreEl.dataset.date + 'T00:00:00'); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
const dayEvents = normed
.filter(({ ns, ne }) => ns <= dayDate && ne >= dayDate)
.map(({ ev }) => ev)
.sort((a, b) => {
if (a.allDay && !b.allDay) return -1;
if (!a.allDay && b.allDay) return 1;
return new Date(a.start) - new Date(b.start);
});
showOverflowPopup(moreEl, dayDate, dayEvents, onEventClick);
return; return;
} }
// Column click → select day // Column click → select day
@@ -251,7 +233,7 @@ function dateKey(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {
@@ -261,91 +243,3 @@ function escHtml(s) {
function escAttr(s) { function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
function showOverflowPopup(anchor, date, events, onEventClick) {
document.querySelectorAll('.month-overflow-popup').forEach(p => p.remove());
const popup = document.createElement('div');
popup.className = 'month-overflow-popup';
// Header: "Mo, 3. Jul"
const months = t('months');
const dow = t('dow_monday');
const dowIdx = (date.getDay() + 6) % 7; // 0=Mon
const header = document.createElement('div');
header.className = 'mop-header';
header.textContent = `${dow[dowIdx]}, ${date.getDate()}. ${months[date.getMonth()]}`;
popup.appendChild(header);
events.forEach(ev => {
const evStart = new Date(ev.start); evStart.setHours(0, 0, 0, 0);
const evEnd = new Date(ev.end); evEnd.setHours(0, 0, 0, 0);
// allDay end from API is exclusive → actual last day = evEnd - 1d
const lastDay = ev.allDay ? new Date(evEnd.getTime() - 86400000) : evEnd;
const continuesLeft = evStart < date;
const continuesRight = lastDay > date;
const color = ev.color || ev.calendarColor || '#4285f4';
if (ev.allDay) {
// All-day events → colored bar with continuation arrows
const bar = document.createElement('div');
bar.className = 'mop-bar'
+ (continuesLeft ? ' continues-left' : '')
+ (continuesRight ? ' continues-right' : '');
bar.style.background = color;
bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
bar.addEventListener('click', e => {
e.stopPropagation();
popup.remove();
onEventClick(ev, bar);
});
popup.appendChild(bar);
} else {
// Timed events → dot + time + title
const row = document.createElement('div');
row.className = 'mop-row';
const dot = document.createElement('span');
dot.className = 'mop-dot';
dot.style.background = color;
const time = document.createElement('span');
time.className = 'mop-time';
time.textContent = fmtTime(new Date(ev.start));
const title = document.createElement('span');
title.className = 'mop-title';
title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
row.append(dot, time, title);
row.addEventListener('click', e => {
e.stopPropagation();
popup.remove();
onEventClick(ev, row);
});
popup.appendChild(row);
}
});
document.body.appendChild(popup);
// Position near anchor, stay in viewport
const r = anchor.getBoundingClientRect();
const pw = popup.offsetWidth || 260;
const ph = popup.offsetHeight || 200;
let left = r.left;
let top = r.bottom + 4;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
if (top + ph > window.innerHeight - 8) top = r.top - ph - 4;
if (left < 8) left = 8;
if (top < 8) top = 8;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
setTimeout(() => {
document.addEventListener('click', function close() {
popup.remove();
document.removeEventListener('click', close);
});
}, 0);
}

View File

@@ -1,4 +1,4 @@
import { isToday, isPast, dayOfWeek, eventTitle } from '../utils.js'; import { isToday, isPast, dayOfWeek } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
@@ -64,7 +64,7 @@ export function renderQuarter(container, currentDate, events, onDayClick, onEven
const dots = cellEvs.slice(0, 3).map(ev => { const dots = cellEvs.slice(0, 3).map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`; return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
}).join(''); }).join('');
const moreDot = cellEvs.length > 3 const moreDot = cellEvs.length > 3
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>` ? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`

View File

@@ -1,5 +1,5 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js'; import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t, getLocale } from '../i18n.js'; import { t } from '../i18n.js';
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) { export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
// Build the days array (7 days for week, 1 for day) // Build the days array (7 days for week, 1 for day)
@@ -63,26 +63,14 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : ''; const multiCls = isMultiTimed ? 'multiday-timed' : '';
// continues-left/right: compute on date-only basis for all-day events const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
let evStart = new Date(ev.start); const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
let evEnd = new Date(ev.end);
if (ev.allDay) {
evStart.setHours(0, 0, 0, 0);
evEnd.setHours(0, 0, 0, 0);
if (evEnd > evStart) evEnd.setDate(evEnd.getDate() - 1);
}
const firstDay = new Date(days[0]); firstDay.setHours(0, 0, 0, 0);
const lastDayMidnight = new Date(days[n-1]); lastDayMidnight.setHours(24, 0, 0, 0);
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}` ? `${fmtTime(new Date(ev.start))} ${ev.title}`
: eventTitle(ev); : ev.title;
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}" return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff" style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`; data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
}).join(''); }).join('');
const alldayBgCols = days.map(day => const alldayBgCols = days.map(day =>
@@ -125,17 +113,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const startStr = fmtTime(s); const startStr = fmtTime(s);
// Short events lack the vertical room to stack time over title, so render const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
// them on one line (time next to title) and drop the location. return `<div class="timed-event ${pastCls}"
const isShort = height < 34;
const shortCls = isShort ? 'short' : '';
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="timed-event ${pastCls} ${shortCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff" style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}"> data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
<div class="ev-time">${startStr}</div> <div class="ev-time">${startStr}</div>
<div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div> <div class="ev-title">${escHtml(ev.title)}</div>
${locHtml} ${locHtml}
</div>`; </div>`;
}).join(''); }).join('');
@@ -253,28 +236,11 @@ function renderNowLine(container, days, hourH = 60) {
function layoutWeekAllDay(evs, days) { function layoutWeekAllDay(evs, days) {
const items = []; const items = [];
evs.forEach(ev => { evs.forEach(ev => {
// For all-day events, normalize to date-only with inclusive end-day
// (iCal stores exclusive end → subtract 1). For timed events, keep
// the original strict-overlap logic so events ending exactly at
// midnight don't bleed into the next day.
let ns, ne;
if (ev.allDay) {
ns = new Date(ev.start); ns.setHours(0, 0, 0, 0);
ne = new Date(ev.end); ne.setHours(0, 0, 0, 0);
if (ne > ns) ne.setDate(ne.getDate() - 1);
}
let colStart = -1, colEnd = -1; let colStart = -1, colEnd = -1;
days.forEach((day, i) => { days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0); const ds = new Date(day); ds.setHours(0, 0, 0, 0);
let matches; const de = new Date(day); de.setHours(24, 0, 0, 0);
if (ev.allDay) { if (new Date(ev.start) < de && new Date(ev.end) > ds) {
matches = ds >= ns && ds <= ne;
} else {
const de = new Date(day); de.setHours(24, 0, 0, 0);
matches = new Date(ev.start) < de && new Date(ev.end) > ds;
}
if (matches) {
if (colStart === -1) colStart = i; if (colStart === -1) colStart = i;
colEnd = i; colEnd = i;
} }
@@ -352,7 +318,7 @@ function isSameDay(a, b) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {

View File

@@ -1,17 +1,12 @@
{ {
"id": "/",
"name": "Calendarr", "name": "Calendarr",
"short_name": "Calendarr", "short_name": "Calendarr",
"description": "Dein privater, selbst gehosteter Kalender.",
"lang": "de",
"dir": "ltr",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#0e0e14", "background_color": "#0e0e14",
"theme_color": "#16713d", "theme_color": "#4285f4",
"categories": ["productivity", "utilities"],
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
@@ -26,16 +21,10 @@
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/icons/icon-maskable-192.png", "src": "/icons/icon.svg",
"sizes": "192x192", "sizes": "any",
"type": "image/png", "type": "image/svg+xml",
"purpose": "maskable" "purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

View File

@@ -1,21 +1,39 @@
// Calendarr Service Worker — minimal-cache strategy // Calendarr Service Worker
// // Cache-first for static assets, network-first for /api/* (graceful offline)
// Strategy: network-first for everything. The cache is only used as a
// last-resort fallback when offline (so the app shell still opens). This
// means every online request hits the network and respects the
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v36'; const CACHE_VERSION = 'calendarr-v11';
const OFFLINE_SHELL = ['/', '/index.html']; const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/static/css/app.css',
'/static/favicon.svg',
'/static/js/app.js',
'/static/js/api.js',
'/static/js/calendar.js',
'/static/js/color-picker.js',
'/static/js/date-picker.js',
'/static/js/i18n.js',
'/static/js/utils.js',
'/static/js/version.js',
'/static/js/views/agenda.js',
'/static/js/views/month.js',
'/static/js/views/quarter.js',
'/static/js/views/week.js',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon.svg',
];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_VERSION).then(cache => caches.open(CACHE_VERSION).then(cache =>
Promise.all(OFFLINE_SHELL.map(url => // Use addAll with a fallback so a single missing file doesn't abort install
cache.add(url).catch(err => console.warn('[SW] skip', url, err)) Promise.all(
)) STATIC_ASSETS.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
)
)
).then(() => self.skipWaiting()) ).then(() => self.skipWaiting())
); );
}); });
@@ -34,8 +52,7 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url); const url = new URL(req.url);
// API routes: always go to the network, no offline fallback (we'd just // Network-first for API routes — fail silently if offline
// be returning stale account/event data otherwise).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
event.respondWith( event.respondWith(
fetch(req).catch(() => fetch(req).catch(() =>
@@ -48,29 +65,45 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Everything else: network-first. The browser's HTTP cache (driven by // Network-first for navigation (HTML) and the version-defining files —
// the server's Cache-Control headers) already throttles re-fetches — // ensures users always get the freshest entry point so new releases
// the SW just makes sure offline still works for the entry HTML. // take effect on the next reload without a manual SW unregister.
const isHtml = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
const isVersionFile = url.pathname === '/static/js/version.js';
if (isHtml || isVersionFile) {
event.respondWith(
fetch(req).then(resp => {
if (resp && resp.status === 200) {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() =>
caches.match(req).then(c => c || caches.match('/index.html'))
)
);
return;
}
// Cache-first for everything else (static)
event.respondWith( event.respondWith(
fetch(req).then(resp => { caches.match(req).then(cached => {
// Keep a fresh copy of navigation requests / index.html for offline if (cached) return cached;
const isNavigation = req.mode === 'navigate' return fetch(req).then(resp => {
|| url.pathname === '/' // Only cache successful, basic-origin responses
|| url.pathname === '/index.html'; if (resp && resp.status === 200 && resp.type === 'basic') {
if (isNavigation && resp && resp.status === 200) { const clone = resp.clone();
const clone = resp.clone(); caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); }
} return resp;
return resp; }).catch(() => {
}).catch(() => { // Offline fallback for navigation requests
// Offline fallback: only the HTML shell is served from cache, so the if (req.mode === 'navigate') return caches.match('/index.html');
// app at least renders and can show its own offline UI. return new Response('', { status: 503 });
if (req.mode === 'navigate' });
|| url.pathname === '/'
|| url.pathname === '/index.html') {
return caches.match(req).then(c => c || caches.match('/index.html'));
}
return new Response('', { status: 503 });
}) })
); );
}); });

View File

@@ -1,3 +0,0 @@
-r requirements.txt
pytest>=8.0
httpx>=0.27