Compare commits

...

35 Commits

Author SHA1 Message Date
Scarriffle
bff9a244e7 feat: per-event reminders + default reminder setting (server)
local_events gains a `reminders` TEXT column (comma-separated minutes-before-
start, like exdate); EventCreate/EventUpdate accept a `reminders: [int]` list
and build_local_event_dict emits it back as a list. user_settings gains
`default_reminder_minutes` (nullable int, null = off), exposed/updatable via
/api/settings (explicit null persists as off). Migrations added in _migrate().
Clients (iOS/Android) schedule the OS notifications locally from these.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:07:19 +02:00
Scarriffle
fc00bf9114 feat(web): group members in the sidebar + per-user member colours
Moved the group member filter from a top bar into the left sidebar (a
"Mitglieder" section that replaces the personal calendar list while a group is
active). Each member row has a checkbox (show/hide their events) and a colour
dot that opens the colour picker to recolour that member just for this viewer —
stored per-device in localStorage and applied over the server colour. Bumped v44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:32:45 +02:00
Scarriffle
817ce075d4 feat(web): non-emoji group icons (inline SVG) for consistent cross-platform look
Group icons are semantic keys rendered as inline SVG (mirroring iOS SF Symbols /
Android Material) in the picker, group list and sidebar flags — instead of OS
emoji that vary per platform. Legacy emoji values still render as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:25:40 +02:00
Scarriffle
4b7e5799bf refactor: group icons are semantic keys, not emoji (display_title drops glyph)
Group icons move from OS-emoji (which render differently per platform) to
semantic keys rendered natively per client. The combined view's display_title
therefore no longer embeds an icon glyph — group-calendar events are
distinguished by their colour; only the owner/creator first-name is prefixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:20:31 +02:00
Scarriffle
12c14e3c02 feat(web): hide individual member calendars in the group view
In the group overlay a per-member filter bar appears under the banner: tap a
member (or the group calendar) to hide/show their events, Outlook-style.
Filtering is client-side by event owner; members + colours load from the group
detail. Bumped version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:16:25 +02:00
Scarriffle
447c3ddab1 feat(web): render server display_title in the group combined view
Use the server-provided display_title (group icon + owner prefix) instead of
the client-side prefix, so web/iOS/Android show group events identically.
Falls back to the local prefix for older servers. Bumped to v41.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:54:22 +02:00
Scarriffle
6869a15bb8 fix(security): stop private-event leak in merge read + harden busy masking, uploads, profile
Findings from the security review:
- HIGH: private local events leaked in full (title/location/description) to
  anyone who could READ a shared or group calendar via GET /api/caldav/events —
  the private_event_visibility rule was only enforced in /groups/{id}/combined.
  Now enforced in the merge read too, via a shared helper (apply_event_privacy)
  so the two paths can't drift.
- HIGH: 'busy' masking was a blacklist that still leaked creator identity,
  source-calendar name, recurrence rule and per-event colour. Replaced with a
  whitelist (mask_busy_event): only timing/identity/render fields survive.
- MEDIUM: .ics import had no size limit (raw = await file.read()) → memory DoS.
  Now capped at 5 MB (413), read before creating any calendar.
- LOW/INFO: profile email now checked for uniqueness + basic format; display
  name / username / email length-capped and control-chars stripped.

Deferred (tracked): RRULE expansion cap at the trust boundary, SQLite
PRAGMA foreign_keys + ON DELETE cascade, and JWT-by-user-id + token version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:49:56 +02:00
Scarriffle
0d15af736d feat: server-side display_title for the group combined view
The group-event icon + owner-name prefix was being done per-client (iOS only),
so web/Android were inconsistent. The /groups/{id}/combined endpoint now emits
a decorated `display_title` per event (group's own icon for group-calendar
events, owner first-name for other members' events) while keeping the raw
`title` for editing. All clients can render this identically. 18 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:30:38 +02:00
Scarriffle
f834ae5773 feat(web): modernize event popup (glass, colour accent, icon rows, animation)
The event detail popup looked plainly "HTML". Redesigned it: translucent
glass surface (backdrop-blur), the event's colour as an accent strip + tinted
header + glowing dot, body rows with subtle leading icons (time/location/notes/
calendar/creator), rounded 16px corners, layered shadow, and a soft scale+fade
entrance (respecting prefers-reduced-motion). Body rows restructured to
icon+text; JS now drives row visibility and sets --ev-color. Bumped to v40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:06:09 +02:00
Scarriffle
7be77da59f feat: Einstellungen in Kapitel (Profil/Darstellung/Ansicht/Kalender)
- Settings-Sidebar mit Kapiteln: Profil, Darstellung, Ansicht, Kalender,
  Benutzerverwaltung (statt eines langen "Darstellung"-Panels).
- Neues "Profil"-Kapitel: Anzeigename + Login-Name ändern, E-Mail, plus
  Privatsphäre (private Sichtbarkeit) und "Für Gruppen sichtbarer Kalender".
- "Konten" → "Kalender" umbenannt. Version v39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:05:39 +02:00
Scarriffle
8d605ab2cd fix: Monatszelle am Monatswechsel nicht mehr vertikal zentriert
Mein voriges flex/align-center hat "1 JUN" in die Mitte der ganzen Zelle
geschoben. Jetzt: Tageszahl bleibt oben links (wie alle Tage), Monatskürzel
sitzt klein/dezent inline daneben (cell-day inline-flex + kleiner Marker).
Version v38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:00:53 +02:00
Scarriffle
a992d97796 feat: server-definierte Gruppenfarben (per API) + Gruppentermine überall erstellen
- Pro Mitglied eine Farbe (group_members.color, auto aus Palette, vom Owner
  oder Mitglied selbst änderbar via PUT /groups/{id}/members/{uid}/color).
- Gruppentermin-Farbe = Farbe des Gruppenkalenders.
- API liefert Farben aus: GET /groups & /groups/{id} (member.color,
  group_calendar_color), GET /groups/{id}/combined (display_color pro Event)
  -> Apps können dieselben Farben anzeigen. Test ergänzt (18 grün).
- Web nutzt display_color; Gruppenkalender im Termin-Editor mit 👥 markiert
  (Gruppentermine ohne Gruppenansicht erstellbar); Mitglieder-Farben im
  Verwalten-Dialog editierbar. Version v37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:52:40 +02:00
Scarriffle
b0f1497bc8 fix: Monatsansicht-Layout bei Monatswechsel + Gruppenkalender in Sidebar
- Monatsmarker ("JUN") sitzt jetzt inline neben der Tageszahl ("1 JUN") statt
  darüber -> einheitliche Zeilenhöhe; Termine der Woche rutschen nicht mehr
  nach unten und überlappen nicht mehr mit "+X weitere".
- Gruppenkalender erscheint in "Meine Kalender" (mit 👥-Markierung) und kann
  aus-/eingeblendet werden; Besitzer kann ihn umfärben. Recolor fremder
  Kalender abgefangen (nur Besitzer). Version v36.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:45:49 +02:00
Scarriffle
fd7f7ddfe0 feat: Gruppentermin-Button in der Gruppenansicht + Banner in Akzentfarbe
- "+ Gruppentermin"-Button im Gruppenansicht-Banner: oeffnet den Editor mit
  dem Gruppenkalender vorausgewaehlt -> jeder kann direkt eintragen.
- Banner + aktive Gruppe nutzen jetzt die Akzentfarbe (color-mix, Fallback)
  statt Blau. Version v35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:38:35 +02:00
Scarriffle
7429a309c3 feat: wählbares Gruppen-Icon, geteilter Kalender markiert, Ersteller bei Gruppen-Terminen
- Gruppe: wählbares Emoji-Icon (groups.icon-Spalte + PUT /api/groups/{id});
  wird in der Sidebar statt des Zahnrads angezeigt; Verwalten jetzt klares "⋯".
  Gruppe umbenennen möglich (war vorher gesperrt).
- "Meine Kalender": der aktuell für Gruppen sichtbare Kalender wird mit 👥
  gekennzeichnet.
- Gruppenansicht: Gruppenkalender-Termine zeigen, wer sie hinzugefügt hat
  (👥 Vorname: Titel) und sind nach Ersteller eingefärbt; jeder kann weiterhin
  Termine im Gruppenkalender anlegen. Version v34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:34:59 +02:00
Scarriffle
682f9613ec feat: schönere Gruppenansicht – Vorname statt Initialen + Farbe pro Person
Kombinierte Ansicht: kryptisches "[SC]"-Initialen-Präfix ersetzt durch den
Vornamen ("Guido: Titel") für fremde Termine; eigene Termine ohne Präfix;
Gruppen-Termine mit 👥. Zusätzlich feste Farbkodierung pro Besitzer, damit
jedes Mitglied als Gruppe lesbar ist. Version v33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:01:55 +02:00
Scarriffle
2033cf99d4 feat: Anzeigename im Web (Profil bearbeiten + Anzeige)
- Profil: Anzeigename + Login-Name editierbar (vorher Benutzername read-only).
  Login-Namenwechsel speichert den frisch zurueckgegebenen Token.
- Menue/Dropdown und "Erstellt von"/Picker zeigen den Anzeigenamen.
- localStorage-User um display_name ergaenzt. Version v32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:49:19 +02:00
Scarriffle
f9923b022e feat: Login-Name vs. Anzeigename (Server)
- Neue Spalte users.display_name (Original-Schreibweise); username bleibt der
  lowercase Login-Name. Setup/Create setzen display_name aus der Eingabe.
- Login bleibt case-insensitive (Anzeigename eingeben funktioniert -> wird
  lowercased -> trifft den Login-Namen).
- Profil: PUT /api/profile/ kann display_name UND username (Login-Name) aendern;
  bei Login-Namen-Wechsel kommt ein frischer Token zurueck (JWT sub haengt am
  Namen). Stabile interne ID (Integer-PK) traegt alle Verweise -> Umbenennen
  bricht Shares/Gruppen/creator_id nicht.
- display_name ueberall ausgeliefert/genutzt (me, profile, users, directory,
  shares, Gruppen-Mitglieder, creator/owner, ORGANIZER-Export).
- Migration + Backfill (display_name = username). Tests ergaenzt (17 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:40:38 +02:00
Scarriffle
28a7cbe94e fix: kaputtes Plus-Icon + Kalendernamen nicht vorzeitig abschneiden
- Plus-Icon (Kalender/Gruppen hinzufuegen): fehlerhafter SVG-Pfad (v11 statt
  v6) -> korrekter, symmetrischer Plus-Pfad.
- Entfernen/Auge-Button per display:none statt opacity:0 -> reserviert keinen
  Platz mehr; Kalendername nutzt volle Breite und kuerzt erst beim Hover
  (wenn das Auge erscheint). Version v31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:31:39 +02:00
Scarriffle
cc4ccc7d81 feat: Live-Vorschau beim Kalender-Sortieren
Beim Ziehen wandert die Zeile jetzt live zwischen die anderen, die Liste
macht sichtbar Platz an der Zielposition. Container-dragover wird nur einmal
gebunden (kein Listener-Stacking pro Render). Version v30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:29:41 +02:00
Scarriffle
c62b3df33a fix: Kalenderquelle als Tooltip statt inline (Namen nicht mehr abgeschnitten)
Die Quelle/Konto stand inline rechts und hat die Kalendernamen abgeschnitten.
Jetzt im title-Tooltip des Eintrags ("Name · Quelle"); der Name nutzt die
volle Breite. Version v29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:25:47 +02:00
Scarriffle
8d2f487607 feat: flache sortierbare Kalenderliste (Drag&Drop) + Fixes
- Sidebar: eine flache Kalenderliste statt Quellen-Gruppen; Quelle/Konto
  klein-grau inline rechts neben dem Namen; per Drag&Drop sortierbar
  (Reihenfolge pro Geraet in localStorage).
- Gruppenkalender serverseitig auch beim Besitzer als group:true markiert
  -> erscheint nicht mehr in der "Fuer Gruppen sichtbar"-Auswahl und nicht
  in der normalen Kalenderliste (nur unter Gruppen).
- Settings-URL-State: uiSettingsOpen wird beim Init aus der URL gesetzt,
  bevor das erste writeUrlState() es ueberschreibt -> Reload bleibt jetzt
  wirklich in den Einstellungen.
- Auswahl-Markierungen (Mitglieder/Gruppen-Sichtbar) in Akzentfarbe,
  CSS-gezeichnet statt blauer Emoji. Version v28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:23:28 +02:00
Scarriffle
c7185a128e fix: Import-500 bei doppelten UIDs, Picker-UI & Settings-URL-State
- Import: Dedupe doppelter UIDs innerhalb der Datei (Nextcloud exportiert
  wiederkehrende Termine als mehrere VEVENTs gleicher UID) -> kein
  UNIQUE-constraint-500 mehr; Commit abgesichert. Test ergaenzt (15 gruen).
- Picker (Gruppen-Sichtbarkeit + Mitglieder): als <div>-Zeilen statt <label>,
  damit die globale ".form-group label"-Uppercase/Grau-Regel das Layout nicht
  mehr zerschiesst. Saubere .pick-row-Optik (Checkbox/Radio links, Name links).
- Einstellungen haben jetzt eigenen URL-State (#...&settings=1): Reload/Cache-
  leeren bleibt in den Einstellungen statt zur Kalenderansicht zu springen.
- Version v27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:15:17 +02:00
Scarriffle
8abeefcb5a feat: Gruppen-Sichtbarkeit in Einstellungen + "Mit dir geteilt"-Sektion + Import-Fehler
- Einstellungen: neuer "Kalender"-Abschnitt – Radio-Auswahl, welcher eigene
  Kalender fuer Gruppenmitglieder sichtbar ist (group_visible_calendar_id).
- Sidebar: geteilte Kalender in eigener Sektion "Mit dir geteilt" (mit
  Besitzername) statt inline bei "Meine Kalender"; Gruppenkalender dort
  ausgeblendet (erscheinen unter Gruppen).
- Lokale Checkbox tolerant: geteilte/Gruppen-Kalender werden client-seitig
  ein-/ausgeblendet (kein 404-PUT als Nicht-Besitzer).
- Import-Fehler: zeigt HTTP-Status / "Datei zu gross" statt "unbekannter
  Fehler" (z.B. nginx 413). Version v26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:02:03 +02:00
Scarriffle
06ba9c2bb1 feat: Gruppen-Sichtbarkeit – genau ein designierter Kalender pro Person
Neues user_settings.group_visible_calendar_id: jedes Mitglied waehlt EINEN
lokalen Kalender, der in seinen Gruppen sichtbar ist. Die kombinierte
Ansicht ueberlagert nur diesen (statt aller) Kalender je Mitglied + den
Gruppenkalender; private Termine weiter gefiltert. Settings GET/PUT erweitert
(nullbar). Tests angepasst + ergaenzt (14 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:57:42 +02:00
Scarriffle
479da29bc4 fix: Gruppen-Mitgliederliste sauber ausrichten (Checkbox links, Name links)
Eigene .group-member-item-Klasse statt der generischen Picker-Klasse —
behebt versetzte Checkboxen / rechtsbuendige Namen. Version v25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:47:58 +02:00
Scarriffle
f018f33f69 fix: JS/CSS immer revalidieren (no-cache) + Version v24
Bisher bekamen /static/js und /static/css max-age=7200. Da index.html
no-cache ist, konnte eine frische HTML mit 2h-altem, gecachtem JS/CSS
gepaart werden — neue Features (z.B. Gruppen-Button) ohne passenden
Handler. JS/CSS revalidieren jetzt bei jedem Load (304 wenn unveraendert);
Icons & uebrige Assets behalten 2h. Deploys greifen so sofort beim Reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:42:21 +02:00
Scarriffle
e8a13ba33c feat: Gruppen im Web-Frontend + Gruppenkalender in /local/calendars
- Sidebar-Sektion "Gruppen": Liste, Erstellen (Name + Mitglieder-Picker),
  Verwalten (Mitglieder hinzufuegen/entfernen), Loeschen.
- Gruppenansicht: laedt /api/groups/{id}/combined fuer den sichtbaren
  Bereich; Event-Titel werden mit Besitzer-Initialen bzw. Gruppen-Icon
  praefixt; Banner mit "Gruppenansicht verlassen".
- Server: GET /api/local/calendars liefert nun auch Gruppenkalender
  (group:true, read_write) fuer Mitglieder, damit sie im Editor waehlbar
  sind. Test ergaenzt (13 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:37:08 +02:00
Scarriffle
8d2a697f8b feat: Web-Frontend – Sharing, iCal Import/Export, Ersteller & Privat-Flag
- Ersteller-Zeile im Event-Popup (nur wenn Ersteller != aktueller User).
- Privat-Toggle im Event-Editor (nur lokale Kalender) + Sichtbarkeits-
  Auswahl (hidden|busy) in den Einstellungen.
- Lokale Kalender in Settings & Sidebar: Teilen/Importieren/Exportieren-
  Aktionen (nur eigene; geteilte mit "geteilt von"-Badge, kein Loeschen).
- Share-Modal: Benutzerverzeichnis mit Suche, read/read_write, Freigaben
  entfernen.
- api.js: download()-Helper fuer iCal-Export (Blob).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:30:47 +02:00
Scarriffle
32268a18b2 feat: Kalender-Sharing, Gruppen, iCal Import/Export & Ersteller (Server)
Kollaborations-Features ausschliesslich fuer lokale Kalender:

- Sharing: calendar_shares-Tabelle, GET/POST/DELETE /api/local/calendars/{id}/shares
  (nur Besitzer), GET /api/users/directory, geteilte Kalender in
  GET /api/local/calendars (shared_by/permission/owned) und im Merge-Read.
- Gruppen: groups/group_members/group_calendars + /api/groups-Router inkl.
  kombinierter Ansicht /api/groups/{id}/combined (owner + is_group_event).
- Ersteller: local_events.creator_id (serverseitig gesetzt) + creator_name_external
  aus ORGANIZER; creator-Feld in allen lokalen Event-Responses.
- Private-Flag: local_events.is_private + user_settings.private_event_visibility
  (hidden|busy), Filterung in der Gruppenansicht.
- iCal Import/Export: ical_io.py, POST /api/local/calendars/{id}/import,
  POST /api/local/import, GET /api/local/calendars/{id}/export.
- Zentraler Berechtigungs-Helper (permissions.py) und gemeinsamer Event-Dict-
  Builder (local_events_util.py) ersetzen die Nur-Besitzer-Filter.
- pytest-Suite (12 Tests) fuer Sharing, Gruppen, Parser, Private-Filterung.

Additiv & rueckwaertskompatibel; Migrationen in main.py._migrate().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:05:18 +02:00
Scarriffle
362cc7212c fix(version): Sidebar-Copyright wird jetzt auch aus version.js befuellt 2026-05-19 10:22:13 +02:00
Scarriffle
275e5a2ae0 fix: unaufgeloeste Merge-Konflikt-Marker aus i18n/calendar/week/css entfernt
Beim letzten Beta->Master-Merge sind die <<<<<<< / ======= / >>>>>>>
Marker mit committet worden. Das hat i18n.js mit einem SyntaxError beim
Parsen abgebrochen und damit den gesamten Frontend-Start kaputt gemacht
(=> komplett schwarze Seite, weil applyTheme nie lief).

Acht Bloecke aufgeloest, in allen Faellen die HEAD-Seite behalten
(neue Features: copy-Key, URL-State, all-day-continues-Logik, Event-
Popup-Header). v22 / sw cache v22.
2026-05-19 10:18:49 +02:00
Scarriffle
f102f02cb9 fix(version): Tab-Titel + Impressum dynamisch aus version.js
Vorher waren "Calendarr v18" in index.html hardcoded und wurden bei
Releases nie mit gebumpt — v19/v20 wurden zwar in version.js gepflegt,
landeten aber nie im Tab-Titel. Jetzt liest calendar.js APP_VERSION
direkt aus version.js und setzt sowohl document.title als auch das
Impressum-Footer-Label, damit das nicht mehr auseinanderlaufen kann.

v21 / sw cache v21
2026-05-19 10:12:32 +02:00
Scarriffle
43575f9042 fix(theme): Defaults weiss-auf-schwarz + Kontrast-Sicherheitsbremse
- Default-Schriftfarbe = #FFFFFF, Default-Hintergrund = #000000
- Wenn Schrift- und Hintergrundfarbe zu wenig Kontrast haben (< 2.5:1),
  wird automatisch auf weiss-auf-schwarz zurueckgefallen. So kann
  man sich nicht mehr in eine unsichtbare Seite manoevrieren.
- Color-Picker zeigt jetzt die wirksame Default-Farbe in der Vorschau
  (statt leer/transparent), auch wenn keine Override gesetzt ist.
2026-05-19 10:06:12 +02:00
Scarriffle
fd7562966a fix(settings): Schrift-/Linien-/Hintergrundfarbe — Live-Vorschau + Hex ohne '#'
- Live-Vorschau beim Tippen statt erst bei Blur (input-Event)
- Hex-Werte werden auch ohne fuehrendes '#' akzeptiert ("ff0000" -> "#FF0000")
- Reset-Button wendet Standardwerte sofort an
- v19 / sw cache v19
2026-05-19 09:57:40 +02:00
26 changed files with 3637 additions and 492 deletions

205
backend/ical_io.py Normal file
View File

@@ -0,0 +1,205 @@
"""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

@@ -0,0 +1,213 @@
"""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",
}
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,
) -> 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.
"""
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()],
}
if owner is not None:
d["owner"] = owner
if is_group_event:
d["is_group_event"] = 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,
) -> 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,
))
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,
))
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,
))
return results

View File

@@ -17,7 +17,7 @@ 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 auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -102,6 +102,20 @@ 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()
@@ -132,6 +146,62 @@ def _migrate():
except Exception: except Exception:
pass 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
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@@ -153,7 +223,12 @@ async def add_cache_headers(request: Request, call_next):
response.headers["Cache-Control"] = NO_CACHE response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
# 2h cache for the rest of the frontend (JS/CSS/icons/etc.) # 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/"): elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML; # SPA fallback (everything else that isn't an API route) returns HTML;
@@ -170,6 +245,7 @@ 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(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"])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -7,7 +7,10 @@ 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)
@@ -34,6 +37,11 @@ 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"
@@ -87,6 +95,15 @@ class UserSettings(Base):
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) 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) 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) bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# 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)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
@@ -119,8 +136,17 @@ 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)
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User")
class ICalSubscription(Base): class ICalSubscription(Base):
@@ -219,3 +245,76 @@ class HomeAssistantCalendar(Base):
sidebar_hidden = Column(Boolean, default=False) sidebar_hidden = Column(Boolean, default=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 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 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")

126
backend/permissions.py Normal file
View File

@@ -0,0 +1,126 @@
"""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 readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
"""All local calendar ids the user may read: own + shared + group calendars."""
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)
return list(ids)

View File

@@ -32,7 +32,12 @@ class LoginRequest(BaseModel):
def _user_dict(user: models.User) -> dict: def _user_dict(user: models.User) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin} return {
"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")
@@ -46,6 +51,7 @@ 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,
@@ -113,6 +119,7 @@ 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

@@ -11,8 +11,16 @@ 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__)
@@ -82,101 +90,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
} }
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)
@@ -417,15 +330,25 @@ def get_events(
"Error fetching calendar %s: %s", calendar.id, exc "Error fetching calendar %s: %s", calendar.id, exc
) )
# ── Local calendar events ───────────────────────────── # ── Local calendar events (own + shared + group calendars) ─────────────
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.user_id == current_user.id, models.LocalCalendar.id.in_(readable_ids),
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()}
# 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:
local_events = ( local_events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
@@ -441,25 +364,28 @@ 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:
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt)) built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)
else: else:
all_events.append({ built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
"id": ev.uid, for b in built:
"url": f"local://{ev.uid}", b = apply_event_privacy(
"title": ev.title, b, owner_id=owner_id, is_private=is_priv,
"start": ev.start, requester_id=current_user.id, visibility=visibility,
"end": ev.end, )
"allDay": ev.all_day, if b is not None:
"location": ev.location or "", all_events.append(b)
"description": ev.description or "",
"color": ev.color,
"rrule": None,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ────────────────────────── # ── iCal subscription events ──────────────────────────
ical_subs = ( ical_subs = (

View File

@@ -0,0 +1,448 @@
"""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()
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)],
})
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 so every client (web,
iOS, Android) renders identically: another member's / creator's first name
is prefixed. No icon glyph is embedded — group icons are semantic keys the
clients render as native vector icons, and group-calendar events are
distinguished by their (group) colour. The raw `title` stays for editing."""
if is_group:
if creator and creator.get("id") is not None and creator.get("id") != me_id:
return f"{_first_name(creator.get('display_name'))}: {title}"
return title
if owner and owner.get("id") is not None and owner.get("id") != me_id:
return f"{_first_name(owner.get('display_name'))}: {title}"
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}
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)
else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
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.
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

@@ -1,17 +1,26 @@
import uuid import uuid
from typing import Optional from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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"
@@ -33,6 +42,8 @@ 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)
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -45,35 +56,34 @@ 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
def _cal_dict(cal: models.LocalCalendar) -> dict: class ShareCreate(BaseModel):
return { user_id: int
permission: str = "read"
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
d = {
"id": cal.id, "id": cal.id,
"name": cal.name, "name": cal.name,
"color": cal.color, "color": cal.color,
"enabled": cal.enabled, "enabled": cal.enabled,
"type": "local",
"owned": owned,
} }
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) -> dict: def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
return { return build_local_event_dict(ev, cal, creator=resolve_creator(ev))
"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 ─────────────────────────────────────────
@@ -83,12 +93,70 @@ def list_calendars(
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),
): ):
cals = ( # Map calendar_id -> group name for every group the user belongs to, so we
# 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()
)
}
# 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()
) )
return [_cal_dict(c) for c in cals] result = []
for c in own:
d = _cal_dict(c, owned=True)
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,
)
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")
d["group"] = True
result.append(d)
return result
@router.post("/calendars") @router.post("/calendars")
@@ -164,16 +232,10 @@ 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),
): ):
cal = ( # Owner, shared (read_write), or group-member calendars are writable.
db.query(models.LocalCalendar) cal = permissions.accessible_local_calendar(
.filter( db, current_user, data.calendar_id, require_write=True
models.LocalCalendar.id == data.calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
) )
if not cal:
raise HTTPException(404, "Calendar not found")
ev = models.LocalEvent( ev = models.LocalEvent(
calendar_id=cal.id, calendar_id=cal.id,
@@ -186,11 +248,23 @@ def create_event(
description=data.description, description=data.description,
color=data.color, color=data.color,
rrule=data.rrule, rrule=data.rrule,
is_private=data.private,
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
creator_id=current_user.id, # server-side, never from the client
) )
db.add(ev) db.add(ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
return _event_dict(ev, cal) return _event_dict(ev, cal, db)
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}")
@@ -200,17 +274,9 @@ 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 = ( ev = _writable_event(db, current_user, uid)
db.query(models.LocalEvent) if data.private is not None:
.join(models.LocalCalendar) ev.is_private = data.private
.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:
@@ -233,6 +299,8 @@ 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
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -243,17 +311,219 @@ 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 = ( ev = _writable_event(db, current_user, uid)
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}
# ── 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"),
)
db.add(ev)
imported += 1
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,4 +1,5 @@
import io import io
import re
import base64 import base64
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -8,11 +9,13 @@ 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 from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
import models import models
from auth import get_current_user, get_password_hash, verify_password from auth import create_access_token, 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()
@@ -25,7 +28,16 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ─────────────────────────────────────────────── # ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
email: Optional[str] = None # Length caps (SQLite ignores VARCHAR limits, so enforce here).
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)
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):
@@ -47,6 +59,7 @@ 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,
@@ -60,10 +73,47 @@ 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:
current_user.email = data.email or None email = _strip_controls(data.email)
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
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
db.commit() db.commit()
return {"ok": True} return result
# ── Avatar ──────────────────────────────────────────────── # ── Avatar ────────────────────────────────────────────────

View File

@@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,6 +27,9 @@ class SettingsUpdate(BaseModel):
text_color: Optional[str] = None text_color: Optional[str] = None
line_color: Optional[str] = None line_color: Optional[str] = None
bg_color: Optional[str] = None 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
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -46,6 +49,9 @@ def _settings_dict(s: models.UserSettings) -> dict:
"text_color": s.text_color, "text_color": s.text_color,
"line_color": s.line_color, "line_color": s.line_color,
"bg_color": s.bg_color, "bg_color": s.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,
} }
@@ -82,10 +88,13 @@ 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"):
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
# For these three override colours, an explicit null is meaningful # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields # ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored. # keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"} NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes"}
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
if field in NULLABLE_OVERRIDES: if field in NULLABLE_OVERRIDES:

View File

@@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel):
def _user_dict(u: models.User) -> dict: def _user_dict(u: models.User) -> dict:
return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin} return {
"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("/")
@@ -35,6 +41,25 @@ 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)
.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,
@@ -45,6 +70,7 @@ 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,

61
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,61 @@
"""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

@@ -0,0 +1,411 @@
"""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

View File

@@ -535,8 +535,10 @@ a { color: var(--primary); text-decoration: none; }
outline: none; outline: none;
} }
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; } .cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
.cal-item-remove { opacity: 0; } /* Hide the remove/eye button until hover so the calendar name uses the full
.cal-item:hover .cal-item-remove { opacity: 1; } width and only truncates while the button is visible. */
.cal-item-remove { display: none; }
.cal-item:hover .cal-item-remove { display: inline-flex; }
/* ── Month View ─────────────────────────────────────────── */ /* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@@ -580,7 +582,8 @@ a { color: var(--primary); text-decoration: none; }
.cell-day { .cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2); font-size: 12px; font-weight: 500; color: var(--text-2);
width: 26px; height: 26px; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
vertical-align: top;
border-radius: 50%; flex-shrink: 0; border-radius: 50%; flex-shrink: 0;
} }
.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } .cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
@@ -589,13 +592,8 @@ a { color: var(--primary); text-decoration: none; }
.month-col { .month-col {
position: relative; /* anchor for divider pseudo-elements */ position: relative; /* anchor for divider pseudo-elements */
} }
.month-col.first-of-month { /* first-of-month keeps the normal block layout (number top-left); the month
display: flex; label sits inline next to the number via .cell-day/.month-marker below. */
flex-direction: column;
align-items: flex-start;
gap: 0;
padding-top: 8px;
}
/* Dividers via pseudo-elements so they render above events (z-index 2) */ /* Dividers via pseudo-elements so they render above events (z-index 2) */
.month-col.month-divider-left::before { .month-col.month-divider-left::before {
content: ''; content: '';
@@ -639,14 +637,14 @@ a { color: var(--primary); text-decoration: none; }
pointer-events: none; pointer-events: none;
} }
.month-marker { .month-marker {
font-size: 14px; font-size: 11px;
font-weight: 700; font-weight: 600;
text-transform: uppercase; text-transform: none;
letter-spacing: .5px; letter-spacing: .3px;
color: var(--month-label-color, #7090c0); color: var(--month-label-color, #7090c0);
line-height: 1; line-height: 26px; /* align with the day-number circle */
padding: 0 2px; margin-left: 4px;
margin: 0 0 2px 4px; vertical-align: top;
position: relative; position: relative;
z-index: 3; /* above events overlay (z-index 2) */ z-index: 3; /* above events overlay (z-index 2) */
} }
@@ -655,10 +653,10 @@ a { color: var(--primary); text-decoration: none; }
position: relative; position: relative;
z-index: 3; z-index: 3;
} }
/* Push events overlay down when row contains a first-of-month cell so the /* Month marker now sits inline next to the day number, so the header height is
day "1" (which sits below the month marker) isn't hidden by event bars */ uniform and the events overlay needs no extra offset for month-start weeks. */
.month-row.has-month-marker .month-events-overlay { .month-row.has-month-marker .month-events-overlay {
top: 56px; top: 30px;
} }
/* Events overlay — pointer-events:none so clicks pass to columns */ /* Events overlay — pointer-events:none so clicks pass to columns */
.month-events-overlay { .month-events-overlay {
@@ -975,40 +973,61 @@ a { color: var(--primary); text-decoration: none; }
} }
.ctx-item:hover { background: var(--bg-hover); } .ctx-item:hover { background: var(--bg-hover); }
<<<<<<< HEAD
/* ── Event Popup ────────────────────────────────────────── /* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar), Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
modern und lässt dem Titel die meiste Breite. */ modern und lässt dem Titel die meiste Breite. */
=======
/* ── Event Popup ────────────────────────────────────────── */
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
.event-popup { .event-popup {
position: fixed; z-index: 600; position: fixed; z-index: 600;
background: var(--bg-surface); width: 320px;
border: 1px solid var(--border); background: var(--bg-surface); /* fallback for no color-mix */
border-radius: var(--radius); background: color-mix(in srgb, var(--bg-surface) 86%, transparent);
width: 360px; -webkit-backdrop-filter: blur(22px) saturate(1.6);
box-shadow: var(--shadow-lg); backdrop-filter: blur(22px) saturate(1.6);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 16px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.05),
0 12px 40px rgba(0,0,0,.55),
0 2px 8px rgba(0,0,0,.4);
overflow: hidden;
transform-origin: top left;
animation: popupIn .18s cubic-bezier(.2,.8,.2,1);
}
@keyframes popupIn {
from { opacity: 0; transform: translateY(6px) scale(.97); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.event-popup { animation: none; }
} }
.popup-header { .popup-header {
display: flex; align-items: flex-start; gap: 10px; position: relative;
padding: 12px 10px 12px 16px; display: flex; align-items: flex-start; gap: 11px;
border-bottom: 1px solid var(--border); padding: 14px 10px 13px 18px;
background: linear-gradient(180deg,
color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent);
}
/* Slim accent strip in the event's colour. */
.popup-header::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--ev-color, var(--primary));
} }
.popup-color-dot { .popup-color-dot {
width: 11px; height: 11px; border-radius: 50%; width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0; background: var(--ev-color, var(--primary));
margin-top: 6px; flex-shrink: 0; margin-top: 6px;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ev-color, var(--primary)) 22%, transparent);
} }
.popup-header h4 { .popup-header h4 {
flex: 1; flex: 1;
font-size: 14px; font-weight: 500; font-size: 15px; font-weight: 600;
line-height: 1.4; line-height: 1.35; letter-spacing: -.01em;
color: var(--text-1);
word-break: break-word; word-break: break-word;
padding-top: 2px; padding-top: 1px;
} }
.popup-toolbar { .popup-toolbar {
@@ -1018,8 +1037,8 @@ a { color: var(--primary); text-decoration: none; }
margin-left: 4px; margin-left: 4px;
} }
.popup-icon-btn { .popup-icon-btn {
width: 30px; height: 30px; width: 32px; height: 32px;
border-radius: 50%; border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
background: transparent; background: transparent;
border: none; border: none;
@@ -1029,9 +1048,9 @@ a { color: var(--primary); text-decoration: none; }
transition: transition:
background var(--transition), background var(--transition),
color var(--transition), color var(--transition),
transform .1s ease; transform .12s ease;
} }
.popup-icon-btn svg { width: 15px; height: 15px; fill: currentColor; flex-shrink: 0; } .popup-icon-btn svg { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; }
.popup-icon-btn:hover { .popup-icon-btn:hover {
background: rgba(66,133,244,.16); background: rgba(66,133,244,.16);
background: color-mix(in srgb, var(--primary) 16%, transparent); background: color-mix(in srgb, var(--primary) 16%, transparent);
@@ -1046,32 +1065,39 @@ a { color: var(--primary); text-decoration: none; }
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-1); color: var(--text-1);
} }
.popup-icon-btn:active { transform: scale(.9); } .popup-icon-btn:active { transform: scale(.88); }
.popup-body { padding: 12px 16px 14px; display: flex; flex-direction: column; gap: 9px; }
.popup-row { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.45; color: var(--text-2); }
.popup-row-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; fill: var(--text-3); }
#popup-time { color: var(--text-1); font-weight: 500; }
.popup-row-desc { color: var(--text-1); }
.popup-row-desc span { white-space: pre-wrap; }
#popup-creator { font-style: italic; }
.popup-body { padding: 12px 16px; }
.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; }
.popup-copy-menu { .popup-copy-menu {
border-top: 1px solid var(--border); border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
padding: 4px 0; padding: 6px;
} }
.popup-copy-label { .popup-copy-label {
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-3); letter-spacing: .5px; color: var(--text-3);
padding: 4px 14px 6px; padding: 4px 10px 6px;
} }
.popup-copy-item { .popup-copy-item {
display: flex; align-items: center; gap: 9px; display: flex; align-items: center; gap: 9px;
padding: 7px 14px; cursor: pointer; font-size: 13px; color: var(--text-1); padding: 8px 10px; cursor: pointer; font-size: 13px; color: var(--text-1);
border-radius: 10px;
transition: background var(--transition);
} }
.popup-copy-item:hover { background: var(--bg-hover); } .popup-copy-item:hover { background: var(--bg-hover); }
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.popup-copy-edit-toggle { .popup-copy-edit-toggle {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 6px 14px 8px; padding: 6px 10px 8px;
font-size: 12px; color: var(--text-2); font-size: 12px; color: var(--text-2);
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--border); border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
margin-bottom: 4px; margin-bottom: 4px;
} }
.popup-copy-edit-toggle input[type="checkbox"] { margin: 0; cursor: pointer; } .popup-copy-edit-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
@@ -1701,24 +1727,12 @@ a { color: var(--primary); text-decoration: none; }
.topbar-left { gap: 0; } .topbar-left { gap: 0; }
.topbar-right { gap: 0; } .topbar-right { gap: 0; }
<<<<<<< HEAD
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */ /* Event-Popup auf Mobile: an Viewport-Breite anpassen */
.event-popup { width: min(94vw, 380px); max-width: 94vw; } .event-popup { width: min(94vw, 360px); max-width: 94vw; }
.popup-header { padding: 10px 8px 10px 14px; } .popup-header { padding: 12px 8px 11px 16px; }
.popup-header h4 { font-size: 13.5px; } .popup-header h4 { font-size: 14.5px; }
.popup-icon-btn { width: 32px; height: 32px; } .popup-icon-btn { width: 36px; height: 36px; }
.popup-icon-btn svg { width: 16px; height: 16px; } .popup-icon-btn svg { width: 17px; height: 17px; }
=======
/* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */
.event-popup .icon-btn {
min-width: 32px !important;
min-height: 32px !important;
width: 32px;
height: 32px;
}
.event-popup .popup-header { gap: 2px; padding: 10px 12px; }
.event-popup { width: min(92vw, 340px); max-width: 92vw; }
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
.month-event-time { display: none; } .month-event-time { display: none; }
@@ -1780,3 +1794,180 @@ a { color: var(--primary); text-decoration: none; }
} }
} }
/* ── Collaboration: sharing badges & user picker ───────────── */
.cal-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-surface);
color: var(--text-2);
border: 1px solid var(--border);
white-space: nowrap;
}
.cal-badge-shared {
background: rgba(66, 133, 244, 0.15);
color: var(--primary);
border-color: transparent;
}
.share-user-picker {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
.share-user-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.share-user-item:last-child { border-bottom: none; }
.share-user-item:hover { background: var(--bg-surface); }
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
/* ── Groups ─────────────────────────────────────────────────── */
.group-view-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
background: var(--bg-surface); /* fallback for browsers without color-mix */
background: color-mix(in srgb, var(--accent) 15%, var(--bg-app));
border-bottom: 1px solid var(--accent);
font-size: 14px;
color: var(--text-1);
}
/* Group-member rows in the sidebar (colour dot = per-user colour, checkbox =
show/hide). Reuse the calendar-list item styling. */
.gm-row .gm-dot { cursor: pointer; }
.group-item-active {
background: var(--bg-surface);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 8px;
}
.group-item .cal-item-name { cursor: pointer; flex: 1; }
.cal-list-empty {
padding: 6px 4px;
font-size: 13px;
color: var(--text-3);
}
/* Group member picker rows — checkbox left, name left, one per line. */
.group-member-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
text-align: left;
text-transform: none;
}
.group-member-item:last-child { border-bottom: none; }
.group-member-item:hover { background: var(--bg-surface); }
.group-member-item input[type="checkbox"] { flex: 0 0 auto; margin: 0; }
.group-member-name { flex: 1 1 auto; color: var(--text-1); }
/* Calendar radio list (group-visible selection in settings). */
.cal-radio-list {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.cal-radio-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.cal-radio-item:last-child { border-bottom: none; }
.cal-radio-item:hover { background: var(--bg-surface); }
.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; }
/* Picker rows (group-visible calendar radio + group member checkboxes).
Deliberately NOT <label> elements, so the global ".form-group label"
uppercase/grey styling never applies. */
.cal-radio-list,
#group-member-picker {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
max-height: 260px;
overflow-y: auto;
}
.pick-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
text-align: left;
text-transform: none;
letter-spacing: normal;
font-size: 14px;
color: var(--text-1);
}
.pick-row:last-child { border-bottom: none; }
.pick-row:hover { background: var(--bg-surface); }
.pick-row-sel { box-shadow: inset 3px 0 0 var(--accent); }
.pick-mark {
flex: 0 0 auto;
width: 18px; height: 18px;
border: 2px solid var(--text-3);
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; line-height: 1; color: #fff;
}
.pick-check { border-radius: 4px; }
.pick-radio { border-radius: 50%; }
.pick-mark.on { background: var(--accent); border-color: var(--accent); }
.pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; }
.pick-dot-empty { background: transparent; }
.pick-name { flex: 1 1 auto; text-align: left; }
/* Flat calendar list: inline source label + drag handle. */
.cal-source {
margin-left: auto;
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 8px;
}
.cal-drag-handle {
flex: 0 0 auto;
cursor: grab;
color: var(--text-3);
font-size: 14px;
user-select: none;
}
.cal-item.cal-dragging { opacity: .5; }
/* Group emoji + icon picker */
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
.group-icon-opt {
width: 38px; height: 38px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; line-height: 1;
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.group-icon-opt { color: var(--text-1); }
.group-icon-opt:hover { background: var(--bg-surface); }
.group-icon-opt.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; color: var(--accent); }
/* SVG group icons render as block so they centre cleanly (vs emoji baseline). */
.group-emoji svg, .cal-shared-flag svg, .group-icon-opt svg { display: block; }

View File

@@ -3,8 +3,8 @@
<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" />
<!-- APP_VERSION: update here + version.js on every release --> <!-- Title wird beim Laden aus version.js dynamisch gesetzt -->
<title>Calendarr v18</title> <title>Calendarr</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="#4285f4" /> <meta name="theme-color" content="#4285f4" />
@@ -179,13 +179,21 @@
<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-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></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>
@@ -198,13 +206,30 @@
</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" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v18</button> <button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</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>
@@ -319,6 +344,11 @@
<label>Kalender</label> <label>Kalender</label>
<select id="ev-calendar"></select> <select id="ev-calendar"></select>
</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 class="form-group"> <div class="form-group">
<label>Ort</label> <label>Ort</label>
<input type="text" id="ev-location" placeholder="Ort hinzufügen" /> <input type="text" id="ev-location" placeholder="Ort hinzufügen" />
@@ -344,6 +374,68 @@
</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">
@@ -391,10 +483,26 @@
</div> </div>
</div> </div>
<div class="popup-body"> <div class="popup-body">
<div class="popup-time" id="popup-time"></div> <div class="popup-row" id="popup-row-time">
<div class="popup-location" id="popup-location"></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-description" id="popup-description"></div> <span id="popup-time"></span>
<div class="popup-calendar" id="popup-calendar"></div> </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>
@@ -567,15 +675,53 @@
<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="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button> <button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="view" data-i18n="settings_nav_view">Ansicht</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_users">Benutzerverwaltung</button>
</nav> </nav>
<div class="settings-panels"> <div class="settings-panels">
<!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) --> <!-- Profil: Name, Privatsphäre, geteilter Kalender -->
<div class="settings-panel active" id="settings-panel-general"> <div class="settings-panel active" id="settings-panel-profile">
<h4 class="panel-title" data-i18n="profile_account">Konto</h4>
<div class="form-group">
<label data-i18n="profile_display_name">Anzeigename</label>
<input type="text" id="cfg-display-name" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" />
</div>
<div class="form-group">
<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>
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
<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">
<label data-i18n="settings_private_visibility">Private Termine für Gruppenmitglieder</label>
<select id="cfg-private-visibility">
<option value="busy" data-i18n="private_visibility_busy">Als „Beschäftigt“ anzeigen</option>
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
</select>
</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">
<label data-i18n="settings_group_visible">Für Gruppen sichtbarer Kalender</label>
<div id="cfg-group-visible-list" class="cal-radio-list"></div>
</div>
</div>
<!-- Darstellung: Sprache, Farben, Stundenhöhe -->
<div class="settings-panel" id="settings-panel-general">
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4> <h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
<div class="form-group"> <div class="form-group">
@@ -647,7 +793,19 @@
</div> </div>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4> <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>
</div>
<!-- Ansicht: Standardansicht, Wochenstart, vergangene Termine, ausgeblendete Kalender -->
<div class="settings-panel" id="settings-panel-view">
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<div class="form-group"> <div class="form-group">
<label data-i18n="settings_default_view">Standardansicht</label> <label data-i18n="settings_default_view">Standardansicht</label>
<select id="cfg-default-view"> <select id="cfg-default-view">
@@ -672,22 +830,13 @@
</label> </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> <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 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>
<!-- Konten (CalDAV, Lokal, iCal, Google) --> <!-- Konten (CalDAV, Lokal, iCal, Google) -->
<div class="settings-panel" id="settings-panel-accounts"> <div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4> <h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
<div class="accounts-section"> <div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div> <div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
@@ -766,16 +915,21 @@
<!-- Account Info --> <!-- Account Info -->
<div class="settings-section"> <div class="settings-section">
<h4>Konto</h4> <h4 data-i18n="profile_account">Konto</h4>
<div class="form-group"> <div class="form-group">
<label>Benutzername</label> <label data-i18n="profile_display_name">Anzeigename</label>
<input type="text" id="profile-username" disabled class="input-disabled" /> <input type="text" id="profile-display-name-input" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" />
</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">Speichern</button> <button class="btn btn-primary btn-sm" id="profile-save-info" data-i18n="save">Speichern</button>
</div> </div>
<!-- Password --> <!-- Password -->
@@ -895,7 +1049,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 style="font-size:12px;color:var(--text-3)">Calendarr v18</span> <span id="impressum-version" style="font-size:12px;color:var(--text-3)">Calendarr</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

@@ -50,19 +50,58 @@ async function uploadRequest(path, formData) {
return null; return null;
} }
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') })); // Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
throw new Error(err.detail || `HTTP ${res.status}`); // 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; if (res.status === 204) return null;
return res.json(); 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) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
// Derive filename from Content-Disposition if present.
let filename = fallbackName || 'calendar.ics';
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 = {
get: (path) => request('GET', path), get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body), post: (path, body) => request('POST', path, body),
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

@@ -60,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.username || 'Benutzer'; document.getElementById('dropdown-username').textContent = user.display_name || user.username || 'Benutzer';
avatar.addEventListener('click', e => { avatar.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();

File diff suppressed because it is too large Load Diff

View File

@@ -60,6 +60,9 @@ const translations = {
// Settings // Settings
settings_title: 'Einstellungen', settings_title: 'Einstellungen',
settings_nav_appearance: 'Darstellung', settings_nav_appearance: 'Darstellung',
settings_nav_profile: 'Profil',
settings_nav_view: 'Ansicht',
settings_nav_calendars: 'Kalender',
settings_nav_google: 'Google Konten', settings_nav_google: 'Google Konten',
settings_nav_users: 'Benutzerverwaltung', settings_nav_users: 'Benutzerverwaltung',
settings_colors: 'Farben', settings_colors: 'Farben',
@@ -84,6 +87,61 @@ const translations = {
settings_week_start: 'Erster Wochentag', settings_week_start: 'Erster Wochentag',
week_start_monday: 'Montag', week_start_sunday: 'Sonntag', week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
settings_dim_past: 'Vergangene Termine ausgrauen', settings_dim_past: 'Vergangene Termine ausgrauen',
settings_privacy: 'Privatsphäre',
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
private_visibility_busy: 'Als „Beschäftigt“ anzeigen',
private_visibility_hidden: 'Ausblenden',
created_by: 'Erstellt von: {name}',
event_private: 'Privat',
share: 'Teilen',
import: 'Importieren',
export: 'Exportieren',
importing: 'Importiere…',
import_result: '{imported} importiert, {skipped} übersprungen',
shared_by: 'geteilt von {name}',
share_title: 'Kalender teilen',
share_current: 'Aktuelle Freigaben',
share_add: 'Benutzer hinzufügen',
share_search: 'Benutzer suchen…',
share_none: 'Noch nicht geteilt',
share_no_users: 'Keine Benutzer gefunden',
perm_read: 'Nur lesen',
perm_read_write: 'Lesen & schreiben',
remove: 'Entfernen',
done: 'Fertig',
groups_title: 'Gruppen',
groups_none: 'Noch keine Gruppen',
group_create: 'Gruppe erstellen',
group_manage: 'Gruppe verwalten',
group_name: 'Name',
group_name_ph: 'Gruppenname',
group_members: 'Mitglieder',
group_delete: 'Gruppe löschen',
group_delete_confirm: 'Diese Gruppe und ihren Gruppenkalender wirklich löschen?',
group_deleted: 'Gruppe gelöscht',
group_saved: 'Gruppe gespeichert',
group_created: 'Gruppe erstellt',
group_view_label: 'Gruppenansicht: {name}',
group_exit: 'Gruppenansicht verlassen',
group_calendar: 'Gruppenkalender',
group_icon: 'Icon',
group_visible_flag: 'Für deine Gruppen sichtbar',
group_new_event: '+ Gruppentermin',
group_member_colors: 'Farben der Mitglieder',
only_owner_color: 'Nur der Besitzer kann die Farbe ändern',
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
shared_with_me: 'Mit dir geteilt',
settings_calendars: 'Kalender',
settings_group_visible: 'Für Gruppen sichtbarer Kalender',
settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist',
group_visible_none: 'Keiner',
drag_reorder: 'Zum Sortieren ziehen',
profile_account: 'Konto',
profile_display_name: 'Anzeigename',
profile_display_name_ph: 'Anzeigename',
profile_login_name: 'Login-Name',
profile_login_name_desc: 'Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)', settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt', settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal', hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -158,11 +216,7 @@ const translations = {
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine', rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
<<<<<<< HEAD
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
=======
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Vor dem Kopieren bearbeiten', edit_before_copy: 'Vor dem Kopieren bearbeiten',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?', confirm_delete_event: '"{title}" wirklich löschen?',
@@ -279,6 +333,9 @@ const translations = {
// Settings // Settings
settings_title: 'Settings', settings_title: 'Settings',
settings_nav_appearance: 'Appearance', settings_nav_appearance: 'Appearance',
settings_nav_profile: 'Profile',
settings_nav_view: 'View',
settings_nav_calendars: 'Calendars',
settings_nav_google: 'Google Accounts', settings_nav_google: 'Google Accounts',
settings_nav_users: 'User Management', settings_nav_users: 'User Management',
settings_colors: 'Colors', settings_colors: 'Colors',
@@ -303,6 +360,61 @@ const translations = {
settings_week_start: 'First day of week', settings_week_start: 'First day of week',
week_start_monday: 'Monday', week_start_sunday: 'Sunday', week_start_monday: 'Monday', week_start_sunday: 'Sunday',
settings_dim_past: 'Dim past events', settings_dim_past: 'Dim past events',
settings_privacy: 'Privacy',
settings_private_visibility: 'Private events for group members',
settings_private_visibility_desc: 'How your private events appear to other group members',
private_visibility_busy: 'Show as "Busy"',
private_visibility_hidden: 'Hide completely',
created_by: 'Created by: {name}',
event_private: 'Private',
share: 'Share',
import: 'Import',
export: 'Export',
importing: 'Importing…',
import_result: '{imported} imported, {skipped} skipped',
shared_by: 'shared by {name}',
share_title: 'Share calendar',
share_current: 'Current shares',
share_add: 'Add user',
share_search: 'Search users…',
share_none: 'Not shared yet',
share_no_users: 'No users found',
perm_read: 'Read only',
perm_read_write: 'Read & write',
remove: 'Remove',
done: 'Done',
groups_title: 'Groups',
groups_none: 'No groups yet',
group_create: 'Create group',
group_manage: 'Manage group',
group_name: 'Name',
group_name_ph: 'Group name',
group_members: 'Members',
group_delete: 'Delete group',
group_delete_confirm: 'Really delete this group and its group calendar?',
group_deleted: 'Group deleted',
group_saved: 'Group saved',
group_created: 'Group created',
group_view_label: 'Group view: {name}',
group_exit: 'Exit group view',
group_calendar: 'Group calendar',
group_icon: 'Icon',
group_visible_flag: 'Visible to your groups',
group_new_event: '+ Group event',
group_member_colors: 'Member colours',
only_owner_color: 'Only the owner can change the colour',
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
shared_with_me: 'Shared with me',
settings_calendars: 'Calendars',
settings_group_visible: 'Calendar visible to groups',
settings_group_visible_desc: 'Choose which of your calendars your group members can see',
group_visible_none: 'None',
drag_reorder: 'Drag to reorder',
profile_account: 'Account',
profile_display_name: 'Display name',
profile_display_name_ph: 'Display name',
profile_login_name: 'Login name',
profile_login_name_desc: 'Lowercase, used to sign in. Case-insensitive.',
settings_hour_height: 'Hour height (week & day view)', settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid', settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal', hour_compact: 'Compact', hour_normal: 'Normal',
@@ -377,11 +489,7 @@ const translations = {
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences', rec_on_date: 'On date', rec_occurrences: 'occurrences',
<<<<<<< HEAD
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy', copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
=======
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Edit before copying', edit_before_copy: 'Edit before copying',
event_updated: 'Event updated', event_created: 'Event created', event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?', confirm_delete_event: 'Really delete "{title}"?',

View File

@@ -76,6 +76,12 @@ const LINE_CONTRAST = {
4: { border: '#5a5a78', light: '#484860' }, 4: { border: '#5a5a78', light: '#484860' },
}; };
// Defaults wenn kein Custom-Override gesetzt ist.
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
export const DEFAULT_TEXT_COLOR = '#FFFFFF';
export const DEFAULT_LINE_COLOR = '#3A3A52';
export const DEFAULT_BG_COLOR = '#000000';
export function applyTheme(settings) { export function applyTheme(settings) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--primary', settings.primary_color || '#4285f4'); root.style.setProperty('--primary', settings.primary_color || '#4285f4');
@@ -83,47 +89,33 @@ export function applyTheme(settings) {
root.style.setProperty('--accent', settings.accent_color || '#ea4335'); root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4'); root.style.setProperty('--today-color', settings.today_color || '#4285f4');
// Text colour: a custom hex (settings.text_color) wins over the legacy // Effektive Farben bestimmen (Override > Default).
// 14 contrast step. We derive --text-2/--text-3 by darkening the let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
// chosen colour so the secondary/tertiary text stays in the same hue. let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
if (settings.text_color) { let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
root.style.setProperty('--text-1', settings.text_color);
root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25)); // Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55)); // Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
} else { // erzwinge weiss-auf-schwarz, damit man nicht in einer unbedienbaren
const tc = TEXT_CONTRAST[settings.text_contrast || 3]; // Seite landet.
root.style.setProperty('--text-1', tc.t1); if (contrastRatio(textColor, bgColor) < 2.5) {
root.style.setProperty('--text-2', tc.t2); textColor = DEFAULT_TEXT_COLOR;
root.style.setProperty('--text-3', tc.t3); bgColor = DEFAULT_BG_COLOR;
} }
// Line colour: custom hex overrides the legacy contrast step. root.style.setProperty('--text-1', textColor);
if (settings.line_color) { root.style.setProperty('--text-2', shadeHex(textColor, -0.25));
root.style.setProperty('--border', settings.line_color); root.style.setProperty('--text-3', shadeHex(textColor, -0.55));
root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25));
} else {
const lc = LINE_CONTRAST[settings.line_contrast || 3];
root.style.setProperty('--border', lc.border);
root.style.setProperty('--border-light', lc.light);
}
// Background colour: optional. If set, also tint the topbar/sidebar root.style.setProperty('--border', lineColor);
// and surface variants so the whole UI stays coherent. root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
if (settings.bg_color) {
root.style.setProperty('--bg-app', settings.bg_color); root.style.setProperty('--bg-app', bgColor);
root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10)); root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10));
root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10)); root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18)); root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26)); root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40)); root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
} else {
root.style.removeProperty('--bg-app');
root.style.removeProperty('--bg-topbar');
root.style.removeProperty('--bg-sidebar');
root.style.removeProperty('--bg-surface');
root.style.removeProperty('--bg-hover');
root.style.removeProperty('--bg-active');
}
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');
@@ -132,6 +124,24 @@ export function applyTheme(settings) {
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0'); root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
} }
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; }
}
function hexToRgba(hex, alpha) { function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3), 16); const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16); const g = parseInt(hex.slice(3,5), 16);

View File

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

View File

@@ -149,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
? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>` ? `<span class="month-marker">${monthsShort[cell.getMonth()]}</span>`
: ''; : '';
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}">
${monthLabel}
<div class="cell-day ${numCls}">${cell.getDate()}</div> <div class="cell-day ${numCls}">${cell.getDate()}</div>
${monthLabel}
</div>`; </div>`;
}); });

View File

@@ -63,7 +63,6 @@ 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' : '';
<<<<<<< HEAD
// continues-left/right: compute on date-only basis for all-day events // continues-left/right: compute on date-only basis for all-day events
let evStart = new Date(ev.start); let evStart = new Date(ev.start);
let evEnd = new Date(ev.end); let evEnd = new Date(ev.end);
@@ -77,10 +76,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0); const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
const cL = evStart < firstDay ? 'continues-left' : ''; const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : ''; const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
=======
const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
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))} ${ev.title}` ? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title; : ev.title;
@@ -252,7 +247,6 @@ function renderNowLine(container, days, hourH = 60) {
function layoutWeekAllDay(evs, days) { function layoutWeekAllDay(evs, days) {
const items = []; const items = [];
evs.forEach(ev => { evs.forEach(ev => {
<<<<<<< HEAD
// For all-day events, normalize to date-only with inclusive end-day // For all-day events, normalize to date-only with inclusive end-day
// (iCal stores exclusive end → subtract 1). For timed events, keep // (iCal stores exclusive end → subtract 1). For timed events, keep
// the original strict-overlap logic so events ending exactly at // the original strict-overlap logic so events ending exactly at
@@ -275,13 +269,6 @@ function layoutWeekAllDay(evs, days) {
matches = new Date(ev.start) < de && new Date(ev.end) > ds; matches = new Date(ev.start) < de && new Date(ev.end) > ds;
} }
if (matches) { if (matches) {
=======
let colStart = -1, colEnd = -1;
days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
const de = new Date(day); de.setHours(24, 0, 0, 0);
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
if (colStart === -1) colStart = i; if (colStart === -1) colStart = i;
colEnd = i; colEnd = i;
} }

View File

@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next // the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required. // reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v18'; const CACHE_VERSION = 'calendarr-v23';
const OFFLINE_SHELL = ['/', '/index.html']; const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => { self.addEventListener('install', event => {

3
requirements-dev.txt Normal file
View File

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