Compare commits
195 Commits
58faf3876c
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45bf6ea73 | ||
|
|
c632dabc31 | ||
|
|
995d9bb5b4 | ||
|
|
4c7cdc362a | ||
|
|
37e5507261 | ||
|
|
22a58e0d9f | ||
|
|
cea96660d9 | ||
|
|
6316ed3a6b | ||
|
|
12c869451b | ||
|
|
a7802778b6 | ||
|
|
7a89cc44f8 | ||
|
|
131f6b496d | ||
|
|
4ab07ddcc3 | ||
|
|
137694ed98 | ||
|
|
3d58e8fef8 | ||
|
|
91ae434e7c | ||
|
|
1f1eb582ed | ||
|
|
29ddc5acbb | ||
|
|
0ce77ccdf8 | ||
|
|
16ff434bef | ||
|
|
1a30b4066a | ||
|
|
aa12b83302 | ||
|
|
19258096e2 | ||
|
|
6baa07379f | ||
|
|
eb0684b99c | ||
|
|
ca09538971 | ||
|
|
d426f8985c | ||
|
|
89a1355149 | ||
|
|
f844ded57d | ||
|
|
ec85a5b5f3 | ||
|
|
af64e191ec | ||
|
|
52bc7066db | ||
|
|
7d5530e51c | ||
|
|
90bc4b1531 | ||
|
|
1eecf834a3 | ||
|
|
b8a578cf53 | ||
|
|
572172c424 | ||
|
|
343f7a5e7b | ||
|
|
cad48efcc6 | ||
|
|
2ae8499247 | ||
|
|
79fdf4f54a | ||
|
|
f76d2783d9 | ||
|
|
e539508bec | ||
|
|
784c9013eb | ||
|
|
e3844294ae | ||
|
|
444772c959 | ||
|
|
e0ea16f2ef | ||
|
|
9fb350eb29 | ||
|
|
94655ce7c5 | ||
|
|
9306d638ad | ||
|
|
f662163185 | ||
|
|
fb32f0424f | ||
|
|
34e701fa78 | ||
|
|
661a8ea579 | ||
|
|
47944ed952 | ||
|
|
a591eb51d3 | ||
|
|
f791160d1a | ||
|
|
906241a743 | ||
|
|
2e76e63ba2 | ||
|
|
6e8efb2ed5 | ||
|
|
d6159f0639 | ||
|
|
be3d4057b3 | ||
|
|
eb9440c2b2 | ||
|
|
34777c78c9 | ||
|
|
0427e1189d | ||
|
|
c1fdfe72d0 | ||
|
|
6365f99ac0 | ||
|
|
d04e2952cb | ||
|
|
c5fd5b4e55 | ||
|
|
d1c5b66230 | ||
|
|
2c59d873f3 | ||
|
|
c515e9d7e1 | ||
|
|
e6bc7eab9d | ||
|
|
70aaec3401 | ||
|
|
900abcdb7a | ||
|
|
0c8fbb86e8 | ||
|
|
367087450e | ||
|
|
c684a424eb | ||
|
|
db43bad432 | ||
|
|
14dea01053 | ||
|
|
bff9a244e7 | ||
|
|
fc00bf9114 | ||
|
|
817ce075d4 | ||
|
|
4b7e5799bf | ||
|
|
12c14e3c02 | ||
|
|
447c3ddab1 | ||
|
|
6869a15bb8 | ||
|
|
0d15af736d | ||
|
|
f834ae5773 | ||
|
|
7be77da59f | ||
|
|
8d605ab2cd | ||
|
|
a992d97796 | ||
|
|
b0f1497bc8 | ||
|
|
fd7f7ddfe0 | ||
|
|
7429a309c3 | ||
|
|
682f9613ec | ||
|
|
2033cf99d4 | ||
|
|
f9923b022e | ||
|
|
28a7cbe94e | ||
|
|
cc4ccc7d81 | ||
|
|
c62b3df33a | ||
|
|
8d2f487607 | ||
|
|
c7185a128e | ||
|
|
8abeefcb5a | ||
|
|
06ba9c2bb1 | ||
|
|
479da29bc4 | ||
|
|
f018f33f69 | ||
|
|
e8a13ba33c | ||
|
|
8d2a697f8b | ||
|
|
32268a18b2 | ||
|
|
362cc7212c | ||
|
|
275e5a2ae0 | ||
|
|
f102f02cb9 | ||
|
|
43575f9042 | ||
|
|
fd7562966a | ||
|
|
8f9eafe561 | ||
|
|
d3fa591bef | ||
|
|
254adfa12a | ||
|
|
dc1cb4b57d | ||
|
|
baa7e4c064 | ||
|
|
1d6acceafc | ||
|
|
9013f57d02 | ||
|
|
199a65e2a5 | ||
|
|
3152c744a0 | ||
|
|
ba86092cc8 | ||
|
|
50c19c7999 | ||
|
|
05e55b3326 | ||
|
|
74ebf6465d | ||
|
|
87ebc22d17 | ||
|
|
496d4e5745 | ||
|
|
006c1f994c | ||
|
|
15b6c90b11 | ||
|
|
e52299fc08 | ||
|
|
e7247d2ee1 | ||
|
|
15388e5806 | ||
|
|
85d427f9b2 | ||
|
|
2f8fed0600 | ||
|
|
264c47fefd | ||
|
|
fdf9af09cd | ||
|
|
528d63d7dd | ||
|
|
23a18b0a20 | ||
|
|
e7174770f3 | ||
|
|
c12f30cbbf | ||
|
|
da74e8dc78 | ||
|
|
b1b08072e7 | ||
|
|
b961cf94ef | ||
|
|
dce9890bfa | ||
|
|
98870ccfb3 | ||
|
|
1f010078d3 | ||
|
|
80cde5aaee | ||
|
|
0ea0a530f6 | ||
|
|
7047f55cf7 | ||
|
|
d859e969d0 | ||
|
|
f970276b91 | ||
|
|
4964dcf7f3 | ||
|
|
86fa07d18c | ||
|
|
59f53b5524 | ||
|
|
e5265b3694 | ||
|
|
3e204d3355 | ||
|
|
1638c9f631 | ||
|
|
013fb3dbc2 | ||
|
|
9a59911156 | ||
|
|
e81bcfa269 | ||
|
|
f82b7cf739 | ||
|
|
a41e76b1bf | ||
|
|
7c55a6043d | ||
|
|
4ffcd2628e | ||
|
|
e172386850 | ||
|
|
e70433a61c | ||
|
|
f9f305b213 | ||
|
|
7f123de148 | ||
|
|
a362ab21ae | ||
|
|
15c540bd25 | ||
|
|
f2da15784b | ||
|
|
62e7fa8be1 | ||
| 307ee3c6a9 | |||
| 0cce4fc721 | |||
| ecdf8917d6 | |||
| 77462263e1 | |||
| 4a2f094a40 | |||
| 4156bc4413 | |||
| f98ff69a9b | |||
| eea150373e | |||
| 5dcde0a3ef | |||
| d29cbb8450 | |||
| dea15191d8 | |||
| e9a307a20d | |||
| e8b5bb3a40 | |||
|
|
3846af527a | ||
|
|
59751349b7 | ||
|
|
bc93474f49 | ||
|
|
62ac0162eb | ||
|
|
46f6765087 | ||
|
|
e2f98520e2 | ||
|
|
94cbe4e7fb |
66
THEME.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Theme parameters (Web)
|
||||
|
||||
Calendarr's web client lets you customise the colour theme under
|
||||
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
|
||||
it across your devices or keep it device-local — see
|
||||
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
|
||||
|
||||
You can also **export** the current theme to a `<date>_<time>.theme` file and
|
||||
**import** one later. A `.theme` file is plain JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"_format": "calendarr-theme",
|
||||
"_version": 1,
|
||||
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
|
||||
"exported_at": "2026-07-20T14:33:00.000Z",
|
||||
"settings": {
|
||||
"primary_color": "#4285F4",
|
||||
"hover_highlight_color": "#2A2A38",
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A theme may be **partial** — delete any keys you don't want and only the
|
||||
remaining ones are applied. Importing writes **only the parameters present in
|
||||
the file** (keys whose sync toggle is on are also pushed to the server; the rest
|
||||
update this browser only); omitted parameters are left untouched. If the file
|
||||
contains parameters this version doesn't recognise, you're asked whether to
|
||||
import the rest anyway.
|
||||
|
||||
## Colour parameters
|
||||
|
||||
Source of truth for the defaults: `DEFAULT_COLORS` in
|
||||
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
|
||||
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
|
||||
untouched reproduces the previous automatic look; setting it overrides that.
|
||||
|
||||
| Key | What it colours | Default |
|
||||
|---|---|---|
|
||||
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#58B900` |
|
||||
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
|
||||
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
|
||||
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
|
||||
| `bg_color` | App background | `#000000` |
|
||||
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#2B2B2B` |
|
||||
| `line_color` | Borders and grid lines | `#3B3B3D` |
|
||||
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
|
||||
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
|
||||
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
|
||||
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
|
||||
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
|
||||
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
|
||||
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
|
||||
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
|
||||
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#477650` |
|
||||
|
||||
Notes:
|
||||
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
|
||||
the chosen colour (so content stays readable). The colour you pick is the base
|
||||
hue; the swatch shows the full colour.
|
||||
- `day_bg_color` defaults to the app background. If you set a custom
|
||||
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
|
||||
- The other clients (iOS / Android) currently ignore the fine-grained element
|
||||
colours; they are stored and synced by the server but only the web client
|
||||
renders them.
|
||||
63
backend/SETTINGS_SYNC.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Settings sync contract (Web / iOS / Android)
|
||||
|
||||
> For a human-facing description of each theme colour parameter (and the
|
||||
> `.theme` import/export format), see [../THEME.md](../THEME.md).
|
||||
|
||||
Per-setting, cross-device synchronisation of user settings. The **server is the
|
||||
sole authority** for *which* settings sync; clients must not duplicate that logic.
|
||||
This document is the shared contract all three clients implement identically.
|
||||
|
||||
## Canonical keys & default flags
|
||||
|
||||
Source of truth: `DEFAULT_SYNC` in `backend/routers/settings_router.py`. Keys use
|
||||
the server's snake_case field names.
|
||||
|
||||
| Key | Kind | Default sync |
|
||||
|---|---|---|
|
||||
| `default_view` | enum | ON |
|
||||
| `week_start_day` | enum | ON |
|
||||
| `dim_past_events` | bool | ON |
|
||||
| `hour_height` | enum(int) | ON |
|
||||
| `primary_color` `accent_color` `today_color` `text_color` `line_color` `bg_color` `month_divider_color` `month_label_color` | color hex | ON |
|
||||
| `default_event_duration_minutes` | enum(int) | ON |
|
||||
| `default_reminder_minutes` | enum(int, null=off) | ON |
|
||||
| `language` | enum | **OFF** |
|
||||
| `share_calendar_icon` | icon key | **OFF** |
|
||||
| `cache_months` | enum(int) | **OFF** |
|
||||
| `month_view_paged` | bool | **OFF** |
|
||||
|
||||
Settings **not** in this list are never synced by this mechanism:
|
||||
- Account-wide settings (`private_event_visibility`, `group_visible_calendar_id`,
|
||||
`directory_hidden`) — one value per account, always identical everywhere; they
|
||||
keep their existing dedicated endpoints/UI, not a sync toggle.
|
||||
- Platform-exclusive device prefs (e.g. iOS `liquid_glass`) — stay device-local.
|
||||
- Identity/security, calendar/account management, admin.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/settings/` returns every value **plus** `sync_flags`: a fully-resolved
|
||||
`{key: bool}` map covering exactly the keys above (stored overrides on top of
|
||||
`DEFAULT_SYNC`). Clients read this map verbatim — no client-side defaults.
|
||||
- `PUT /api/settings/` accepts a partial `sync_flags` map (merged account-wide,
|
||||
unknown keys ignored, untouched flags preserved) and partial value fields
|
||||
(`exclude_unset`; `text_color`/`line_color`/`bg_color`/… treated as
|
||||
nullable-reset per `NULLABLE_OVERRIDES`).
|
||||
|
||||
## Client rules
|
||||
|
||||
Each client keeps a **local copy** of every syncable value (UserDefaults /
|
||||
DataStore-SharedPreferences / localStorage) so that "not synced" works per device.
|
||||
|
||||
1. **On login / launch / foreground:** `GET /api/settings/` → values + `sync_flags`.
|
||||
2. **Pull:** for each syncable key, if `sync_flags[key]` is ON, adopt the server
|
||||
value into the local copy; if OFF, keep the local value.
|
||||
3. **Push (debounced, read-modify-write):** start from the current server snapshot,
|
||||
overwrite only keys whose flag is ON with the local value, `PUT`. Never push a
|
||||
key whose flag is OFF.
|
||||
4. **Toggle a flag ON:** set the flag true **and** push this device's current local
|
||||
value (it becomes the shared value). **OFF:** set false; keep the local value.
|
||||
5. **Global "share everything":** set all syncable flags true and push all local
|
||||
values. Global off: set all false.
|
||||
|
||||
The flag map itself is always account-wide and always fetched fresh; it is what a
|
||||
client consults to decide what to send/receive.
|
||||
63
backend/dav_util.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Shared helpers for CalDAV publishing of local calendars.
|
||||
|
||||
Publishing is opt-in per calendar: a published calendar gets a secret
|
||||
``dav_token`` and is reachable as a two-way CalDAV collection at
|
||||
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
|
||||
changes; each event carries an ``etag`` that changes on write. Rotating the
|
||||
token revokes existing subscriptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""A URL-safe, unguessable token used as the CalDAV collection path."""
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def new_tag() -> str:
|
||||
"""A fresh ctag/etag value."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def bump_dav(cal, event=None) -> None:
|
||||
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
|
||||
|
||||
Safe to call unconditionally on every local-event write — it only refreshes
|
||||
opaque change tags, so unpublished calendars are unaffected.
|
||||
"""
|
||||
if cal is not None:
|
||||
cal.dav_ctag = new_tag()
|
||||
if event is not None:
|
||||
event.etag = new_tag()
|
||||
|
||||
|
||||
def public_base(request) -> str:
|
||||
"""Public origin (scheme://host) as clients actually reach us.
|
||||
|
||||
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
|
||||
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
|
||||
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
|
||||
ones.
|
||||
"""
|
||||
env = os.environ.get("PUBLIC_BASE_URL")
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
h = request.headers
|
||||
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
|
||||
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def caldav_url(request, token: str) -> str:
|
||||
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
|
||||
return f"{public_base(request)}/dav/{token}/"
|
||||
|
||||
|
||||
def caldav_login_url(request) -> str:
|
||||
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
|
||||
return f"{public_base(request)}/caldav/"
|
||||
205
backend/ical_io.py
Normal 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")
|
||||
240
backend/local_events_util.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Shared builders for local-event API dicts.
|
||||
|
||||
Every local event returned by the API (the local router, the unified event
|
||||
merge in caldav_router, and the group combined view) must look identical and
|
||||
carry the additive collaboration fields: ``creator``, ``private``, ``type``,
|
||||
and — in the group view — ``owner`` and ``is_group_event``.
|
||||
|
||||
Centralising this avoids the three near-duplicate dict constructions that used
|
||||
to live in caldav_router.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]:
|
||||
"""Build the ``creator`` payload for an event.
|
||||
|
||||
Returns ``{"id": int, "display_name": username}`` for a local creator,
|
||||
``{"id": None, "display_name": "<name> (importiert)"}`` for an imported
|
||||
event, or ``None`` when no creator info exists (legacy events).
|
||||
|
||||
``name_cache`` maps user_id -> username to avoid per-event DB lookups; the
|
||||
creator relationship is used as a fallback.
|
||||
"""
|
||||
if ev.creator_id:
|
||||
display = None
|
||||
if name_cache is not None:
|
||||
display = name_cache.get(ev.creator_id)
|
||||
if display is None and ev.creator is not None:
|
||||
display = ev.creator.display_name or ev.creator.username
|
||||
if display is not None:
|
||||
return {"id": ev.creator_id, "display_name": display}
|
||||
if ev.creator_name_external:
|
||||
return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
|
||||
return None
|
||||
|
||||
|
||||
def private_visibility_for(db: Session, user_id: int) -> str:
|
||||
"""A user's chosen visibility for their private events ('hidden' | 'busy')."""
|
||||
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
|
||||
return (s.private_event_visibility if s else None) or "busy"
|
||||
|
||||
|
||||
# Only these fields survive 'busy' anonymisation — a whitelist, so no content
|
||||
# field (title/location/description/creator/calendar name/recurrence) can leak.
|
||||
_BUSY_KEEP = {
|
||||
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
|
||||
"source", "type", "owner", "is_group_event", "display_color", "read_only",
|
||||
}
|
||||
|
||||
|
||||
def mask_busy_event(event: dict) -> dict:
|
||||
"""Anonymise a private event for 'busy' visibility: keep only timing /
|
||||
identity / render fields, drop ALL content."""
|
||||
masked = {k: event[k] for k in _BUSY_KEEP if k in event}
|
||||
masked["title"] = "Beschäftigt"
|
||||
masked["location"] = ""
|
||||
masked["description"] = ""
|
||||
masked["calendar_name"] = ""
|
||||
masked["creator"] = None
|
||||
masked["color"] = None
|
||||
masked["rrule"] = None
|
||||
masked["exdate"] = None
|
||||
masked["private"] = True
|
||||
return masked
|
||||
|
||||
|
||||
def apply_event_privacy(
|
||||
event: dict, *, owner_id, is_private: bool, requester_id: int, visibility: str
|
||||
) -> Optional[dict]:
|
||||
"""Enforce another user's private-event visibility on a built event dict.
|
||||
|
||||
Returns the event unchanged for the requester's own events or non-private
|
||||
events, ``None`` when the owner chose 'hidden', or a busy-masked copy when
|
||||
the owner chose 'busy'. Used by BOTH the merge read and the combined view so
|
||||
the privacy rule can never drift between them.
|
||||
"""
|
||||
if not is_private or owner_id == requester_id:
|
||||
return event
|
||||
if visibility == "hidden":
|
||||
return None
|
||||
return mask_busy_event(event)
|
||||
|
||||
|
||||
def build_local_event_dict(
|
||||
ev: models.LocalEvent,
|
||||
cal: models.LocalCalendar,
|
||||
*,
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
all_day: Optional[bool] = None,
|
||||
rrule: Optional[str] = ...,
|
||||
creator: Optional[dict] = None,
|
||||
owner: Optional[dict] = None,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
) -> dict:
|
||||
"""Build the unified dict for a single local event (or occurrence).
|
||||
|
||||
``start``/``end``/``all_day`` override the stored values (used when emitting
|
||||
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
|
||||
by the group combined view. ``read_only`` marks events the requester may not
|
||||
edit (someone else's calendar), so clients can hide edit/delete.
|
||||
"""
|
||||
d = {
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": ev.start if start is None else start,
|
||||
"end": ev.end if end is None else end,
|
||||
"allDay": ev.all_day if all_day is None else all_day,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule if rrule is ... else rrule,
|
||||
"exdate": ev.exdate,
|
||||
"calendar_id": f"local-{cal.id}",
|
||||
"calendar_name": cal.name,
|
||||
"calendarColor": cal.color,
|
||||
"source": "local",
|
||||
"type": "local",
|
||||
"creator": creator,
|
||||
"private": bool(ev.is_private),
|
||||
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
|
||||
}
|
||||
# Birthday calendars: the server owns the presentation. It flags the event so
|
||||
# clients can show a cake icon, appends the age computed for THIS occurrence
|
||||
# (so it stays correct as years pass), and — when the calendar defines a
|
||||
# "notify N days before" — injects a reminder so the mobile schedulers fire
|
||||
# it. Web has no notification delivery, so the reminder is display-only there.
|
||||
if getattr(cal, "is_birthday", False):
|
||||
d["is_birthday"] = True
|
||||
display = ev.title
|
||||
if ev.birth_year:
|
||||
try:
|
||||
occ_year = int(str(d["start"])[:4])
|
||||
age = occ_year - int(ev.birth_year)
|
||||
if age >= 0:
|
||||
display = f"{ev.title} ({age})"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
d["display_title"] = display
|
||||
if not d["reminders"] and cal.birthday_notify_days_before is not None:
|
||||
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
|
||||
if owner is not None:
|
||||
d["owner"] = owner
|
||||
if is_group_event:
|
||||
d["is_group_event"] = True
|
||||
if read_only:
|
||||
d["read_only"] = True
|
||||
return d
|
||||
|
||||
|
||||
def expand_recurring_local(
|
||||
ev: models.LocalEvent,
|
||||
local_cal: models.LocalCalendar,
|
||||
range_start,
|
||||
range_end,
|
||||
*,
|
||||
creator: Optional[dict] = None,
|
||||
owner: Optional[dict] = None,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
) -> list:
|
||||
"""Expand a recurring LocalEvent into individual occurrences in the range."""
|
||||
results = []
|
||||
excluded = set()
|
||||
if ev.exdate:
|
||||
for d in ev.exdate.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
excluded.add(d)
|
||||
try:
|
||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||
|
||||
if ev.all_day:
|
||||
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
||||
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
||||
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
||||
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_start = occ.date()
|
||||
occ_key = occ_start.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ_start + duration
|
||||
results.append(build_local_event_dict(
|
||||
ev, local_cal,
|
||||
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
|
||||
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
||||
if ev_start.tzinfo is None:
|
||||
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
||||
if ev_end.tzinfo is None:
|
||||
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
||||
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
if r_start.tzinfo is None:
|
||||
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
||||
if r_end.tzinfo is None:
|
||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_key = occ.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ + duration
|
||||
results.append(build_local_event_dict(
|
||||
ev, local_cal,
|
||||
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
|
||||
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to a single event.
|
||||
results.append(build_local_event_dict(
|
||||
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
return results
|
||||
281
backend/main.py
@@ -4,15 +4,20 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
# How long the browser may keep static assets before revalidating.
|
||||
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
|
||||
NO_CACHE = "no-cache, no-store, must-revalidate"
|
||||
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
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 admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -97,6 +102,20 @@ def _migrate():
|
||||
except Exception:
|
||||
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:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
|
||||
conn.commit()
|
||||
@@ -109,19 +128,277 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Collaboration features (sharing, groups, creator, private) ──
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added private_event_visibility to user_settings")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added creator_id to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added creator_name_external to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added is_private to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added group_visible_calendar_id to user_settings")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added display_name to users")
|
||||
except Exception:
|
||||
pass
|
||||
# Backfill display_name from username for existing rows (only where empty).
|
||||
try:
|
||||
conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added icon to groups")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added color to group_members")
|
||||
except Exception:
|
||||
pass
|
||||
# Per-calendar reminder toggle (default ON = unchanged behaviour for existing data).
|
||||
for tbl in ("calendars", "local_calendars", "google_calendars",
|
||||
"homeassistant_calendars", "ical_subscriptions"):
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN reminders_enabled BOOLEAN DEFAULT 1"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added reminders_enabled to %s", tbl)
|
||||
except Exception:
|
||||
pass
|
||||
# Synced default duration (minutes) for newly created events.
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added default_event_duration_minutes to user_settings")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN share_calendar_icon VARCHAR(16)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added share_calendar_icon to user_settings")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-setting cross-device sync: value columns for the two newly syncable
|
||||
# device-local prefs, plus the JSON map of which settings sync.
|
||||
for col, ddl in (
|
||||
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
|
||||
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
|
||||
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
|
||||
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to user_settings", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fine-grained web element theme colours (all optional overrides).
|
||||
for col in (
|
||||
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
|
||||
"day_hover_color", "day_selected_color",
|
||||
"day_bg_color", "today_bg_color",
|
||||
):
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to user_settings", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
|
||||
# from the sidebar, matching caldav/google/ha.
|
||||
for tbl in ("local_calendars", "ical_subscriptions"):
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added sidebar_hidden to %s", tbl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||
for col, ddl in (
|
||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
|
||||
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_calendars", col)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added etag to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added color to calendar_shares")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hide a user from sharing/group pickers (admin management still shows them).
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added directory_hidden to users")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Birthday calendars: flag + per-calendar "notify N days before".
|
||||
for col, ddl in (
|
||||
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
|
||||
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_calendars", col)
|
||||
except Exception:
|
||||
pass
|
||||
# Birthday events: stable external id (Contacts dedup) + birth year.
|
||||
for col, ddl in (
|
||||
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
|
||||
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_events", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# One-time cleanup of duplicate imported events sharing the same
|
||||
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
|
||||
# older build without idempotent upsert. Keep the earliest row.
|
||||
# Idempotent: after cleanup there is nothing left to delete.
|
||||
try:
|
||||
conn.execute(text(
|
||||
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
|
||||
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
|
||||
"GROUP BY calendar_id, external_uid)"
|
||||
))
|
||||
conn.commit()
|
||||
logging.info("Migration: de-duplicated local_events by external_uid")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hard guarantee: the DB itself forbids two events with the same
|
||||
# external_uid in one calendar (imported entries only; NULLs unconstrained).
|
||||
try:
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
|
||||
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
|
||||
))
|
||||
conn.commit()
|
||||
logging.info("Migration: unique index on (calendar_id, external_uid)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_cache_headers(request: Request, call_next):
|
||||
"""Force ≤ 2h browser cache for static assets and disable cache for the
|
||||
entry HTML / SW / version file. API responses are left alone (handlers
|
||||
decide their own caching)."""
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
|
||||
# Never cache: entry HTML, manifest, service worker, version marker
|
||||
if (
|
||||
path in ("/", "/index.html", "/manifest.json", "/sw.js")
|
||||
or path == "/static/js/version.js"
|
||||
):
|
||||
response.headers["Cache-Control"] = NO_CACHE
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
# JS/CSS must revalidate on every load so a deploy takes effect on the next
|
||||
# reload (returns a cheap 304 when unchanged). Without this, a fresh
|
||||
# no-cache index.html could pair with stale 2h-cached scripts.
|
||||
elif path.startswith("/static/js/") or path.startswith("/static/css/"):
|
||||
response.headers["Cache-Control"] = NO_CACHE
|
||||
# 2h cache for the rest of the frontend (icons, fonts, images, …)
|
||||
elif path.startswith("/static/") or path.startswith("/icons/"):
|
||||
response.headers["Cache-Control"] = STATIC_CACHE
|
||||
# SPA fallback (everything else that isn't an API route) returns HTML;
|
||||
# don't let the browser cache that either.
|
||||
elif not path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = NO_CACHE
|
||||
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
||||
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(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
||||
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
|
||||
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||
app.include_router(dav_router.router, tags=["dav"])
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
@@ -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 database import Base
|
||||
|
||||
@@ -7,13 +7,19 @@ class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# Login name: always lowercase, unique, used for authentication.
|
||||
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)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
avatar_filename = Column(String(255), nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
# When true, the user is hidden from sharing/group picker directories
|
||||
# (/users/directory). Admin user management (/users/) still shows them.
|
||||
directory_hidden = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
@@ -34,6 +40,11 @@ class User(Base):
|
||||
"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):
|
||||
__tablename__ = "caldav_accounts"
|
||||
@@ -63,6 +74,8 @@ class Calendar(Base):
|
||||
color = Column(String(7), nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
sidebar_hidden = Column(Boolean, default=False)
|
||||
# Whether events of this calendar generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
account = relationship("CalDAVAccount", back_populates="calendars")
|
||||
|
||||
@@ -84,10 +97,82 @@ class UserSettings(Base):
|
||||
language = Column(String(5), default="de")
|
||||
month_divider_color = Column(String(7), default="#7090c0")
|
||||
month_label_color = Column(String(7), default="#7090c0")
|
||||
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
|
||||
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
|
||||
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
||||
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
|
||||
# NULL = derive from bg_color. Device-local by default (not synced).
|
||||
surface_color = Column(String(7), nullable=True)
|
||||
# Fine-grained web element colours (all optional overrides; NULL = derive the
|
||||
# previous default). See frontend THEME.md for what each one paints.
|
||||
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
|
||||
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
|
||||
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
|
||||
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
|
||||
day_selected_color = Column(String(7), nullable=True) # selected day background
|
||||
day_bg_color = Column(String(7), nullable=True) # normal day background
|
||||
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
|
||||
# How this user's private events appear to other group members:
|
||||
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
||||
private_event_visibility = Column(String(10), default="busy")
|
||||
# The single local calendar this user shares into all their groups
|
||||
# (combined view shows only this calendar per member). NULL = share nothing.
|
||||
group_visible_calendar_id = Column(Integer, nullable=True)
|
||||
# Default reminder in minutes-before-start applied to all events client-side
|
||||
# (0 = at start time). NULL = no default reminder.
|
||||
default_reminder_minutes = Column(Integer, nullable=True)
|
||||
# Default duration (in minutes) applied to a newly created event's end time.
|
||||
default_event_duration_minutes = Column(Integer, default=60)
|
||||
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
|
||||
share_calendar_icon = Column(String(16), nullable=True)
|
||||
# How many months around the visible range clients preload/cache. Device-local
|
||||
# by default (only shared when its sync flag is on).
|
||||
cache_months = Column(Integer, default=3)
|
||||
# Whether the month view uses horizontal paging (swipe) instead of a vertical
|
||||
# scroll feed. Device-local by default (only shared when its sync flag is on).
|
||||
month_view_paged = Column(Boolean, default=False)
|
||||
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
|
||||
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
|
||||
# the single authority for which settings each client sends/fetches.
|
||||
sync_flags = Column(Text, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="settings")
|
||||
|
||||
|
||||
class InstanceSettings(Base):
|
||||
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
|
||||
Applies to everyone; a user's own settings still override the default theme."""
|
||||
__tablename__ = "instance_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True) # always 1
|
||||
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
|
||||
# client's built-in defaults. A user's own colour wins over this.
|
||||
default_theme = Column(Text, nullable=True)
|
||||
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
|
||||
logo_filename = Column(String(255), nullable=True)
|
||||
favicon_filename = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
class AppPassword(Base):
|
||||
"""Per-device app-specific password for CalDAV (Basic Auth).
|
||||
|
||||
Keeps MFA intact: accounts with 2FA can't use their normal password over
|
||||
CalDAV (clients can't send a TOTP code), so they authenticate with one of
|
||||
these revocable app passwords instead. Only the bcrypt hash is stored.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_passwords"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
label = Column(String(100), nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(String(50), nullable=True)
|
||||
last_used_at = Column(String(50), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class LocalCalendar(Base):
|
||||
__tablename__ = "local_calendars"
|
||||
|
||||
@@ -96,6 +181,22 @@ class LocalCalendar(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(7), default="#34a853")
|
||||
enabled = Column(Boolean, default=True)
|
||||
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
|
||||
sidebar_hidden = Column(Boolean, default=False, nullable=False)
|
||||
# Whether events of this calendar generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
||||
# collection reachable via a secret token URL. dav_ctag changes on every
|
||||
# event write so clients detect changes; rotating the token revokes access.
|
||||
caldav_published = Column(Boolean, default=False, nullable=False)
|
||||
dav_token = Column(String(64), nullable=True, unique=True)
|
||||
dav_ctag = Column(String(32), nullable=True)
|
||||
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
|
||||
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
|
||||
is_birthday = Column(Boolean, default=False, nullable=False)
|
||||
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
|
||||
# Injected as an event reminder on read so the mobile schedulers fire it.
|
||||
birthday_notify_days_before = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="local_calendars")
|
||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
@@ -116,8 +217,24 @@ class LocalEvent(Base):
|
||||
color = Column(String(7), nullable=True)
|
||||
rrule = Column(Text, nullable=True)
|
||||
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
|
||||
# Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start).
|
||||
reminders = Column(Text, nullable=True)
|
||||
# Creator: set server-side from the auth token on create, never from the client.
|
||||
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
# For imported events without a local user (from the .ics ORGANIZER field).
|
||||
creator_name_external = Column(Text, nullable=True)
|
||||
# Private events are filtered for other group members per their visibility setting.
|
||||
is_private = Column(Boolean, default=False)
|
||||
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
|
||||
etag = Column(String(32), nullable=True)
|
||||
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
|
||||
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
|
||||
external_uid = Column(String(255), nullable=True, index=True)
|
||||
# Birth year for birthday events; NULL = year unknown (no age shown).
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
|
||||
calendar = relationship("LocalCalendar", back_populates="events")
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class ICalSubscription(Base):
|
||||
@@ -129,6 +246,10 @@ class ICalSubscription(Base):
|
||||
url = Column(String(1000), nullable=False)
|
||||
color = Column(String(7), default="#46bdc6")
|
||||
enabled = Column(Boolean, default=True)
|
||||
# Hidden from the sidebar calendar list (still subscribed; just not shown).
|
||||
sidebar_hidden = Column(Boolean, default=False, nullable=False)
|
||||
# Whether events of this subscription generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
refresh_minutes = Column(Integer, default=60)
|
||||
last_fetched = Column(DateTime, nullable=True)
|
||||
cached_ics = Column(Text, nullable=True)
|
||||
@@ -181,6 +302,8 @@ class GoogleCalendar(Base):
|
||||
color = Column(String(7), nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
sidebar_hidden = Column(Boolean, default=False)
|
||||
# Whether events of this calendar generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
account = relationship("GoogleAccount", back_populates="calendars")
|
||||
|
||||
@@ -214,5 +337,116 @@ class HomeAssistantCalendar(Base):
|
||||
color = Column(String(7), nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
sidebar_hidden = Column(Boolean, default=False)
|
||||
# Whether events of this calendar generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
account = relationship("HomeAssistantAccount", back_populates="calendars")
|
||||
|
||||
|
||||
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
||||
|
||||
|
||||
class CalendarShare(Base):
|
||||
"""A local calendar shared with another Calendarr user."""
|
||||
|
||||
__tablename__ = "calendar_shares"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
permission = Column(String(20), default="read") # 'read' | 'read_write'
|
||||
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
calendar = relationship("LocalCalendar")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class CalendarColorPref(Base):
|
||||
"""A user's personal colour for a calendar they don't own — works for any
|
||||
way a foreign calendar becomes visible (direct share, group calendar, or a
|
||||
co-member's group-visible calendar). NULL/absent = the owner's colour."""
|
||||
|
||||
__tablename__ = "calendar_color_prefs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_pref"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
color = Column(String(16), nullable=False)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
icon = Column(String(16), nullable=True) # emoji shown for the group
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
members = relationship(
|
||||
"GroupMember", back_populates="group", cascade="all, delete-orphan"
|
||||
)
|
||||
group_calendar = relationship(
|
||||
"GroupCalendar", back_populates="group", uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class GroupMember(Base):
|
||||
__tablename__ = "group_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
role = Column(String(10), default="member") # 'owner' | 'member'
|
||||
color = Column(String(7), nullable=True) # this member's colour within the group
|
||||
joined_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
group = relationship("Group", back_populates="members")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class BirthdaySyncDevice(Base):
|
||||
"""A device that has synced Contacts birthdays into the user's birthday
|
||||
calendar. Powers the web "birthdays come from these devices" list. One row
|
||||
per (user, device); the client sends a stable device_id + human name."""
|
||||
|
||||
__tablename__ = "birthday_sync_devices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
device_id = Column(String(64), nullable=False)
|
||||
device_name = Column(String(120), nullable=False)
|
||||
last_sync = Column(String(50), nullable=True) # ISO 8601
|
||||
count = Column(Integer, default=0)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class GroupCalendar(Base):
|
||||
"""1:1 link between a group and its shared local calendar."""
|
||||
|
||||
__tablename__ = "group_calendars"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", name="uq_group_calendar_group"),
|
||||
UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
|
||||
group = relationship("Group", back_populates="group_calendar")
|
||||
calendar = relationship("LocalCalendar")
|
||||
|
||||
176
backend/permissions.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Central access control for local calendars.
|
||||
|
||||
Local calendars are visible/writable to a user if any of the following holds:
|
||||
- the user owns the calendar (LocalCalendar.user_id),
|
||||
- the calendar is shared with the user (calendar_shares; write needs 'read_write'),
|
||||
- the calendar is a group calendar and the user is a member of that group
|
||||
(members get read & write).
|
||||
|
||||
These helpers replace the scattered owner-only filters so sharing and groups
|
||||
work consistently across every local-calendar endpoint and the event merge read.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
|
||||
|
||||
def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]:
|
||||
return (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool:
|
||||
gc = (
|
||||
db.query(models.GroupCalendar)
|
||||
.filter(models.GroupCalendar.calendar_id == calendar_id)
|
||||
.first()
|
||||
)
|
||||
if not gc:
|
||||
return False
|
||||
member = (
|
||||
db.query(models.GroupMember)
|
||||
.filter(
|
||||
models.GroupMember.group_id == gc.group_id,
|
||||
models.GroupMember.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return member is not None
|
||||
|
||||
|
||||
def accessible_local_calendar(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
calendar_id: int,
|
||||
*,
|
||||
require_write: bool = False,
|
||||
) -> models.LocalCalendar:
|
||||
"""Return the calendar if the user may access it, else raise 404/403.
|
||||
|
||||
404 when the calendar does not exist or is not visible to the user (so we
|
||||
don't leak existence). 403 when it is visible (read) but write is required.
|
||||
"""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.id == calendar_id)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
|
||||
if cal.user_id == user.id:
|
||||
return cal # owner: full access
|
||||
|
||||
if _is_group_calendar_member(db, calendar_id, user.id):
|
||||
return cal # group members get read & write
|
||||
|
||||
share = _share_for(db, calendar_id, user.id)
|
||||
if share is None:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if require_write and share.permission != "read_write":
|
||||
raise HTTPException(403, "You only have read access to this calendar")
|
||||
return cal
|
||||
|
||||
|
||||
def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
|
||||
"""Return the calendar only if the user owns it, else raise 404."""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == calendar_id,
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
return cal
|
||||
|
||||
|
||||
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
|
||||
"""Calendars that co-members of the user's groups share into the group.
|
||||
|
||||
Each user designates ONE of their own calendars via
|
||||
UserSettings.group_visible_calendar_id. This returns those calendars for
|
||||
every co-member of any group the user belongs to (excluding the user's own).
|
||||
The calendar must be owned by the designating member. Deduped (each calendar
|
||||
appears once even across multiple shared groups).
|
||||
"""
|
||||
my_group_ids = (
|
||||
db.query(models.GroupMember.group_id)
|
||||
.filter(models.GroupMember.user_id == user.id)
|
||||
)
|
||||
co_member_ids = (
|
||||
db.query(models.GroupMember.user_id)
|
||||
.filter(
|
||||
models.GroupMember.group_id.in_(my_group_ids),
|
||||
models.GroupMember.user_id != user.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.join(
|
||||
models.UserSettings,
|
||||
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
|
||||
)
|
||||
.filter(
|
||||
models.UserSettings.user_id.in_(co_member_ids),
|
||||
# the designating member must own the calendar they share
|
||||
models.LocalCalendar.user_id == models.UserSettings.user_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
"""All local calendar ids the user may read: own + shared + group + co-member group-visible."""
|
||||
ids: set[int] = set()
|
||||
|
||||
own = (
|
||||
db.query(models.LocalCalendar.id)
|
||||
.filter(models.LocalCalendar.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in own)
|
||||
|
||||
shared = (
|
||||
db.query(models.CalendarShare.calendar_id)
|
||||
.filter(models.CalendarShare.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in shared)
|
||||
|
||||
group_cals = (
|
||||
db.query(models.GroupCalendar.calendar_id)
|
||||
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
|
||||
.filter(models.GroupMember.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in group_cals)
|
||||
|
||||
# Calendars co-members share into shared groups (group-visible).
|
||||
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
||||
|
||||
return list(ids)
|
||||
|
||||
|
||||
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
|
||||
"""Map calendar_id -> the user's personal colour for calendars they don't
|
||||
own (any sharing path). Empty when the user set no overrides."""
|
||||
return {
|
||||
p.calendar_id: p.color
|
||||
for p in db.query(models.CalendarColorPref).filter(
|
||||
models.CalendarColorPref.user_id == user_id
|
||||
)
|
||||
}
|
||||
194
backend/routers/admin_router.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Instance-wide (singleton) settings: an admin-defined default theme plus a
|
||||
custom logo and favicon. The GET endpoint is public (needed on the login screen,
|
||||
before auth); all writes require admin. Branding files are stored under
|
||||
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_admin
|
||||
from database import DATA_DIR, get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BRANDING_DIR = DATA_DIR / "branding"
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
# Colour keys an admin may set as the instance default theme. Keep in sync with
|
||||
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
|
||||
THEME_COLOR_KEYS = {
|
||||
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
|
||||
"line_color", "surface_color", "month_divider_color", "month_label_color",
|
||||
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
|
||||
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
|
||||
}
|
||||
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> models.InstanceSettings:
|
||||
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
|
||||
if not inst:
|
||||
inst = models.InstanceSettings(id=1)
|
||||
db.add(inst)
|
||||
db.commit()
|
||||
db.refresh(inst)
|
||||
return inst
|
||||
|
||||
|
||||
def _mtime(filename: Optional[str]) -> int:
|
||||
if not filename:
|
||||
return 0
|
||||
p = BRANDING_DIR / filename
|
||||
try:
|
||||
return int(p.stat().st_mtime)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _public_dict(inst: models.InstanceSettings) -> dict:
|
||||
theme = {}
|
||||
if inst.default_theme:
|
||||
try:
|
||||
theme = json.loads(inst.default_theme) or {}
|
||||
except (ValueError, TypeError):
|
||||
theme = {}
|
||||
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
|
||||
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
|
||||
return {
|
||||
"default_theme": theme,
|
||||
"has_logo": has_logo,
|
||||
"has_favicon": has_favicon,
|
||||
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
|
||||
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
|
||||
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Public read ───────────────────────────────────────────
|
||||
@router.get("/")
|
||||
def get_instance(db: Session = Depends(get_db)):
|
||||
return _public_dict(_get_or_create(db))
|
||||
|
||||
|
||||
@router.get("/logo")
|
||||
def get_logo(db: Session = Depends(get_db)):
|
||||
inst = _get_or_create(db)
|
||||
if not inst.logo_filename:
|
||||
raise HTTPException(404, "No logo")
|
||||
path = BRANDING_DIR / inst.logo_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No logo")
|
||||
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@router.get("/favicon")
|
||||
def get_favicon(db: Session = Depends(get_db)):
|
||||
inst = _get_or_create(db)
|
||||
if not inst.favicon_filename:
|
||||
raise HTTPException(404, "No favicon")
|
||||
path = BRANDING_DIR / inst.favicon_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No favicon")
|
||||
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
# ── Admin writes ──────────────────────────────────────────
|
||||
class ThemeUpdate(BaseModel):
|
||||
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
|
||||
|
||||
|
||||
@router.put("/theme")
|
||||
def set_default_theme(
|
||||
data: ThemeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
# Keep only known colour keys with valid hex values.
|
||||
clean = {
|
||||
k: v.upper()
|
||||
for k, v in (data.default_theme or {}).items()
|
||||
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
|
||||
}
|
||||
inst = _get_or_create(db)
|
||||
inst.default_theme = json.dumps(clean) if clean else None
|
||||
db.commit()
|
||||
return {"ok": True, "default_theme": clean}
|
||||
|
||||
|
||||
async def _save_branding(file: UploadFile, kind: str) -> str:
|
||||
"""Validate + normalise an uploaded image and store it. Returns the filename."""
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_BRANDING_SIZE:
|
||||
raise HTTPException(400, "File too large (max 5 MB)")
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid image")
|
||||
if kind == "favicon":
|
||||
img = img.resize((128, 128), Image.LANCZOS)
|
||||
else: # logo: keep aspect ratio, cap the longest edge at 256px
|
||||
img.thumbnail((256, 256), Image.LANCZOS)
|
||||
filename = f"{kind}.png"
|
||||
img.save(str(BRANDING_DIR / filename), "PNG")
|
||||
return filename
|
||||
|
||||
|
||||
@router.post("/logo")
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
inst = _get_or_create(db)
|
||||
inst.logo_filename = await _save_branding(file, "logo")
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/logo")
|
||||
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
|
||||
inst = _get_or_create(db)
|
||||
if inst.logo_filename:
|
||||
p = BRANDING_DIR / inst.logo_filename
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
inst.logo_filename = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/favicon")
|
||||
async def upload_favicon(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
inst = _get_or_create(db)
|
||||
inst.favicon_filename = await _save_branding(file, "favicon")
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/favicon")
|
||||
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
|
||||
inst = _get_or_create(db)
|
||||
if inst.favicon_filename:
|
||||
p = BRANDING_DIR / inst.favicon_filename
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
inst.favicon_filename = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -32,7 +32,12 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
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")
|
||||
@@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
|
||||
raise HTTPException(400, "Setup already completed")
|
||||
user = models.User(
|
||||
username=req.username.lower(),
|
||||
display_name=req.username.strip(), # keep the original casing for display
|
||||
email=req.email,
|
||||
password_hash=get_password_hash(req.password),
|
||||
is_admin=True,
|
||||
@@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name or current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
|
||||
77
backend/routers/birthdays_router.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Birthday sync device tracking.
|
||||
|
||||
The iOS app reports, after each Contacts birthday sync, which device it was and
|
||||
how many birthdays it manages. The web shows this as a "birthdays come from
|
||||
these devices" list. Birthday events themselves are ordinary local events
|
||||
(see local_router); this router only tracks the sync sources.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SyncReport(BaseModel):
|
||||
device_id: str
|
||||
device_name: str
|
||||
count: int = 0
|
||||
|
||||
|
||||
@router.post("/sync-report")
|
||||
def report_sync(
|
||||
data: SyncReport,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
|
||||
row = (
|
||||
db.query(models.BirthdaySyncDevice)
|
||||
.filter(
|
||||
models.BirthdaySyncDevice.user_id == current_user.id,
|
||||
models.BirthdaySyncDevice.device_id == data.device_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
name = (data.device_name or "Gerät")[:120]
|
||||
if row is None:
|
||||
db.add(models.BirthdaySyncDevice(
|
||||
user_id=current_user.id, device_id=data.device_id,
|
||||
device_name=name, last_sync=now, count=data.count,
|
||||
))
|
||||
else:
|
||||
row.device_name = name
|
||||
row.last_sync = now
|
||||
row.count = data.count
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def list_devices(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
rows = (
|
||||
db.query(models.BirthdaySyncDevice)
|
||||
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
|
||||
.order_by(models.BirthdaySyncDevice.last_sync.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_id": r.device_id,
|
||||
"device_name": r.device_name,
|
||||
"last_sync": r.last_sync,
|
||||
"count": r.count,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -11,8 +11,16 @@ from sqlalchemy import or_
|
||||
|
||||
import caldav_client
|
||||
import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,6 +42,7 @@ class CalendarUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -76,107 +85,13 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||
"enabled": c.enabled,
|
||||
"cal_id": c.cal_id,
|
||||
"sidebar_hidden": bool(c.sidebar_hidden),
|
||||
"reminders_enabled": bool(c.reminders_enabled),
|
||||
}
|
||||
for c in a.calendars
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
||||
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
||||
results = []
|
||||
# Parse excluded dates
|
||||
excluded = set()
|
||||
if ev.exdate:
|
||||
for d in ev.exdate.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
excluded.add(d)
|
||||
try:
|
||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||
|
||||
if ev.all_day:
|
||||
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
||||
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
||||
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
||||
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_start = occ.date()
|
||||
occ_key = occ_start.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ_start + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ_start.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": True,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
||||
if ev_start.tzinfo is None:
|
||||
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
||||
if ev_end.tzinfo is None:
|
||||
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
||||
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
if r_start.tzinfo is None:
|
||||
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
||||
if r_end.tzinfo is None:
|
||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_key = occ.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": False,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to single event
|
||||
results.append({
|
||||
"id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
|
||||
"start": ev.start, "end": ev.end, "allDay": ev.all_day,
|
||||
"location": ev.location or "", "description": ev.description or "",
|
||||
"color": ev.color, "rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color, "source": "local",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
|
||||
parsed = urlparse(url)
|
||||
@@ -357,6 +272,8 @@ def update_calendar(
|
||||
calendar.name = data.name
|
||||
if data.sidebar_hidden is not None:
|
||||
calendar.sidebar_hidden = data.sidebar_hidden
|
||||
if data.reminders_enabled is not None:
|
||||
calendar.reminders_enabled = data.reminders_enabled
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -383,6 +300,7 @@ def get_events(
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
all_events = []
|
||||
sync_errors = []
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(
|
||||
@@ -416,17 +334,59 @@ def get_events(
|
||||
logger.error(
|
||||
"Error fetching calendar %s: %s", calendar.id, exc
|
||||
)
|
||||
sync_errors.append({
|
||||
"source": "caldav",
|
||||
"name": f"{account.username} – {calendar.name}",
|
||||
"calendar_id": calendar.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
# ── Local calendar events ─────────────────────────────
|
||||
# ── Local calendar events (own + shared + group calendars) ─────────────
|
||||
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
||||
local_calendars = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.id.in_(readable_ids),
|
||||
models.LocalCalendar.enabled == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
) if readable_ids else []
|
||||
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||
# A personal calendar shared WITH the current user is relabelled under the
|
||||
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and
|
||||
# flagged read_only unless the share grants write. Group calendars are
|
||||
# excluded — they keep their own name and stay writable for members.
|
||||
shares_by_cal = {
|
||||
s.calendar_id: s
|
||||
for s in db.query(models.CalendarShare).filter(
|
||||
models.CalendarShare.user_id == current_user.id
|
||||
)
|
||||
}
|
||||
group_cal_ids = {
|
||||
r[0] for r in db.query(models.GroupCalendar.calendar_id).filter(
|
||||
models.GroupCalendar.calendar_id.in_(readable_ids)
|
||||
)
|
||||
} if readable_ids else set()
|
||||
# Per-user colour overrides for calendars the user doesn't own (any share path).
|
||||
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||
vis_cache: dict = {}
|
||||
|
||||
def vis_for(uid: int) -> str:
|
||||
if uid not in vis_cache:
|
||||
vis_cache[uid] = private_visibility_for(db, uid)
|
||||
return vis_cache[uid]
|
||||
|
||||
for local_cal in local_calendars:
|
||||
# Decoration for a personal calendar shared with (not owned by) me.
|
||||
is_shared_personal = (
|
||||
local_cal.user_id != current_user.id
|
||||
and local_cal.id not in group_cal_ids
|
||||
)
|
||||
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
|
||||
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
|
||||
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
|
||||
shared_color = color_prefs.get(local_cal.id)
|
||||
local_events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
@@ -441,25 +401,34 @@ def get_events(
|
||||
.all()
|
||||
)
|
||||
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:
|
||||
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:
|
||||
all_events.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": None,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
|
||||
for b in built:
|
||||
if shared_owner_name:
|
||||
b["calendar_name"] = shared_owner_name
|
||||
if shared_read_only:
|
||||
b["read_only"] = True
|
||||
if shared_color:
|
||||
b["calendarColor"] = shared_color
|
||||
b = apply_event_privacy(
|
||||
b, owner_id=owner_id, is_private=is_priv,
|
||||
requester_id=current_user.id, visibility=visibility,
|
||||
)
|
||||
if b is not None:
|
||||
all_events.append(b)
|
||||
|
||||
# ── iCal subscription events ──────────────────────────
|
||||
ical_subs = (
|
||||
@@ -484,13 +453,18 @@ def get_events(
|
||||
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
google_errors = []
|
||||
for g_acc in google_accounts:
|
||||
try:
|
||||
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
||||
g_events, g_errors = get_google_events(g_acc, start_dt, end_dt, db)
|
||||
all_events.extend(g_events)
|
||||
sync_errors.extend(g_errors)
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
||||
google_errors.append({"email": g_acc.email})
|
||||
sync_errors.append({
|
||||
"source": "google",
|
||||
"name": g_acc.email,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
# ── Home Assistant events ─────────────────────────────
|
||||
from routers.homeassistant_router import get_ha_events
|
||||
@@ -501,11 +475,18 @@ def get_events(
|
||||
)
|
||||
for ha_acc in ha_accounts:
|
||||
try:
|
||||
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
|
||||
ha_events, ha_errors = get_ha_events(ha_acc, start_dt, end_dt, db)
|
||||
all_events.extend(ha_events)
|
||||
sync_errors.extend(ha_errors)
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||
sync_errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": ha_acc.name,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
return {"events": all_events, "errors": google_errors}
|
||||
return {"events": all_events, "errors": sync_errors}
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
|
||||
491
backend/routers/dav_router.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""Minimal two-way CalDAV server for published local calendars.
|
||||
|
||||
Two ways to reach a published local calendar:
|
||||
|
||||
1. Secret token URL (no login) — ``/dav/{token}/``.
|
||||
2. Username + password (HTTP Basic Auth) with principal discovery —
|
||||
``/caldav/`` advertises the user's calendar-home-set and lists every
|
||||
published calendar as ``/caldav/{id}/``. This is what account-based clients
|
||||
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
|
||||
|
||||
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
|
||||
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
|
||||
getetag per event), avoiding deletion tombstones.
|
||||
|
||||
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
|
||||
round-tripped (parse_ics ignores them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote, unquote
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import dav_util
|
||||
import ical_io
|
||||
import models
|
||||
from auth import verify_password
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# XML namespaces used across WebDAV / CalDAV.
|
||||
NS_DAV = "DAV:"
|
||||
NS_CAL = "urn:ietf:params:xml:ns:caldav"
|
||||
NS_CS = "http://calendarserver.org/ns/"
|
||||
NS_ICAL = "http://apple.com/ns/ical/"
|
||||
|
||||
_NS_DECL = (
|
||||
'xmlns:D="DAV:" '
|
||||
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
|
||||
'xmlns:CS="http://calendarserver.org/ns/" '
|
||||
'xmlns:ICAL="http://apple.com/ns/ical/"'
|
||||
)
|
||||
|
||||
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
|
||||
_MULTISTATUS_CT = "application/xml; charset=utf-8"
|
||||
_LOGIN_HREF = "/caldav/"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
|
||||
if not token:
|
||||
return None
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.dav_token == token,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
|
||||
"""Validate an HTTP Basic Authorization header against a Calendarr account.
|
||||
|
||||
Accepts an app-specific password (always) or the account password (only when
|
||||
MFA is off — otherwise the account password would bypass 2FA, which CalDAV
|
||||
clients can't satisfy).
|
||||
"""
|
||||
hdr = request.headers.get("Authorization", "")
|
||||
if not hdr.lower().startswith("basic "):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
username, sep, password = raw.partition(":")
|
||||
if not sep:
|
||||
return None
|
||||
# Login names are stored lowercase; match case-insensitively like the web login.
|
||||
user = (
|
||||
db.query(models.User)
|
||||
.filter(func.lower(models.User.username) == username.lower())
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# 1) App-specific passwords — always allowed, MFA-safe.
|
||||
for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all():
|
||||
try:
|
||||
if verify_password(password, ap.password_hash):
|
||||
ap.last_used_at = datetime.now(timezone.utc).isoformat()
|
||||
db.commit()
|
||||
return user
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2) Account password — only when 2FA is disabled.
|
||||
if not user.totp_enabled:
|
||||
try:
|
||||
if verify_password(password, user.password_hash):
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _unauthorized() -> Response:
|
||||
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
|
||||
|
||||
|
||||
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
|
||||
return (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _etag(ev: models.LocalEvent) -> str:
|
||||
return ev.etag or "0"
|
||||
|
||||
|
||||
def _resource_name(ev: models.LocalEvent) -> str:
|
||||
return f"{quote(ev.uid, safe='')}.ics"
|
||||
|
||||
|
||||
def _event_href(base: str, ev: models.LocalEvent) -> str:
|
||||
return f"{base}{_resource_name(ev)}"
|
||||
|
||||
|
||||
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
if owner:
|
||||
return {owner.id: (owner.display_name or owner.username)}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
|
||||
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
|
||||
|
||||
|
||||
# ── XML builders ──────────────────────────────────────────
|
||||
|
||||
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
|
||||
principal_href: str | None = None,
|
||||
home_href: str | None = None) -> str:
|
||||
principal_href = principal_href or base
|
||||
home_href = home_href or base
|
||||
return f""" <D:response>
|
||||
<D:href>{base}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
||||
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
|
||||
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
|
||||
<D:supported-report-set>
|
||||
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
|
||||
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
|
||||
</D:supported-report-set>
|
||||
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
|
||||
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
|
||||
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
|
||||
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
|
||||
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _principal_propstat(user: models.User) -> str:
|
||||
href = _LOGIN_HREF
|
||||
name = user.display_name or user.username
|
||||
return f""" <D:response>
|
||||
<D:href>{href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
|
||||
<D:displayname>{xml_escape(name)}</D:displayname>
|
||||
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
|
||||
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
|
||||
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
|
||||
ics: str | None = None) -> str:
|
||||
href = _event_href(base, ev)
|
||||
data = ""
|
||||
if with_data and ics is not None:
|
||||
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
|
||||
return f""" <D:response>
|
||||
<D:href>{href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
|
||||
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _multistatus(body: str) -> Response:
|
||||
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
|
||||
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
|
||||
|
||||
|
||||
# ── Method handlers (shared by token and Basic-Auth paths) ──
|
||||
|
||||
def _handle_options() -> Response:
|
||||
return Response(status_code=200, headers={
|
||||
"DAV": "1, 2, 3, calendar-access",
|
||||
"Allow": _ALLOW,
|
||||
})
|
||||
|
||||
|
||||
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
|
||||
name = resource.rsplit("/", 1)[-1]
|
||||
if name.endswith(".ics"):
|
||||
name = name[:-4]
|
||||
uid = unquote(name)
|
||||
return (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.uid == uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
|
||||
db: Session, *, principal_href: str | None = None,
|
||||
home_href: str | None = None) -> Response:
|
||||
# PROPFIND on a single event resource.
|
||||
if resource:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
return _multistatus(_event_propstat(base, ev))
|
||||
|
||||
# Collection: always include collection props; Depth:1 adds each event.
|
||||
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
|
||||
if depth != "0":
|
||||
for ev in _events(cal, db):
|
||||
parts.append(_event_propstat(base, ev))
|
||||
return _multistatus("\n".join(parts))
|
||||
|
||||
|
||||
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
|
||||
report_type = None
|
||||
hrefs: list[str] = []
|
||||
if body:
|
||||
try:
|
||||
root = ET.fromstring(body)
|
||||
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
|
||||
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
|
||||
except ET.ParseError:
|
||||
pass
|
||||
|
||||
if report_type == "calendar-multiget" and hrefs:
|
||||
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
|
||||
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
|
||||
else:
|
||||
# calendar-query (or unknown) → return the whole calendar.
|
||||
evs = _events(cal, db)
|
||||
|
||||
parts = []
|
||||
for ev in evs:
|
||||
ics = _build_ics(cal, [ev], db)
|
||||
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
|
||||
return _multistatus("\n".join(parts) if parts else "")
|
||||
|
||||
|
||||
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
|
||||
*, head: bool = False) -> Response:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
ics = _build_ics(cal, [ev], db)
|
||||
headers = {"ETag": f'"{_etag(ev)}"'}
|
||||
return Response(
|
||||
content=b"" if head else ics,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
|
||||
try:
|
||||
parsed = ical_io.parse_ics(body)
|
||||
except ValueError:
|
||||
return Response(status_code=400)
|
||||
items = parsed.get("events") or []
|
||||
if not items:
|
||||
return Response(status_code=400)
|
||||
item = items[0]
|
||||
|
||||
# Key by the VEVENT UID; fall back to the resource name.
|
||||
uid = item.get("uid")
|
||||
if not uid:
|
||||
name = resource.rsplit("/", 1)[-1]
|
||||
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
|
||||
|
||||
# Scope to THIS calendar — never touch another calendar's/user's event that
|
||||
# happens to share the UID (local_events.uid is globally unique).
|
||||
ev = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.uid == uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
created = ev is None
|
||||
if created:
|
||||
# If the UID already exists elsewhere, the global UNIQUE constraint would
|
||||
# 500 on commit — reject cleanly with 409 instead.
|
||||
if db.query(models.LocalEvent.id).filter(models.LocalEvent.uid == uid).first():
|
||||
return Response(status_code=409)
|
||||
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
|
||||
db.add(ev)
|
||||
ev.title = item.get("title") or "(ohne Titel)"
|
||||
ev.start = item["start"]
|
||||
ev.end = item["end"]
|
||||
ev.all_day = item.get("all_day", False)
|
||||
ev.location = item.get("location")
|
||||
ev.description = item.get("description")
|
||||
ev.rrule = item.get("rrule")
|
||||
ev.exdate = item.get("exdate")
|
||||
dav_util.bump_dav(cal, ev)
|
||||
db.commit()
|
||||
db.refresh(ev)
|
||||
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
|
||||
|
||||
|
||||
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
dav_util.bump_dav(cal)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
|
||||
resource: str, db: Session, *,
|
||||
principal_href: str | None = None,
|
||||
home_href: str | None = None) -> Response:
|
||||
"""Serve a single calendar collection; auth/ownership already checked."""
|
||||
method = request.method.upper()
|
||||
if method == "PROPFIND":
|
||||
depth = request.headers.get("Depth", "0")
|
||||
return _handle_propfind(cal, base, resource, depth, db,
|
||||
principal_href=principal_href, home_href=home_href)
|
||||
if method == "REPORT":
|
||||
return _handle_report(cal, base, await request.body(), db)
|
||||
if method in ("GET", "HEAD"):
|
||||
if not resource:
|
||||
ics = _build_ics(cal, _events(cal, db), db)
|
||||
return Response(content=ics, media_type="text/calendar; charset=utf-8")
|
||||
return _handle_get(cal, resource, db, head=(method == "HEAD"))
|
||||
if method == "PUT":
|
||||
return _handle_put(cal, resource, await request.body(), db)
|
||||
if method == "DELETE":
|
||||
if not resource:
|
||||
return Response(status_code=403) # don't delete the collection itself
|
||||
return _handle_delete(cal, resource, db)
|
||||
return Response(status_code=405, headers={"Allow": _ALLOW})
|
||||
|
||||
|
||||
# ── Token path (no login): /dav/{token}/… ─────────────────
|
||||
|
||||
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
cal = _resolve(token, db)
|
||||
if not cal:
|
||||
return Response(status_code=404)
|
||||
base = f"/dav/{token}/"
|
||||
return await _dispatch_collection(request, cal, base, resource, db)
|
||||
|
||||
|
||||
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
|
||||
|
||||
|
||||
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
|
||||
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_token(request, token, "", db)
|
||||
|
||||
|
||||
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
|
||||
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_token(request, token, resource, db)
|
||||
|
||||
|
||||
# ── Basic-Auth path (username/password): /caldav/… ────────
|
||||
|
||||
async def _dispatch_home(request: Request, db: Session) -> Response:
|
||||
"""Principal + calendar-home-set: lists the user's published calendars."""
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
user = _basic_auth_user(request, db)
|
||||
if not user:
|
||||
return _unauthorized()
|
||||
if request.method.upper() != "PROPFIND":
|
||||
return Response(status_code=405, headers={"Allow": _ALLOW})
|
||||
depth = request.headers.get("Depth", "0")
|
||||
parts = [_principal_propstat(user)]
|
||||
if depth != "0":
|
||||
for cal in _published_calendars(user, db):
|
||||
parts.append(_collection_propstat(
|
||||
cal, f"/caldav/{cal.id}/",
|
||||
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
|
||||
return _multistatus("\n".join(parts))
|
||||
|
||||
|
||||
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
user = _basic_auth_user(request, db)
|
||||
if not user:
|
||||
return _unauthorized()
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == cal_id,
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
return Response(status_code=404)
|
||||
base = f"/caldav/{cal.id}/"
|
||||
return await _dispatch_collection(request, cal, base, resource, db,
|
||||
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
|
||||
|
||||
|
||||
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
|
||||
async def wellknown_caldav(request: Request):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
# Point discovery at the principal/home collection.
|
||||
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
|
||||
|
||||
|
||||
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
|
||||
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_home(request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_home(request, db)
|
||||
|
||||
|
||||
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_auth_calendar(request, cal_id, "", db)
|
||||
|
||||
|
||||
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_auth_calendar(request, cal_id, resource, db)
|
||||
@@ -142,6 +142,7 @@ def _account_dict(a: models.GoogleAccount) -> dict:
|
||||
"color": c.color or "#4285f4",
|
||||
"enabled": c.enabled,
|
||||
"sidebar_hidden": bool(c.sidebar_hidden),
|
||||
"reminders_enabled": bool(c.reminders_enabled),
|
||||
}
|
||||
for c in a.calendars
|
||||
if not _is_system_calendar(c.cal_id)
|
||||
@@ -344,6 +345,7 @@ class GoogleCalendarUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.put("/calendars/{calendar_id}")
|
||||
@@ -372,21 +374,43 @@ def update_calendar(
|
||||
gcal.name = data.name
|
||||
if data.sidebar_hidden is not None:
|
||||
gcal.sidebar_hidden = data.sidebar_hidden
|
||||
if data.reminders_enabled is not None:
|
||||
gcal.reminders_enabled = data.reminders_enabled
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Events ───────────────────────────────────────────────
|
||||
|
||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
||||
"""Fetch events from all enabled Google calendars for an account."""
|
||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||
"""Fetch events from all enabled Google calendars for an account.
|
||||
|
||||
Returns (events, errors) — errors is a list of
|
||||
{"source": "google", "name": ..., "message": ...} dicts for any
|
||||
calendar that failed to sync. Never includes raw exception text.
|
||||
"""
|
||||
all_events = []
|
||||
errors = []
|
||||
try:
|
||||
token = _refresh_access_token(account, db)
|
||||
except Exception as exc:
|
||||
# A token failure aborts the whole account, but attribute it to each
|
||||
# enabled calendar so clients can pin the error to a specific calendar
|
||||
# (and preserve that calendar's cached events instead of wiping it).
|
||||
logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
|
||||
raise
|
||||
for gcal in account.calendars:
|
||||
if not gcal.enabled or gcal.sidebar_hidden:
|
||||
continue
|
||||
if _is_system_calendar(gcal.cal_id):
|
||||
continue
|
||||
errors.append({
|
||||
"source": "google",
|
||||
"name": f"{account.email} – {gcal.name}",
|
||||
"calendar_id": gcal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
|
||||
all_events = []
|
||||
for gcal in account.calendars:
|
||||
if not gcal.enabled or gcal.sidebar_hidden:
|
||||
continue
|
||||
@@ -405,8 +429,14 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
||||
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
|
||||
errors.append({
|
||||
"source": "google",
|
||||
"name": f"{account.email} – {gcal.name}",
|
||||
"calendar_id": gcal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
return all_events
|
||||
return all_events, errors
|
||||
|
||||
|
||||
class GoogleEventCreate(BaseModel):
|
||||
|
||||
460
backend/routers/groups_router.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""Groups: shared group calendar + combined member-calendar overlay view.
|
||||
|
||||
A group has members and exactly one group calendar (a local calendar owned by
|
||||
the creator, linked via group_calendars). Members get read/write on the group
|
||||
calendar (enforced by permissions.accessible_local_calendar). The combined view
|
||||
overlays every member's local calendars plus the group calendar, applying each
|
||||
member's private-event visibility setting.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
|
||||
# Distinct per-member colours (server-defined so every client shows the same).
|
||||
MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"]
|
||||
|
||||
|
||||
def _next_member_color(db: Session, group_id: int) -> str:
|
||||
n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count()
|
||||
return MEMBER_PALETTE[n % len(MEMBER_PALETTE)]
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
member_ids: List[int] = []
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]:
|
||||
return (
|
||||
db.query(models.GroupMember)
|
||||
.filter(
|
||||
models.GroupMember.group_id == group_id,
|
||||
models.GroupMember.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember:
|
||||
m = _membership(db, group.id, user.id)
|
||||
if not m:
|
||||
raise HTTPException(403, "You are not a member of this group")
|
||||
return m
|
||||
|
||||
|
||||
def _require_owner(db: Session, group: models.Group, user: models.User) -> None:
|
||||
m = _membership(db, group.id, user.id)
|
||||
if not m or m.role != "owner":
|
||||
raise HTTPException(403, "Only the group owner may do this")
|
||||
|
||||
|
||||
def _get_group_or_404(db: Session, group_id: int) -> models.Group:
|
||||
g = db.query(models.Group).filter(models.Group.id == group_id).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "Group not found")
|
||||
return g
|
||||
|
||||
|
||||
def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
|
||||
gc = (
|
||||
db.query(models.GroupCalendar)
|
||||
.filter(models.GroupCalendar.group_id == group_id)
|
||||
.first()
|
||||
)
|
||||
return gc.calendar_id if gc else None
|
||||
|
||||
|
||||
def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]:
|
||||
if calendar_id is None:
|
||||
return None
|
||||
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
|
||||
return cal.color if cal else None
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_group(
|
||||
data: GroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = models.Group(name=data.name, icon=(data.icon or None),
|
||||
created_by=current_user.id, created_at=_now_iso())
|
||||
db.add(group)
|
||||
db.flush()
|
||||
|
||||
# Creator is owner; add the requested members (deduped, excluding creator).
|
||||
# Each member gets a distinct colour from the palette by join order.
|
||||
db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner",
|
||||
color=MEMBER_PALETTE[0], joined_at=_now_iso()))
|
||||
seen = {current_user.id}
|
||||
idx = 1
|
||||
for uid in data.member_ids:
|
||||
if uid in seen:
|
||||
continue
|
||||
if not db.query(models.User).filter(models.User.id == uid).first():
|
||||
continue
|
||||
db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member",
|
||||
color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso()))
|
||||
seen.add(uid)
|
||||
idx += 1
|
||||
|
||||
# Auto-create the group calendar (a local calendar owned by the creator).
|
||||
cal = models.LocalCalendar(
|
||||
user_id=current_user.id,
|
||||
name=f"{data.name} (Gruppe)",
|
||||
color=PALETTE[group.id % len(PALETTE)],
|
||||
)
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id))
|
||||
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return _group_detail(db, group, current_user)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_groups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
memberships = (
|
||||
db.query(models.GroupMember)
|
||||
.filter(models.GroupMember.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for m in memberships:
|
||||
group = db.query(models.Group).filter(models.Group.id == m.group_id).first()
|
||||
if not group:
|
||||
continue
|
||||
member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count()
|
||||
gcal_id = _group_calendar_id(db, group.id)
|
||||
out.append({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"icon": group.icon,
|
||||
"role": m.role,
|
||||
"member_count": member_count,
|
||||
"group_calendar_id": gcal_id,
|
||||
"group_calendar_color": _group_calendar_color(db, gcal_id),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict:
|
||||
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all()
|
||||
member_dicts = []
|
||||
for i, m in enumerate(members):
|
||||
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||
# Whether this member actually shares a calendar into the group (owns a
|
||||
# calendar designated as their group_visible). Lets clients hide phantom
|
||||
# empty rows for members who share nothing.
|
||||
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
|
||||
shares_calendar = False
|
||||
if s and s.group_visible_calendar_id is not None:
|
||||
shares_calendar = db.query(models.LocalCalendar.id).filter(
|
||||
models.LocalCalendar.id == s.group_visible_calendar_id,
|
||||
models.LocalCalendar.user_id == m.user_id,
|
||||
).first() is not None
|
||||
member_dicts.append({
|
||||
"id": m.user_id,
|
||||
"display_name": (u.display_name or u.username) if u else None,
|
||||
"role": m.role,
|
||||
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
||||
"shares_calendar": shares_calendar,
|
||||
})
|
||||
gcal_id = _group_calendar_id(db, group.id)
|
||||
return {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"icon": group.icon,
|
||||
"created_by": group.created_by,
|
||||
"members": member_dicts,
|
||||
"group_calendar_id": gcal_id,
|
||||
"group_calendar_color": _group_calendar_color(db, gcal_id),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{group_id}")
|
||||
def update_group(
|
||||
group_id: int,
|
||||
data: GroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_owner(db, group, current_user)
|
||||
if data.name is not None and data.name.strip():
|
||||
group.name = data.name.strip()
|
||||
if data.icon is not None:
|
||||
group.icon = data.icon or None
|
||||
db.commit()
|
||||
return _group_detail(db, group, current_user)
|
||||
|
||||
|
||||
@router.get("/{group_id}")
|
||||
def get_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_member(db, group, current_user)
|
||||
return _group_detail(db, group, current_user)
|
||||
|
||||
|
||||
@router.post("/{group_id}/members")
|
||||
def add_member(
|
||||
group_id: int,
|
||||
data: MemberAdd,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_owner(db, group, current_user)
|
||||
if not db.query(models.User).filter(models.User.id == data.user_id).first():
|
||||
raise HTTPException(404, "User not found")
|
||||
if _membership(db, group_id, data.user_id):
|
||||
return {"ok": True} # already a member
|
||||
db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member",
|
||||
color=_next_member_color(db, group_id), joined_at=_now_iso()))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class MemberColorUpdate(BaseModel):
|
||||
color: str
|
||||
|
||||
|
||||
@router.put("/{group_id}/members/{user_id}/color")
|
||||
def set_member_color(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
data: MemberColorUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
# Owner may recolour anyone; a member may recolour themselves.
|
||||
if user_id != current_user.id:
|
||||
_require_owner(db, group, current_user)
|
||||
else:
|
||||
_require_member(db, group, current_user)
|
||||
m = _membership(db, group_id, user_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Member not found")
|
||||
m.color = data.color
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}")
|
||||
def remove_member(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
# Owner can remove anyone; a member may remove themselves (leave).
|
||||
if user_id != current_user.id:
|
||||
_require_owner(db, group, current_user)
|
||||
else:
|
||||
_require_member(db, group, current_user)
|
||||
target = _membership(db, group_id, user_id)
|
||||
if not target:
|
||||
raise HTTPException(404, "Member not found")
|
||||
if target.role == "owner":
|
||||
raise HTTPException(422, "The owner cannot be removed; delete the group instead")
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{group_id}")
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_owner(db, group, current_user)
|
||||
# Remove the group calendar (and its events) too.
|
||||
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
|
||||
if gc:
|
||||
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
|
||||
if cal:
|
||||
db.delete(cal) # cascades to events
|
||||
db.delete(group) # cascades to members + group_calendar link
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _first_name(name: Optional[str]) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return name.split(" ", 1)[0]
|
||||
|
||||
|
||||
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
|
||||
owner: Optional[dict], me_id: int) -> str:
|
||||
"""Server-side display title for the combined view. The former owner/creator
|
||||
first-name prefix ("Guido: …") was dropped: each member already has a
|
||||
distinct colour, so the prefix was redundant noise. We still return a
|
||||
non-empty `display_title` (== raw title) so the clients' legacy fallback —
|
||||
which rebuilds a prefix when `display_title` is empty — never kicks in.
|
||||
`display_color` continues to carry the per-person colour."""
|
||||
return title
|
||||
|
||||
|
||||
@router.get("/{group_id}/combined")
|
||||
def combined_events(
|
||||
group_id: int,
|
||||
start: str = Query(...),
|
||||
end: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_member(db, group, current_user)
|
||||
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid date format — use ISO 8601")
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
|
||||
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||
visibility_cache: dict[int, str] = {}
|
||||
|
||||
def visibility_for(user_id: int) -> str:
|
||||
if user_id not in visibility_cache:
|
||||
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
|
||||
visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy"
|
||||
return visibility_cache[user_id]
|
||||
|
||||
group_cal_id = _group_calendar_id(db, group_id)
|
||||
group_cal_color = _group_calendar_color(db, group_cal_id)
|
||||
# Server-defined colours so every client renders members/group consistently.
|
||||
member_color = {
|
||||
m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)])
|
||||
for i, m in enumerate(members)
|
||||
}
|
||||
all_events: list[dict] = []
|
||||
|
||||
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
|
||||
owner_user = name_cache.get(owner_id)
|
||||
owner = {"id": owner_id, "display_name": owner_user}
|
||||
# Editable by the requester iff it's the shared group calendar (all members
|
||||
# may write) or the requester's own calendar; everyone else's is read-only.
|
||||
read_only = not (is_group or owner_id == current_user.id)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
or_(
|
||||
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
|
||||
models.LocalEvent.rrule != None,
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for ev in events:
|
||||
creator_owner_id = ev.creator_id or owner_id
|
||||
# Private filtering for events that belong to someone else.
|
||||
if ev.is_private and creator_owner_id != current_user.id:
|
||||
vis = visibility_for(creator_owner_id)
|
||||
if vis == "hidden":
|
||||
continue
|
||||
creator = None
|
||||
if ev.creator_id and name_cache.get(ev.creator_id):
|
||||
creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]}
|
||||
elif ev.creator_name_external:
|
||||
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
|
||||
|
||||
if ev.rrule:
|
||||
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)
|
||||
else:
|
||||
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)]
|
||||
|
||||
for b in built:
|
||||
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
||||
b = mask_busy_event(b)
|
||||
# Colour to render with: the group calendar's colour for group
|
||||
# events, otherwise the owning member's group colour.
|
||||
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
||||
# Decorated title (group icon / owner name) computed server-side
|
||||
# so all clients render identically; raw `title` kept for editing.
|
||||
# A birthday event already carries an age display_title from
|
||||
# build_local_event_dict — keep it rather than clobber the age.
|
||||
if not b.get("is_birthday"):
|
||||
b["display_title"] = _decorate_title(
|
||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||
owner=owner, me_id=current_user.id,
|
||||
)
|
||||
all_events.append(b)
|
||||
|
||||
# Each member shares exactly one calendar into their groups, chosen in their
|
||||
# settings (group_visible_calendar_id). Only that calendar is overlaid.
|
||||
for m in members:
|
||||
settings = (
|
||||
db.query(models.UserSettings)
|
||||
.filter(models.UserSettings.user_id == m.user_id)
|
||||
.first()
|
||||
)
|
||||
visible_id = settings.group_visible_calendar_id if settings else None
|
||||
if visible_id is None or visible_id == group_cal_id:
|
||||
continue
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == visible_id,
|
||||
models.LocalCalendar.user_id == m.user_id, # must be the member's own
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if cal:
|
||||
emit_calendar(cal, m.user_id, is_group=False)
|
||||
|
||||
# The group calendar itself.
|
||||
if group_cal_id is not None:
|
||||
group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first()
|
||||
if group_cal:
|
||||
emit_calendar(group_cal, group_cal.user_id, is_group=True)
|
||||
|
||||
return {"events": all_events}
|
||||
@@ -313,13 +313,32 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
|
||||
}
|
||||
|
||||
|
||||
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
||||
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||
"""Fetch events from all enabled HA calendars for an account.
|
||||
|
||||
Returns (events, errors) — errors is a list of
|
||||
{"source": "homeassistant", "name": ..., "calendar_id": ..., "message": ...}
|
||||
dicts for any calendar that failed to sync. Never includes raw exception text.
|
||||
"""
|
||||
all_events = []
|
||||
errors = []
|
||||
try:
|
||||
token = _get_valid_token(account, db)
|
||||
except Exception as exc:
|
||||
# A token failure aborts the whole account, but attribute it to each
|
||||
# enabled calendar so clients can pin the error to a specific calendar
|
||||
# (and preserve that calendar's cached events instead of wiping it).
|
||||
logger.error("HA token error for %s: %s", account.name, exc)
|
||||
raise
|
||||
for cal in account.calendars:
|
||||
if not cal.enabled or cal.sidebar_hidden:
|
||||
continue
|
||||
errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": f"{account.name} – {cal.name}",
|
||||
"calendar_id": cal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
for cal in account.calendars:
|
||||
if not cal.enabled or cal.sidebar_hidden:
|
||||
continue
|
||||
@@ -330,7 +349,13 @@ def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_
|
||||
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
|
||||
except Exception as exc:
|
||||
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
|
||||
return all_events
|
||||
errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": f"{account.name} – {cal.name}",
|
||||
"calendar_id": cal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
|
||||
|
||||
# ── Serialization ─────────────────────────────────────────
|
||||
@@ -349,6 +374,7 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
|
||||
"color": c.color or HA_DEFAULT_COLOR,
|
||||
"enabled": c.enabled,
|
||||
"sidebar_hidden": bool(c.sidebar_hidden),
|
||||
"reminders_enabled": bool(c.reminders_enabled),
|
||||
}
|
||||
for c in a.calendars
|
||||
],
|
||||
@@ -375,6 +401,7 @@ class HACalendarUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────
|
||||
@@ -623,6 +650,8 @@ def update_calendar(
|
||||
cal.name = data.name
|
||||
if data.sidebar_hidden is not None:
|
||||
cal.sidebar_hidden = data.sidebar_hidden
|
||||
if data.reminders_enabled is not None:
|
||||
cal.reminders_enabled = data.reminders_enabled
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests as http_requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -28,7 +31,9 @@ class SubscriptionUpdate(BaseModel):
|
||||
url: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
refresh_minutes: Optional[int] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
def _sub_dict(sub: models.ICalSubscription) -> dict:
|
||||
@@ -38,17 +43,74 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
|
||||
"url": sub.url,
|
||||
"color": sub.color,
|
||||
"enabled": sub.enabled,
|
||||
"sidebar_hidden": bool(sub.sidebar_hidden),
|
||||
"reminders_enabled": bool(sub.reminders_enabled),
|
||||
"refresh_minutes": sub.refresh_minutes,
|
||||
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> str:
|
||||
"""Download .ics content from a URL."""
|
||||
_MAX_ICS_BYTES = 5 * 1024 * 1024
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def _host_is_public(host: str) -> bool:
|
||||
"""False if the host resolves to any private/loopback/link-local address."""
|
||||
try:
|
||||
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except ValueError:
|
||||
return False
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_public_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Nur http(s)-URLs sind erlaubt.")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("Ungültige URL.")
|
||||
if not _host_is_public(parsed.hostname):
|
||||
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> str:
|
||||
"""Download .ics content, blocking SSRF to internal/private hosts.
|
||||
|
||||
Redirects are followed manually so every hop is re-validated (a public URL
|
||||
can otherwise 30x-redirect into the internal network). Response size is
|
||||
capped. (Residual risk: DNS rebinding between check and connect.)
|
||||
"""
|
||||
if url.startswith("webcal://"):
|
||||
url = "https://" + url[len("webcal://"):]
|
||||
try:
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
_validate_public_url(url)
|
||||
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
|
||||
if resp.is_redirect and resp.headers.get("location"):
|
||||
url = urljoin(url, resp.headers["location"])
|
||||
resp.close()
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
resp.encoding = "utf-8"
|
||||
chunks, total = [], 0
|
||||
for chunk in resp.iter_content(8192, decode_unicode=True):
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > _MAX_ICS_BYTES:
|
||||
resp.close()
|
||||
raise ValueError("Datei zu groß (max. 5 MB).")
|
||||
return "".join(chunks)
|
||||
raise ValueError("Zu viele Weiterleitungen.")
|
||||
except http_requests.RequestException as e:
|
||||
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
||||
|
||||
@@ -213,8 +275,12 @@ def update_subscription(
|
||||
sub.color = data.color
|
||||
if data.enabled is not None:
|
||||
sub.enabled = data.enabled
|
||||
if data.sidebar_hidden is not None:
|
||||
sub.sidebar_hidden = data.sidebar_hidden
|
||||
if data.refresh_minutes is not None:
|
||||
sub.refresh_minutes = data.refresh_minutes
|
||||
if data.reminders_enabled is not None:
|
||||
sub.reminders_enabled = data.reminders_enabled
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
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, Request, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import dav_util
|
||||
import ical_io
|
||||
import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from local_events_util import build_local_event_dict, resolve_creator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class CalendarCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#34a853"
|
||||
is_birthday: bool = False
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class CalendarUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
caldav_published: Optional[bool] = None
|
||||
is_birthday: Optional[bool] = None
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -33,6 +50,10 @@ class EventCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
private: bool = False
|
||||
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
|
||||
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
|
||||
birth_year: Optional[int] = None # birthday events; NULL = year unknown
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -45,50 +66,147 @@ class EventUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
exdate: Optional[str] = None
|
||||
private: Optional[bool] = None
|
||||
reminders: Optional[List[int]] = None
|
||||
external_uid: Optional[str] = None
|
||||
birth_year: Optional[int] = None
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
||||
return {
|
||||
class ShareCreate(BaseModel):
|
||||
user_id: int
|
||||
permission: str = "read"
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
||||
color_override: Optional[str] = None,
|
||||
request: Optional[Request] = None) -> dict:
|
||||
d = {
|
||||
"id": cal.id,
|
||||
"name": cal.name,
|
||||
"color": cal.color,
|
||||
# A shared calendar is labelled by the person/group it comes from — the
|
||||
# owner's real calendar name must never reach recipients.
|
||||
"name": shared_by if (not owned and shared_by is not None) else cal.name,
|
||||
# A recipient's own colour for a shared calendar wins over the owner's.
|
||||
"color": color_override or cal.color,
|
||||
"enabled": cal.enabled,
|
||||
"sidebar_hidden": bool(cal.sidebar_hidden),
|
||||
"reminders_enabled": bool(cal.reminders_enabled),
|
||||
"caldav_published": bool(cal.caldav_published),
|
||||
"is_birthday": bool(cal.is_birthday),
|
||||
"birthday_notify_days_before": cal.birthday_notify_days_before,
|
||||
"type": "local",
|
||||
"owned": owned,
|
||||
}
|
||||
# Only the owner may publish; expose the subscribe URLs only when active.
|
||||
if owned and cal.caldav_published and cal.dav_token:
|
||||
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
|
||||
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
|
||||
if shared_by is not None:
|
||||
d["shared_by"] = shared_by
|
||||
if permission is not None:
|
||||
d["permission"] = permission
|
||||
return d
|
||||
|
||||
|
||||
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
|
||||
return {
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": ev.start,
|
||||
"end": ev.end,
|
||||
"allDay": ev.all_day,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"exdate": ev.exdate,
|
||||
"calendar_id": f"local-{cal.id}",
|
||||
"calendar_name": cal.name,
|
||||
"calendarColor": cal.color,
|
||||
"source": "local",
|
||||
}
|
||||
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
|
||||
return build_local_event_dict(ev, cal, creator=resolve_creator(ev))
|
||||
|
||||
|
||||
# ── Calendar CRUD ─────────────────────────────────────────
|
||||
|
||||
@router.get("/calendars")
|
||||
def list_calendars(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
|
||||
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||
|
||||
# Own calendars
|
||||
own = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
return [_cal_dict(c) for c in cals]
|
||||
result = []
|
||||
for c in own:
|
||||
d = _cal_dict(c, owned=True, request=request)
|
||||
if c.id in group_cal_map:
|
||||
d["group"] = True
|
||||
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
||||
result.append(d)
|
||||
|
||||
seen_ids = {c.id for c in own}
|
||||
|
||||
# Calendars shared with this user
|
||||
shares = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(models.CalendarShare.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
for share in shares:
|
||||
cal = share.calendar
|
||||
if cal is None or cal.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cal.id)
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
d = _cal_dict(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission=share.permission,
|
||||
color_override=color_prefs.get(cal.id),
|
||||
)
|
||||
if cal.id in group_cal_map:
|
||||
d["group"] = True
|
||||
result.append(d)
|
||||
|
||||
# Group calendars reached via membership (read_write) that aren't already
|
||||
# listed, so members can select/see the group calendar.
|
||||
for cal_id, group_name in group_cal_map.items():
|
||||
if cal_id in seen_ids:
|
||||
continue
|
||||
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first()
|
||||
if not cal:
|
||||
continue
|
||||
seen_ids.add(cal_id)
|
||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
|
||||
color_override=color_prefs.get(cal.id))
|
||||
d["group"] = True
|
||||
result.append(d)
|
||||
|
||||
# Calendars co-members share into shared groups (group_visible_calendar_id).
|
||||
# Read-only, shown under the owner's name. Deduped against everything above
|
||||
# so a calendar already shared directly / as a group calendar isn't doubled.
|
||||
for cal in permissions.co_member_group_visible_calendars(db, current_user):
|
||||
if cal.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cal.id)
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
d = _cal_dict(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission="read",
|
||||
color_override=color_prefs.get(cal.id),
|
||||
request=request,
|
||||
)
|
||||
d["group_shared"] = True
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/calendars")
|
||||
@@ -97,10 +215,27 @@ def create_calendar(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Only ONE birthday calendar per account — hard server guarantee. If the
|
||||
# user already has one, "create birthday calendar" is idempotent: return the
|
||||
# existing one instead of creating a second.
|
||||
if data.is_birthday:
|
||||
existing = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.is_birthday == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
return _cal_dict(existing)
|
||||
|
||||
cal = models.LocalCalendar(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
is_birthday=data.is_birthday,
|
||||
birthday_notify_days_before=data.birthday_notify_days_before,
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
@@ -112,6 +247,7 @@ def create_calendar(
|
||||
def update_calendar(
|
||||
calendar_id: int,
|
||||
data: CalendarUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
@@ -131,8 +267,112 @@ def update_calendar(
|
||||
cal.color = data.color
|
||||
if data.enabled is not None:
|
||||
cal.enabled = data.enabled
|
||||
if data.sidebar_hidden is not None:
|
||||
cal.sidebar_hidden = data.sidebar_hidden
|
||||
if data.reminders_enabled is not None:
|
||||
cal.reminders_enabled = data.reminders_enabled
|
||||
if data.is_birthday is not None:
|
||||
# Never let a second calendar be marked as the birthday calendar.
|
||||
if data.is_birthday and not cal.is_birthday:
|
||||
other = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.is_birthday == True,
|
||||
models.LocalCalendar.id != cal.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if other is not None:
|
||||
raise HTTPException(422, "A birthday calendar already exists")
|
||||
cal.is_birthday = data.is_birthday
|
||||
if data.birthday_notify_days_before is not None:
|
||||
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
|
||||
# send SQL NULL through an Optional that also means "unchanged").
|
||||
cal.birthday_notify_days_before = (
|
||||
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
|
||||
)
|
||||
if data.caldav_published is not None:
|
||||
cal.caldav_published = data.caldav_published
|
||||
if data.caldav_published:
|
||||
# First publish: mint a token + initial ctag so clients can sync.
|
||||
if not cal.dav_token:
|
||||
cal.dav_token = dav_util.new_token()
|
||||
if not cal.dav_ctag:
|
||||
cal.dav_ctag = dav_util.new_tag()
|
||||
else:
|
||||
# Unpublishing revokes access: drop the token so the URL 404s.
|
||||
cal.dav_token = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
db.refresh(cal)
|
||||
return _cal_dict(cal, owned=True, request=request)
|
||||
|
||||
|
||||
class CalendarColorUpdate(BaseModel):
|
||||
color: str
|
||||
|
||||
|
||||
@router.put("/calendars/{calendar_id}/color")
|
||||
def set_calendar_color(
|
||||
calendar_id: int,
|
||||
data: CalendarColorUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a calendar's colour. The owner changes the calendar's colour for
|
||||
everyone; anyone else who can see the calendar (direct share, group calendar,
|
||||
or a co-member's group-visible calendar) sets only their OWN per-user colour.
|
||||
Recipients may recolour but never rename a shared calendar."""
|
||||
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
|
||||
if cal is None:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if cal.user_id == current_user.id:
|
||||
cal.color = data.color
|
||||
else:
|
||||
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
|
||||
raise HTTPException(403, "You cannot access this calendar")
|
||||
pref = (
|
||||
db.query(models.CalendarColorPref)
|
||||
.filter(
|
||||
models.CalendarColorPref.calendar_id == calendar_id,
|
||||
models.CalendarColorPref.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pref:
|
||||
pref.color = data.color
|
||||
else:
|
||||
db.add(models.CalendarColorPref(
|
||||
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
|
||||
db.commit()
|
||||
return {"ok": True, "color": data.color}
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/dav-token/rotate")
|
||||
def rotate_dav_token(
|
||||
calendar_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if not cal.caldav_published:
|
||||
raise HTTPException(422, "Calendar is not published")
|
||||
cal.dav_token = dav_util.new_token()
|
||||
cal.dav_ctag = dav_util.new_tag()
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
return _cal_dict(cal, owned=True, request=request)
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}")
|
||||
@@ -164,33 +404,73 @@ def create_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == data.calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
# Owner, shared (read_write), or group-member calendars are writable.
|
||||
cal = permissions.accessible_local_calendar(
|
||||
db, current_user, data.calendar_id, require_write=True
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=str(uuid.uuid4()),
|
||||
title=data.title,
|
||||
start=data.start,
|
||||
end=data.end,
|
||||
all_day=data.allDay,
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
)
|
||||
db.add(ev)
|
||||
# Idempotent on external_uid: repeated syncs of the same contact (stable
|
||||
# "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row
|
||||
# with this external_uid already exists in this calendar, update it in place
|
||||
# instead of inserting a new one. Enforced server-side, so the client can
|
||||
# never duplicate — even without the reconcile read.
|
||||
existing = None
|
||||
if data.external_uid:
|
||||
existing = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.external_uid == data.external_uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
if existing is not None:
|
||||
ev = existing
|
||||
ev.title = data.title
|
||||
ev.start = data.start
|
||||
ev.end = data.end
|
||||
ev.all_day = data.allDay
|
||||
ev.location = data.location
|
||||
ev.description = data.description
|
||||
ev.color = data.color
|
||||
ev.rrule = data.rrule
|
||||
ev.is_private = data.private
|
||||
ev.reminders = reminders
|
||||
ev.birth_year = data.birth_year
|
||||
else:
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=str(uuid.uuid4()),
|
||||
title=data.title,
|
||||
start=data.start,
|
||||
end=data.end,
|
||||
all_day=data.allDay,
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
is_private=data.private,
|
||||
reminders=reminders,
|
||||
external_uid=data.external_uid,
|
||||
birth_year=data.birth_year,
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
dav_util.bump_dav(cal, ev)
|
||||
db.commit()
|
||||
db.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}")
|
||||
@@ -200,17 +480,9 @@ def update_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ev = (
|
||||
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")
|
||||
ev = _writable_event(db, current_user, uid)
|
||||
if data.private is not None:
|
||||
ev.is_private = data.private
|
||||
if data.title is not None:
|
||||
ev.title = data.title
|
||||
if data.start is not None:
|
||||
@@ -233,6 +505,14 @@ def update_event(
|
||||
if data.exdate not in dates:
|
||||
dates.append(data.exdate)
|
||||
ev.exdate = ",".join(dates)
|
||||
if data.reminders is not None:
|
||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
if data.external_uid is not None:
|
||||
ev.external_uid = data.external_uid
|
||||
if data.birth_year is not None:
|
||||
# -1 is the client's sentinel for "clear" (year became unknown).
|
||||
ev.birth_year = None if data.birth_year < 0 else data.birth_year
|
||||
dav_util.bump_dav(ev.calendar, ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -243,17 +523,259 @@ def delete_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ev = (
|
||||
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")
|
||||
ev = _writable_event(db, current_user, uid)
|
||||
dav_util.bump_dav(ev.calendar)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/calendars/{calendar_id}/birthdays")
|
||||
def list_birthday_entries(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
|
||||
reconcile against the address book by ``external_uid``. Contact-sourced rows
|
||||
carry an ``external_uid``; manually added birthdays have ``None`` and must be
|
||||
left untouched by the importer."""
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for ev in events:
|
||||
month = day = None
|
||||
try:
|
||||
parts = (ev.start or "")[:10].split("-")
|
||||
if len(parts) == 3:
|
||||
month, day = int(parts[1]), int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
out.append({
|
||||
"uid": ev.uid,
|
||||
"external_uid": ev.external_uid,
|
||||
"title": ev.title,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"birth_year": ev.birth_year,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Sharing (owner only) ──────────────────────────────────
|
||||
|
||||
@router.get("/calendars/{calendar_id}/shares")
|
||||
def list_shares(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
shares = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(models.CalendarShare.calendar_id == calendar_id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for s in shares:
|
||||
u = db.query(models.User).filter(models.User.id == s.user_id).first()
|
||||
out.append({
|
||||
"user_id": s.user_id,
|
||||
"display_name": (u.display_name or u.username) if u else None,
|
||||
"permission": s.permission,
|
||||
"created_at": s.created_at,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/shares")
|
||||
def add_share(
|
||||
calendar_id: int,
|
||||
data: ShareCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
if data.permission not in ("read", "read_write"):
|
||||
raise HTTPException(422, "permission must be 'read' or 'read_write'")
|
||||
target = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target.id == current_user.id:
|
||||
raise HTTPException(422, "Cannot share a calendar with yourself")
|
||||
|
||||
share = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == data.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if share:
|
||||
share.permission = data.permission # update existing
|
||||
else:
|
||||
share = models.CalendarShare(
|
||||
calendar_id=calendar_id,
|
||||
user_id=data.user_id,
|
||||
permission=data.permission,
|
||||
created_at=_now_iso(),
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}/shares/{user_id}")
|
||||
def remove_share(
|
||||
calendar_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
share = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found")
|
||||
db.delete(share)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── iCal Import / Export (local calendars only) ───────────
|
||||
|
||||
def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict:
|
||||
parsed = ical_io.parse_ics(raw)
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = list(parsed["errors"])
|
||||
# local_events.uid is globally unique. Dedupe against the DB AND within this
|
||||
# file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a
|
||||
# UID (RECURRENCE-ID overrides), which would otherwise violate the constraint.
|
||||
seen_uids: set[str] = set()
|
||||
for item in parsed["events"]:
|
||||
uid = item.get("uid") or str(uuid.uuid4())
|
||||
if uid in seen_uids:
|
||||
skipped += 1
|
||||
continue
|
||||
seen_uids.add(uid)
|
||||
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=uid,
|
||||
title=item.get("title") or "(ohne Titel)",
|
||||
start=item["start"],
|
||||
end=item["end"],
|
||||
all_day=item.get("all_day", False),
|
||||
location=item.get("location"),
|
||||
description=item.get("description"),
|
||||
rrule=item.get("rrule"),
|
||||
exdate=item.get("exdate"),
|
||||
creator_name_external=item.get("organizer"),
|
||||
etag=dav_util.new_tag(),
|
||||
)
|
||||
db.add(ev)
|
||||
imported += 1
|
||||
if imported:
|
||||
dav_util.bump_dav(cal)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
raise ValueError(f"Import fehlgeschlagen: {exc}")
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM).
|
||||
MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars
|
||||
|
||||
|
||||
async def _read_capped(file: UploadFile) -> bytes:
|
||||
raw = await file.read(MAX_ICS_BYTES + 1)
|
||||
if len(raw) > MAX_ICS_BYTES:
|
||||
raise HTTPException(413, "Datei zu groß (max. 5 MB)")
|
||||
return raw
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/import")
|
||||
async def import_calendar(
|
||||
calendar_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
raw = await _read_capped(file)
|
||||
try:
|
||||
return _import_ics_into(cal, raw, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_generic(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: Optional[int] = Form(None),
|
||||
create_calendar: bool = Form(False),
|
||||
calendar_name: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Read (capped) first so an oversized upload can't leave an empty calendar.
|
||||
raw = await _read_capped(file)
|
||||
if create_calendar:
|
||||
cal = models.LocalCalendar(
|
||||
user_id=current_user.id,
|
||||
name=(calendar_name or "Importiert")[:120],
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
elif calendar_id is not None:
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
else:
|
||||
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
|
||||
|
||||
try:
|
||||
result = _import_ics_into(cal, raw, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
result["calendar_id"] = cal.id
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/calendars/{calendar_id}/export")
|
||||
def export_calendar(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
# Resolve creator display names for ORGANIZER.
|
||||
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
|
||||
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
|
||||
return Response(
|
||||
content=ics,
|
||||
media_type="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -8,11 +11,13 @@ import qrcode
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,7 +30,17 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────
|
||||
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)
|
||||
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
|
||||
|
||||
|
||||
def _strip_controls(s: str) -> str:
|
||||
"""Remove control characters (defends against injected newlines / NULs that
|
||||
could be reflected in other clients' calendar/sharing/group views)."""
|
||||
return re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
@@ -47,10 +62,12 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name or current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
"totp_enabled": current_user.totp_enabled,
|
||||
"directory_hidden": bool(current_user.directory_hidden),
|
||||
}
|
||||
|
||||
|
||||
@@ -60,10 +77,51 @@ def update_profile(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
result = {"ok": True}
|
||||
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
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||
# token, so hand back a fresh one for the client to store.
|
||||
result["access_token"] = create_access_token({"sub": new_login})
|
||||
return result
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
return result
|
||||
|
||||
|
||||
# ── Avatar ────────────────────────────────────────────────
|
||||
@@ -118,7 +176,11 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.get("/avatar/{user_id}")
|
||||
def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
|
||||
def get_user_avatar(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user or not user.avatar_filename:
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
@@ -214,3 +276,74 @@ def disable_totp(
|
||||
current_user.totp_enabled = False
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── App passwords (for CalDAV Basic Auth) ────────────────
|
||||
class AppPasswordCreate(BaseModel):
|
||||
label: str = Field(default="CalDAV", max_length=100)
|
||||
|
||||
|
||||
def _app_pw_dict(ap: models.AppPassword) -> dict:
|
||||
return {
|
||||
"id": ap.id,
|
||||
"label": ap.label,
|
||||
"created_at": ap.created_at,
|
||||
"last_used_at": ap.last_used_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/app-passwords")
|
||||
def list_app_passwords(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
rows = (
|
||||
db.query(models.AppPassword)
|
||||
.filter(models.AppPassword.user_id == current_user.id)
|
||||
.order_by(models.AppPassword.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [_app_pw_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/app-passwords")
|
||||
def create_app_password(
|
||||
data: AppPasswordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Show the plaintext exactly once; only the hash is stored.
|
||||
password = secrets.token_urlsafe(18)
|
||||
ap = models.AppPassword(
|
||||
user_id=current_user.id,
|
||||
label=(data.label or "CalDAV")[:100],
|
||||
password_hash=get_password_hash(password),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
db.add(ap)
|
||||
db.commit()
|
||||
db.refresh(ap)
|
||||
out = _app_pw_dict(ap)
|
||||
out["password"] = password
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/app-passwords/{ap_id}")
|
||||
def delete_app_password(
|
||||
ap_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ap = (
|
||||
db.query(models.AppPassword)
|
||||
.filter(
|
||||
models.AppPassword.id == ap_id,
|
||||
models.AppPassword.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ap:
|
||||
raise HTTPException(404, "App password not found")
|
||||
db.delete(ap)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -11,6 +12,56 @@ from database import get_db
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Which settings can sync across a user's devices, and the default flag applied to
|
||||
# an existing/new account until the user overrides it. This map is the single
|
||||
# authority: GET returns the fully-resolved flags so no client duplicates default
|
||||
# logic. Rollout rule: settings that already lived on the server default to True;
|
||||
# the four newly-syncable device-local prefs default to False.
|
||||
DEFAULT_SYNC = {
|
||||
"default_view": True,
|
||||
"week_start_day": True,
|
||||
"dim_past_events": True,
|
||||
"hour_height": True,
|
||||
"primary_color": True,
|
||||
"accent_color": True,
|
||||
"today_color": True,
|
||||
"text_color": True,
|
||||
"line_color": True,
|
||||
"bg_color": True,
|
||||
"month_divider_color": True,
|
||||
"month_label_color": True,
|
||||
"default_event_duration_minutes": True,
|
||||
"default_reminder_minutes": True,
|
||||
"language": False,
|
||||
"share_calendar_icon": False,
|
||||
"cache_months": False,
|
||||
"month_view_paged": False,
|
||||
"surface_color": False,
|
||||
"hover_highlight_color": True,
|
||||
"icon_inactive_color": True,
|
||||
"icon_active_color": True,
|
||||
"day_hover_color": True,
|
||||
"day_selected_color": True,
|
||||
"day_bg_color": True,
|
||||
"today_bg_color": True,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sync_flags(s: models.UserSettings) -> dict:
|
||||
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
|
||||
top of DEFAULT_SYNC, junk keys dropped."""
|
||||
stored = {}
|
||||
if s.sync_flags:
|
||||
try:
|
||||
stored = json.loads(s.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
stored = {}
|
||||
return {
|
||||
key: bool(stored[key]) if key in stored else default
|
||||
for key, default in DEFAULT_SYNC.items()
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
default_view: Optional[str] = None
|
||||
week_start_day: Optional[str] = None
|
||||
@@ -24,6 +75,25 @@ class SettingsUpdate(BaseModel):
|
||||
language: Optional[str] = None
|
||||
month_divider_color: Optional[str] = None
|
||||
month_label_color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
line_color: Optional[str] = None
|
||||
bg_color: Optional[str] = None
|
||||
surface_color: Optional[str] = None
|
||||
hover_highlight_color: Optional[str] = None
|
||||
icon_inactive_color: Optional[str] = None
|
||||
icon_active_color: Optional[str] = None
|
||||
day_hover_color: Optional[str] = None
|
||||
day_selected_color: Optional[str] = None
|
||||
day_bg_color: Optional[str] = None
|
||||
today_bg_color: Optional[str] = None
|
||||
private_event_visibility: Optional[str] = None
|
||||
group_visible_calendar_id: Optional[int] = None
|
||||
default_reminder_minutes: Optional[int] = None # null = off
|
||||
default_event_duration_minutes: Optional[int] = None
|
||||
share_calendar_icon: Optional[str] = None
|
||||
cache_months: Optional[int] = None
|
||||
month_view_paged: Optional[bool] = None
|
||||
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
|
||||
|
||||
|
||||
def _settings_dict(s: models.UserSettings) -> dict:
|
||||
@@ -40,6 +110,25 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
||||
"language": s.language or "de",
|
||||
"month_divider_color": s.month_divider_color or "#7090c0",
|
||||
"month_label_color": s.month_label_color or "#7090c0",
|
||||
"text_color": s.text_color,
|
||||
"line_color": s.line_color,
|
||||
"bg_color": s.bg_color,
|
||||
"surface_color": s.surface_color,
|
||||
"hover_highlight_color": s.hover_highlight_color,
|
||||
"icon_inactive_color": s.icon_inactive_color,
|
||||
"icon_active_color": s.icon_active_color,
|
||||
"day_hover_color": s.day_hover_color,
|
||||
"day_selected_color": s.day_selected_color,
|
||||
"day_bg_color": s.day_bg_color,
|
||||
"today_bg_color": s.today_bg_color,
|
||||
"private_event_visibility": s.private_event_visibility or "busy",
|
||||
"group_visible_calendar_id": s.group_visible_calendar_id,
|
||||
"default_reminder_minutes": s.default_reminder_minutes,
|
||||
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
||||
"share_calendar_icon": s.share_calendar_icon,
|
||||
"cache_months": s.cache_months or 3,
|
||||
"month_view_paged": bool(s.month_view_paged),
|
||||
"sync_flags": _resolve_sync_flags(s),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +165,50 @@ def update_settings(
|
||||
settings = models.UserSettings(user_id=current_user.id)
|
||||
db.add(settings)
|
||||
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
setattr(settings, field, value)
|
||||
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'")
|
||||
|
||||
# A birthday calendar must never become the group-visible ("personal")
|
||||
# calendar — it may be shared directly, but not stand in as your calendar in
|
||||
# group views. Clients filter it out of the picker; this is the safety net.
|
||||
if data.group_visible_calendar_id:
|
||||
bcal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == data.group_visible_calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if bcal is not None and bcal.is_birthday:
|
||||
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
|
||||
|
||||
# For these three override colours, an explicit null is meaningful
|
||||
# ("reset to default") and must be persisted as NULL. All other fields
|
||||
# keep the previous behaviour where a null/missing value is ignored.
|
||||
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||
# syncable keys are kept; a partial map leaves untouched flags as they were.
|
||||
if "sync_flags" in update_data:
|
||||
incoming = update_data.pop("sync_flags") or {}
|
||||
current = {}
|
||||
if settings.sync_flags:
|
||||
try:
|
||||
current = json.loads(settings.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
current = {}
|
||||
for key, val in incoming.items():
|
||||
if key in DEFAULT_SYNC:
|
||||
current[key] = bool(val)
|
||||
settings.sync_flags = json.dumps(current)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field in NULLABLE_OVERRIDES:
|
||||
setattr(settings, field, value or None)
|
||||
elif value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -23,8 +23,18 @@ class ChangePasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class SetAdminRequest(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
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("/")
|
||||
@@ -35,6 +45,28 @@ def list_users(
|
||||
return [_user_dict(u) for u in db.query(models.User).all()]
|
||||
|
||||
|
||||
@router.get("/directory")
|
||||
def user_directory(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Lightweight list of all users (id + display_name) for sharing/group pickers.
|
||||
|
||||
Available to any authenticated user (unlike GET / which is admin-only).
|
||||
Excludes the requesting user.
|
||||
"""
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(
|
||||
models.User.id != current_user.id,
|
||||
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
|
||||
)
|
||||
.order_by(models.User.username)
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_user(
|
||||
req: CreateUserRequest,
|
||||
@@ -45,6 +77,7 @@ def create_user(
|
||||
raise HTTPException(400, "Username already taken")
|
||||
user = models.User(
|
||||
username=req.username.lower(),
|
||||
display_name=req.username.strip(), # keep the original casing for display
|
||||
email=req.email,
|
||||
password_hash=get_password_hash(req.password),
|
||||
is_admin=req.is_admin,
|
||||
@@ -73,6 +106,28 @@ def delete_user(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{user_id}/admin")
|
||||
def set_admin(
|
||||
user_id: int,
|
||||
req: SetAdminRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_admin),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "Cannot change your own admin status")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
# Never leave the instance without an admin.
|
||||
if user.is_admin and not req.is_admin:
|
||||
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(400, "At least one admin must remain")
|
||||
user.is_admin = req.is_admin
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{user_id}/password")
|
||||
def change_password(
|
||||
user_id: int,
|
||||
|
||||
61
backend/tests/conftest.py
Normal 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}"}
|
||||
514
backend/tests/test_collaboration.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""Tests for sharing, group permissions, the iCal parser, and private filtering."""
|
||||
|
||||
from conftest import register_admin, create_user, auth
|
||||
|
||||
RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
|
||||
|
||||
|
||||
def _make_calendar(client, token, name="Cal"):
|
||||
r = client.post("/api/local/calendars", headers=auth(token), json={"name": name})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _make_event(client, token, cal_id, title="Event", private=False,
|
||||
start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"):
|
||||
r = client.post("/api/local/events", headers=auth(token), json={
|
||||
"calendar_id": cal_id, "title": title, "start": start, "end": end,
|
||||
"private": private,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Sharing ───────────────────────────────────────────────
|
||||
|
||||
def test_share_read_then_read_write(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
|
||||
cal_id = _make_calendar(client, admin, "Admins Kalender")
|
||||
ev = _make_event(client, admin, cal_id, "Meeting")
|
||||
|
||||
# Creator field populated server-side.
|
||||
assert ev["creator"]["display_name"] == "admin"
|
||||
assert ev["type"] == "local"
|
||||
|
||||
# Share read-only with bob.
|
||||
r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
|
||||
json={"user_id": b_id, "permission": "read"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Bob sees the shared calendar with shared_by.
|
||||
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
|
||||
shared = [c for c in cals if not c["owned"]]
|
||||
assert len(shared) == 1
|
||||
assert shared[0]["shared_by"] == "admin"
|
||||
assert shared[0]["permission"] == "read"
|
||||
|
||||
# Bob sees the event in the merged read.
|
||||
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
|
||||
assert any(e["title"] == "Meeting" for e in events)
|
||||
|
||||
# Bob cannot write (read-only) -> 403.
|
||||
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||
"calendar_id": cal_id, "title": "Nope",
|
||||
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
|
||||
})
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
# Upgrade to read_write -> bob can write.
|
||||
client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
|
||||
json={"user_id": b_id, "permission": "read_write"})
|
||||
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||
"calendar_id": cal_id, "title": "Bobs Eintrag",
|
||||
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
# Created by bob.
|
||||
assert r.json()["creator"]["display_name"] == "bob"
|
||||
|
||||
|
||||
def test_non_owner_cannot_manage_shares(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
cal_id = _make_calendar(client, admin)
|
||||
# Bob (no access at all) cannot list shares -> 404 (existence hidden).
|
||||
r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_unshared_calendar_invisible(client):
|
||||
admin = register_admin(client)
|
||||
_b_id, b_tok = create_user(client, admin, "bob")
|
||||
cal_id = _make_calendar(client, admin)
|
||||
_make_event(client, admin, cal_id, "Privat")
|
||||
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
|
||||
assert not any(e["title"] == "Privat" for e in events)
|
||||
|
||||
|
||||
# ── Groups ────────────────────────────────────────────────
|
||||
|
||||
def test_group_create_and_members(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
c_id, c_tok = create_user(client, admin, "carol")
|
||||
|
||||
r = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Familie", "member_ids": [b_id]})
|
||||
assert r.status_code == 200, r.text
|
||||
group = r.json()
|
||||
gid = group["id"]
|
||||
assert group["group_calendar_id"] is not None
|
||||
assert {m["display_name"] for m in group["members"]} == {"admin", "bob"}
|
||||
|
||||
# Both members see the group.
|
||||
assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json())
|
||||
# Carol is not a member.
|
||||
assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json())
|
||||
assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403
|
||||
|
||||
# Only owner adds members.
|
||||
assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok),
|
||||
json={"user_id": c_id}).status_code == 403
|
||||
assert client.post(f"/api/groups/{gid}/members", headers=auth(admin),
|
||||
json={"user_id": c_id}).status_code == 200
|
||||
|
||||
# Member can leave; owner cannot be removed.
|
||||
assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200
|
||||
admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"]
|
||||
assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422
|
||||
|
||||
|
||||
def test_group_members_can_write_group_calendar(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gcal = group["group_calendar_id"]
|
||||
# Bob (member, not owner of the calendar) can create in the group calendar.
|
||||
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||
"calendar_id": gcal, "title": "Teamtermin",
|
||||
"start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_group_member_colors_and_display_color(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
gcal = group["group_calendar_id"]
|
||||
|
||||
# Each member has a server-assigned colour; the group exposes its calendar colour.
|
||||
detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
|
||||
assert all(m.get("color") for m in detail["members"])
|
||||
assert detail.get("group_calendar_color")
|
||||
|
||||
# Owner can recolour a member.
|
||||
r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin),
|
||||
json={"color": "#123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
|
||||
assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"])
|
||||
|
||||
# Bob shares a calendar with an event; combined events carry display_color.
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
_make_event(client, admin, gcal, "Gruppentermin")
|
||||
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
|
||||
by_title = {e["title"]: e for e in evs}
|
||||
assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour
|
||||
assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"]
|
||||
|
||||
|
||||
def test_group_calendar_listed_for_member(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gcal = group["group_calendar_id"]
|
||||
# Bob (member, not owner) sees the group calendar in his local list, flagged.
|
||||
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
|
||||
gc = [c for c in cals if c["id"] == gcal]
|
||||
assert gc and gc[0].get("group") is True
|
||||
assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False
|
||||
|
||||
|
||||
def test_combined_view_marks_owner_and_group_event(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
gcal = group["group_calendar_id"]
|
||||
|
||||
# Bob's own calendar + event; Bob designates it as his group-visible calendar.
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
# A group-calendar event.
|
||||
_make_event(client, admin, gcal, "Gruppentermin")
|
||||
|
||||
events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
|
||||
titles = {e["title"]: e for e in events}
|
||||
assert "Bobs Termin" in titles
|
||||
assert titles["Bobs Termin"]["owner"]["display_name"] == "bob"
|
||||
assert titles["Bobs Termin"].get("is_group_event") is not True
|
||||
assert "Gruppentermin" in titles
|
||||
assert titles["Gruppentermin"]["is_group_event"] is True
|
||||
|
||||
|
||||
# ── Private filtering ─────────────────────────────────────
|
||||
|
||||
def _combined_titles(client, token, gid):
|
||||
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"]
|
||||
return evs
|
||||
|
||||
|
||||
def test_private_visibility_hidden_and_busy(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Geheimes", private=True,
|
||||
start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00")
|
||||
|
||||
# Bob sees his own private event in full.
|
||||
own = _combined_titles(client, b_tok, gid)
|
||||
assert any(e["title"] == "Geheimes" for e in own)
|
||||
|
||||
# Default visibility = busy -> admin sees it as anonymous "Beschäftigt".
|
||||
seen = _combined_titles(client, admin, gid)
|
||||
busy = [e for e in seen if e["start"].startswith("2026-06-15")]
|
||||
assert busy and all(e["title"] == "Beschäftigt" for e in busy)
|
||||
assert all(e["location"] == "" and e["description"] == "" for e in busy)
|
||||
|
||||
# Switch bob to hidden -> admin no longer sees it at all.
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"})
|
||||
seen2 = _combined_titles(client, admin, gid)
|
||||
assert not any(e["start"].startswith("2026-06-15") for e in seen2)
|
||||
|
||||
|
||||
def test_member_calendar_hidden_until_designated(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
|
||||
# Not designated yet -> admin doesn't see Bob's calendar in the combined view.
|
||||
seen = _combined_titles(client, admin, gid)
|
||||
assert not any(e["title"] == "Bobs Termin" for e in seen)
|
||||
|
||||
# After Bob designates it, it appears.
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
seen2 = _combined_titles(client, admin, gid)
|
||||
assert any(e["title"] == "Bobs Termin" for e in seen2)
|
||||
|
||||
|
||||
def test_display_name_case_preserved_and_login_case_insensitive(client):
|
||||
# Setup with mixed-case name: login name lowercased, display name kept.
|
||||
r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"})
|
||||
assert r.status_code == 200, r.text
|
||||
user = r.json()["user"]
|
||||
assert user["username"] == "guido"
|
||||
assert user["display_name"] == "Guido"
|
||||
|
||||
# Login is case-insensitive (typing the display name "GUIDO" works).
|
||||
r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
tok = r2.json()["access_token"]
|
||||
|
||||
# /me reflects the cased display name.
|
||||
me = client.get("/api/auth/me", headers=auth(tok)).json()
|
||||
assert me["display_name"] == "Guido" and me["username"] == "guido"
|
||||
|
||||
|
||||
def test_rename_login_name_returns_new_token(client):
|
||||
admin = register_admin(client, "alice")
|
||||
# Change display name (no token change).
|
||||
r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."})
|
||||
assert r.status_code == 200 and "access_token" not in r.json()
|
||||
# Change login name -> fresh token, references survive (id is stable).
|
||||
r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"})
|
||||
assert r2.status_code == 200 and r2.json().get("access_token")
|
||||
new_tok = r2.json()["access_token"]
|
||||
me = client.get("/api/auth/me", headers=auth(new_tok)).json()
|
||||
assert me["username"] == "alice2" and me["display_name"] == "Alice W."
|
||||
|
||||
|
||||
def test_private_visibility_validation(client):
|
||||
admin = register_admin(client)
|
||||
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ── iCal import/export ────────────────────────────────────
|
||||
|
||||
SAMPLE_ICS = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:evt-1@test
|
||||
SUMMARY:Importiert 1
|
||||
DTSTART:20260620T100000Z
|
||||
DTEND:20260620T110000Z
|
||||
LOCATION:Buero
|
||||
ORGANIZER;CN=Max Mustermann:mailto:max@example.com
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:evt-2@test
|
||||
SUMMARY:Importiert 2
|
||||
DTSTART;VALUE=DATE:20260621
|
||||
DTEND;VALUE=DATE:20260622
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
|
||||
def test_ical_parser_roundtrip():
|
||||
import ical_io
|
||||
parsed = ical_io.parse_ics(SAMPLE_ICS)
|
||||
assert len(parsed["events"]) == 2
|
||||
ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test")
|
||||
assert ev1["title"] == "Importiert 1"
|
||||
assert ev1["location"] == "Buero"
|
||||
assert ev1["organizer"] == "Max Mustermann"
|
||||
assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
|
||||
ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test")
|
||||
assert ev2["all_day"] is True
|
||||
|
||||
|
||||
def test_import_dedupes_by_uid(client):
|
||||
admin = register_admin(client)
|
||||
cal_id = _make_calendar(client, admin, "Import-Ziel")
|
||||
|
||||
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
|
||||
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["imported"] == 2 and body["skipped"] == 0
|
||||
|
||||
# Re-import -> all skipped (UID dedupe).
|
||||
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
|
||||
r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||
assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2
|
||||
|
||||
# Imported events carry the external creator name.
|
||||
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||
imported = [e for e in events if e["title"] == "Importiert 1"]
|
||||
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
|
||||
|
||||
|
||||
DUP_UID_ICS = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nextcloud
|
||||
BEGIN:VEVENT
|
||||
UID:recurring@nc
|
||||
SUMMARY:Standup
|
||||
DTSTART:20260601T090000Z
|
||||
DTEND:20260601T091500Z
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:recurring@nc
|
||||
RECURRENCE-ID:20260608T090000Z
|
||||
SUMMARY:Standup verschoben
|
||||
DTSTART:20260608T100000Z
|
||||
DTEND:20260608T101500Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
|
||||
def test_import_handles_duplicate_uid_in_file(client):
|
||||
"""Nextcloud exports recurring events as multiple VEVENTs sharing a UID;
|
||||
importing must not 500 on the unique constraint."""
|
||||
admin = register_admin(client)
|
||||
cal_id = _make_calendar(client, admin, "NC")
|
||||
files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")}
|
||||
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["imported"] == 1 and body["skipped"] == 1
|
||||
|
||||
|
||||
def test_export_contains_organizer_and_rrule(client):
|
||||
admin = register_admin(client)
|
||||
cal_id = _make_calendar(client, admin, "Export-Test")
|
||||
_make_event(client, admin, cal_id, "Wöchentlich")
|
||||
# Add a recurring rule via update.
|
||||
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||
uid = next(e["id"] for e in events if e["title"] == "Wöchentlich")
|
||||
client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"})
|
||||
|
||||
r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin))
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/calendar")
|
||||
body = r.text
|
||||
assert "BEGIN:VCALENDAR" in body
|
||||
assert "ORGANIZER" in body and "admin" in body
|
||||
assert "RRULE" in body
|
||||
|
||||
|
||||
def test_import_export_only_local(client):
|
||||
"""Import/export endpoints reject non-existent / inaccessible calendars."""
|
||||
admin = register_admin(client)
|
||||
_b, b_tok = create_user(client, admin, "bob")
|
||||
cal_id = _make_calendar(client, admin, "Privat")
|
||||
# Bob has no access -> 404 on export.
|
||||
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
|
||||
|
||||
|
||||
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
|
||||
|
||||
def test_group_visible_propagates_to_member_sidebar(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
|
||||
# Bob designates a calendar as group-visible; admin (co-member) should see it.
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
shared = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(shared) == 1, shared
|
||||
assert shared[0]["owned"] is False
|
||||
assert shared[0]["shared_by"] == "bob"
|
||||
assert shared[0]["permission"] == "read"
|
||||
assert shared[0].get("group_shared") is True
|
||||
|
||||
# Its events appear in the normal merged read, read-only.
|
||||
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
|
||||
assert bob_ev and bob_ev[0].get("read_only") is True
|
||||
|
||||
|
||||
def test_group_visible_absent_when_not_designated(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
assert not any(c["id"] == b_cal for c in cals)
|
||||
|
||||
|
||||
def test_group_visible_not_duplicated_with_direct_share(client):
|
||||
admin = register_admin(client)
|
||||
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
# Also directly shared with admin (read_write) — must not double-list.
|
||||
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
|
||||
json={"user_id": admin_id, "permission": "read_write"})
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
matches = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(matches) == 1, matches
|
||||
# The direct share wins (read_write, not flagged group_shared).
|
||||
assert matches[0]["permission"] == "read_write"
|
||||
assert matches[0].get("group_shared") is not True
|
||||
|
||||
|
||||
# ── Hidden profile (directory opt-out) ────────────────────────────────────
|
||||
|
||||
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
|
||||
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert not any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
# Admin user management still lists the hidden user.
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/", headers=auth(admin)).json())
|
||||
|
||||
|
||||
def test_combined_view_read_only_for_other_members(client):
|
||||
"""In the group combined view, events I may not edit carry read_only=True:
|
||||
other members' calendars are read-only; the group calendar + my own aren't."""
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
gcal = group["group_calendar_id"]
|
||||
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
_make_event(client, admin, gcal, "Gruppentermin")
|
||||
|
||||
# As admin: bob's event is read-only; the group calendar is editable.
|
||||
by = {e["title"]: e for e in
|
||||
client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]}
|
||||
assert by["Bobs Termin"].get("read_only") is True
|
||||
assert by["Gruppentermin"].get("read_only") is not True
|
||||
|
||||
# As bob: his own event and the group calendar are both editable.
|
||||
by_b = {e["title"]: e for e in
|
||||
client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]}
|
||||
assert by_b["Bobs Termin"].get("read_only") is not True
|
||||
assert by_b["Gruppentermin"].get("read_only") is not True
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2ea05a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 27 KiB |
BIN
frontend/icons/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#4285f4"/>
|
||||
<rect width="512" height="512" fill="#16713d"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="102" y="154" width="307" height="266" rx="26"/>
|
||||
<line x1="102" y1="234" x2="409" y2="234"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 432 B After Width: | Height: | Size: 432 B |
@@ -1,13 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<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" />
|
||||
<!-- APP_VERSION: update here + version.js on every release -->
|
||||
<title>Calendarr v11</title>
|
||||
<!-- Title wird beim Laden aus version.js dynamisch gesetzt -->
|
||||
<title>Calendarr</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4285f4" />
|
||||
<meta name="theme-color" content="#16713d" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Calendarr" />
|
||||
@@ -80,7 +81,7 @@
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||
@@ -158,10 +159,19 @@
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-inner">
|
||||
<button class="btn btn-fab" id="btn-create-event">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<span data-i18n="btn_create">Erstellen</span>
|
||||
</button>
|
||||
<div class="create-split add-cal-dropdown-wrap">
|
||||
<button class="btn btn-fab create-main" id="btn-create-event">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<span data-i18n="btn_create">Erstellen</span>
|
||||
</button>
|
||||
<button class="btn btn-fab create-caret" id="btn-create-menu" data-i18n-title="birthday_new" title="Neuer Geburtstag" aria-haspopup="menu" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M7 10l5 5 5-5z"/></svg>
|
||||
</button>
|
||||
<div class="add-cal-dropdown create-menu hidden" id="create-menu" role="menu">
|
||||
<button data-action="event" data-i18n="create_event_option">Neuer Termin</button>
|
||||
<button data-action="birthday" data-i18n="birthday_new">Neuer Geburtstag</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Calendar -->
|
||||
<div class="mini-cal" id="mini-cal">
|
||||
@@ -179,16 +189,25 @@
|
||||
<div class="mini-cal-days" id="mini-days"></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 -->
|
||||
<div class="cal-list" id="cal-list">
|
||||
<div class="cal-list-header">
|
||||
<span data-i18n="my_calendars">Meine Kalender</span>
|
||||
<div class="add-cal-dropdown-wrap">
|
||||
<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>
|
||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||
<button data-action="local">Lokaler Kalender</button>
|
||||
<button data-action="birthday" data-i18n="birthday_calendar_type">Geburtstagskalender</button>
|
||||
<button data-action="caldav">CalDAV-Konto</button>
|
||||
<button data-action="ical">iCal-URL abonnieren</button>
|
||||
<button data-action="google">Google Kalender</button>
|
||||
@@ -198,13 +217,30 @@
|
||||
</div>
|
||||
<div id="cal-list-items"></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>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
||||
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||
</aside>
|
||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||
|
||||
<!-- 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>
|
||||
</main>
|
||||
|
||||
@@ -235,7 +271,7 @@
|
||||
<input type="hidden" id="ev-start" />
|
||||
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
@@ -243,7 +279,7 @@
|
||||
<input type="hidden" id="ev-end" />
|
||||
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +289,7 @@
|
||||
<input type="hidden" id="ev-start-date" />
|
||||
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
@@ -261,7 +297,7 @@
|
||||
<input type="hidden" id="ev-end-date" />
|
||||
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,13 +347,27 @@
|
||||
<input type="hidden" id="ev-rec-until" />
|
||||
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kalender</label>
|
||||
<select id="ev-calendar"></select>
|
||||
<!-- Hidden native select stays the value holder + change-event source;
|
||||
the custom dropdown below renders colour dots and group icons. -->
|
||||
<select id="ev-calendar" class="cal-native-hidden"></select>
|
||||
<div class="cal-select" id="ev-cal-select">
|
||||
<button type="button" class="cal-select-trigger" id="ev-cal-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="cal-select-current" id="ev-cal-current"></span>
|
||||
<svg class="cal-select-chevron" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
|
||||
</button>
|
||||
<div class="cal-select-menu hidden" id="ev-cal-menu" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" id="ev-private-row" style="display:none">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="ev-private" /> <span data-i18n="event_private">Privat</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ort</label>
|
||||
@@ -334,6 +384,12 @@
|
||||
<div class="ev-color-preview" id="ev-color-preview" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="ev-reminders-group" style="display:none">
|
||||
<label data-i18n="reminders">Benachrichtigungen</label>
|
||||
<div id="ev-reminders-hint" class="form-hint" style="display:none" data-i18n="reminders_calendar_off">Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.</div>
|
||||
<div id="ev-reminders-list" class="ev-reminders-list"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-danger hidden" id="ev-delete">Löschen</button>
|
||||
@@ -344,6 +400,68 @@
|
||||
</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">×</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">×</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 & 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 -->
|
||||
<div id="modal-delete-confirm" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:400px">
|
||||
@@ -370,27 +488,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Confirm Dialog (styled replacement for window.confirm) -->
|
||||
<div id="modal-confirm" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:400px">
|
||||
<div class="modal-header">
|
||||
<h3 id="confirm-title">Bestätigen</h3>
|
||||
<button class="icon-btn modal-close" data-modal="modal-confirm">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-text"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-ghost" id="confirm-cancel" data-modal="modal-confirm" data-i18n="cancel">Abbrechen</button>
|
||||
<button class="btn btn-danger" id="confirm-ok" data-i18n="delete">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Detail Popup -->
|
||||
<div id="popup-event" class="event-popup hidden">
|
||||
<div class="popup-header">
|
||||
<div class="popup-color-dot" id="popup-color-dot"></div>
|
||||
<h4 id="popup-title"></h4>
|
||||
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn popup-close" id="popup-close">×</button>
|
||||
<div class="popup-toolbar">
|
||||
<button class="popup-icon-btn" id="popup-edit" title="Bearbeiten" aria-label="Bearbeiten">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</button>
|
||||
<button class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
<button class="popup-icon-btn popup-icon-btn-close" id="popup-close" title="Schließen" aria-label="Schließen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<div class="popup-time" id="popup-time"></div>
|
||||
<div class="popup-location" id="popup-location"></div>
|
||||
<div class="popup-description" id="popup-description"></div>
|
||||
<div class="popup-calendar" id="popup-calendar"></div>
|
||||
<div class="popup-row" id="popup-row-time">
|
||||
<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>
|
||||
<span id="popup-time"></span>
|
||||
</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 id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
||||
</div>
|
||||
@@ -462,6 +618,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Birthday modal -->
|
||||
<div id="modal-birthday" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:400px">
|
||||
<div class="modal-header">
|
||||
<h3 data-i18n="birthday_modal_title">Neuen Geburtstag hinzufügen</h3>
|
||||
<button class="icon-btn modal-close" data-modal="modal-birthday">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="birthday-no-cal" class="hidden">
|
||||
<div class="form-hint" data-i18n="birthday_activate_hint">Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.</div>
|
||||
<button class="btn btn-primary btn-sm" id="birthday-activate" data-i18n="birthday_activate">Geburtstagskalender aktivieren</button>
|
||||
</div>
|
||||
<div id="birthday-form">
|
||||
<div class="form-group">
|
||||
<label data-i18n="birthday_person">Name</label>
|
||||
<input type="text" id="birthday-name" data-i18n-ph="birthday_person_ph" placeholder="Name der Person" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="birthday_date">Geburtstag</label>
|
||||
<div class="birthday-date-row">
|
||||
<select id="birthday-day"></select>
|
||||
<select id="birthday-month"></select>
|
||||
<input type="number" id="birthday-year" min="1900" max="2100" data-i18n-ph="birthday_year_ph" placeholder="Jahr" />
|
||||
</div>
|
||||
<label class="checkbox-row" style="margin-top:10px;display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:normal;font-weight:400;cursor:pointer">
|
||||
<input type="checkbox" id="birthday-year-unknown" style="width:16px;height:16px;flex:none;margin:0" />
|
||||
<span data-i18n="birthday_year_unknown">Jahr unbekannt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" data-modal="modal-birthday" data-i18n="cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="birthday-save" data-i18n="save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iCal Subscription Modal -->
|
||||
<div id="modal-ical-sub" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:480px">
|
||||
@@ -563,148 +757,109 @@
|
||||
<div class="settings-page-body">
|
||||
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
|
||||
<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" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</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 active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
|
||||
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
||||
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
|
||||
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panels">
|
||||
|
||||
<!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) -->
|
||||
<div class="settings-panel active" id="settings-panel-general">
|
||||
|
||||
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
|
||||
<!-- Profil: Name, Privatsphäre, geteilter Kalender -->
|
||||
<div class="settings-panel active" id="settings-panel-profile">
|
||||
<h4 class="panel-title" data-i18n="profile_account">Konto</h4>
|
||||
<div class="form-group">
|
||||
<select id="cfg-language">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_primary_color">Primärfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_accent_color">Akzentfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_label_color">Monatskürzel</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
|
||||
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
|
||||
<div class="form-group app-pw-create">
|
||||
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" />
|
||||
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button>
|
||||
</div>
|
||||
<div id="app-pw-new" class="app-pw-new hidden">
|
||||
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
|
||||
<div class="totp-secret-row">
|
||||
<code id="app-pw-new-value"></code>
|
||||
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app-pw-list"></div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
|
||||
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
|
||||
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast">
|
||||
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button>
|
||||
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button>
|
||||
<button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl" data-i18n="contrast_light">Hell</span></button>
|
||||
<button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl" data-i18n="contrast_max">Maximum</span></button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
|
||||
<p class="panel-desc" data-i18n="settings_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
|
||||
<div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast">
|
||||
<button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl" data-i18n="line_barely">Kaum</span></button>
|
||||
<button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl" data-i18n="line_subtle">Subtil</span></button>
|
||||
<button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl" data-i18n="line_normal">Normal</span></button>
|
||||
<button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl" data-i18n="line_strong">Stark</span></button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
||||
<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_default_view">Standardansicht</label>
|
||||
<select id="cfg-default-view">
|
||||
<option value="month" data-i18n="view_month">Monat</option>
|
||||
<option value="week" data-i18n="view_week">Woche</option>
|
||||
<option value="day" data-i18n="view_day">Tag</option>
|
||||
<option value="quarter" data-i18n="view_quarter">Quartal</option>
|
||||
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_week_start">Erster Wochentag</label>
|
||||
<select id="cfg-week-start">
|
||||
<option value="monday" data-i18n="week_start_monday">Montag</option>
|
||||
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="cfg-dim-past" />
|
||||
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="cfg-directory-hidden" />
|
||||
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span>
|
||||
</label>
|
||||
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
|
||||
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) -->
|
||||
<div class="settings-panel" id="settings-panel-general">
|
||||
<div class="sync-global-row">
|
||||
<div class="sync-global-text">
|
||||
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
|
||||
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
|
||||
</div>
|
||||
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
||||
</div>
|
||||
<div class="settings-table" id="settings-appearance-table"></div>
|
||||
<div class="theme-io-row">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
|
||||
<a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a>
|
||||
<input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
|
||||
<div class="settings-panel" id="settings-panel-accounts">
|
||||
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
||||
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
|
||||
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
|
||||
<div class="accounts-add-row">
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-birthday" data-i18n="birthday_add_btn">+ Geburtstage</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
|
||||
</div>
|
||||
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
|
||||
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
|
||||
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
|
||||
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading">Home Assistant</div>
|
||||
<div id="accounts-ha-list"><span class="accounts-section-empty">Keine HA-Konten</span></div>
|
||||
</div>
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="birthday_settings_title">Geburtstage</h4>
|
||||
<div id="birthday-settings"></div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzerverwaltung -->
|
||||
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
|
||||
<div class="settings-panel" id="settings-panel-users">
|
||||
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
|
||||
<div id="users-list"></div>
|
||||
@@ -723,6 +878,45 @@
|
||||
</label>
|
||||
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
||||
</div>
|
||||
|
||||
<!-- Instanz-Standardtheme -->
|
||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
|
||||
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
|
||||
<div id="admin-theme-grid" class="admin-theme-grid"></div>
|
||||
<div class="admin-theme-actions">
|
||||
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
|
||||
<button class="btn btn-secondary btn-sm" id="admin-theme-import" data-i18n="admin_theme_import">Theme importieren</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
|
||||
<input type="file" id="admin-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
|
||||
</div>
|
||||
|
||||
<!-- Branding: Logo & Favicon -->
|
||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
|
||||
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
|
||||
<div class="admin-brand-row">
|
||||
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
|
||||
<div class="admin-brand-preview admin-brand-preview-logo" id="admin-logo-preview"></div>
|
||||
<div class="admin-brand-controls">
|
||||
<div class="admin-brand-btns">
|
||||
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
|
||||
</div>
|
||||
<span class="admin-brand-dims" data-i18n="admin_logo_dims">PNG/JPEG/WebP · Anzeige max. 150 × 40 px</span>
|
||||
</div>
|
||||
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
|
||||
</div>
|
||||
<div class="admin-brand-row">
|
||||
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
|
||||
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
|
||||
<div class="admin-brand-controls">
|
||||
<div class="admin-brand-btns">
|
||||
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
|
||||
</div>
|
||||
<span class="admin-brand-dims" data-i18n="admin_favicon_dims">PNG/JPEG/WebP · 128 × 128 px (quadratisch)</span>
|
||||
</div>
|
||||
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- settings-panels -->
|
||||
@@ -755,16 +949,21 @@
|
||||
|
||||
<!-- Account Info -->
|
||||
<div class="settings-section">
|
||||
<h4>Konto</h4>
|
||||
<h4 data-i18n="profile_account">Konto</h4>
|
||||
<div class="form-group">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="profile-username" disabled class="input-disabled" />
|
||||
<label data-i18n="profile_display_name">Anzeigename</label>
|
||||
<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 class="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" />
|
||||
</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>
|
||||
|
||||
<!-- Password -->
|
||||
@@ -884,7 +1083,7 @@
|
||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
|
||||
<span id="impressum-version" style="font-size:12px;color:var(--text-3)">Calendarr</span>
|
||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,19 +50,58 @@ async function uploadRequest(path, formData) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
|
||||
// to the HTTP status so the message is diagnostic, not "unknown error".
|
||||
const err = await res.json().catch(() => null);
|
||||
const detail = (err && err.detail)
|
||||
? err.detail
|
||||
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadRequest(path, fallbackName) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
|
||||
if (res.status === 401) {
|
||||
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 = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
put: (path, body) => request('PUT', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
upload: (path, form) => uploadRequest(path, form),
|
||||
download: (path, name) => downloadRequest(path, name),
|
||||
|
||||
login: (username, password, totp_code = null, remember_me = false) =>
|
||||
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { api } from './api.js';
|
||||
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||
import { t } from './i18n.js';
|
||||
import { loadInstance } from './instance.js';
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
|
||||
// setup screens are already branded. Public endpoint — no token needed.
|
||||
loadInstance();
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
@@ -23,14 +27,23 @@ async function boot() {
|
||||
// Check if already logged in
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
let authed = false;
|
||||
try {
|
||||
await api.get('/auth/me'); // validate token
|
||||
await launchApp();
|
||||
return;
|
||||
await api.get('/auth/me'); // validate the TOKEN only
|
||||
authed = true;
|
||||
} catch (_) {
|
||||
// The token is genuinely invalid/expired — clear it and show login.
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
if (authed) {
|
||||
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
|
||||
// try/catch on purpose: a later data/render error inside app init must
|
||||
// never bounce a validly-authenticated user back to the login screen
|
||||
// (that was the "logged out on every reload" bug).
|
||||
await launchApp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showScreen('login');
|
||||
@@ -60,7 +73,7 @@ async function launchApp() {
|
||||
|
||||
// User dropdown menu
|
||||
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 => {
|
||||
e.stopPropagation();
|
||||
@@ -192,8 +205,23 @@ boot();
|
||||
|
||||
// ── Service Worker registration (PWA) ─────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Auto-update: when a new service worker takes control, reload once so the
|
||||
// page runs the fresh assets. Guarded so it never loops and never fires on
|
||||
// the very first install (when there was no previous controller).
|
||||
let refreshing = false;
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing || !hadController) return;
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => {
|
||||
// Check for a new SW now and hourly, so long-open tabs pick up releases.
|
||||
reg.update();
|
||||
setInterval(() => reg.update(), 60 * 60 * 1000);
|
||||
}).catch(err => {
|
||||
console.warn('SW registration failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
|
||||
* mode : 'datetime' | 'date'
|
||||
*/
|
||||
import { t } from './i18n.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
const ITEM_H = 40; // px per scroll item
|
||||
const VISIBLE = 3; // visible items in time scroller
|
||||
@@ -48,9 +48,10 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// Click outside → cancel
|
||||
// Click outside (leaving the picker) → commit the current selection,
|
||||
// rather than discarding it. Cancel button still reverts.
|
||||
overlay.addEventListener('mousedown', e => {
|
||||
if (e.target === overlay) done(null);
|
||||
if (e.target === overlay) done(buildResult());
|
||||
});
|
||||
|
||||
// ── Calendar builder ──────────────────────────────────
|
||||
@@ -144,7 +145,8 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||
render();
|
||||
};
|
||||
|
||||
// Day click
|
||||
// Day click → just select/highlight; committing happens on Save or when
|
||||
// the user leaves the picker (clicks outside).
|
||||
card.querySelectorAll('.dtp-day').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
selDate = new Date(parseInt(el.dataset.ts));
|
||||
@@ -248,16 +250,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||
/**
|
||||
* Format an ISO value for display in the UI
|
||||
* mode: 'datetime' | 'date'
|
||||
* lang: 'de' | 'en'
|
||||
* Formatting follows the active UI language via the i18n locale registry.
|
||||
*/
|
||||
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
||||
export function formatDtDisplay(isoStr, mode) {
|
||||
if (!isoStr) return '—';
|
||||
try {
|
||||
const d = mode === 'datetime'
|
||||
? new Date(isoStr.replace(' ', 'T'))
|
||||
: new Date(isoStr + 'T00:00:00');
|
||||
if (isNaN(d)) return isoStr;
|
||||
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
|
||||
const locale = getLocale();
|
||||
if (mode === 'datetime') {
|
||||
return d.toLocaleString(locale, {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
|
||||
3603
frontend/js/i18n.js
56
frontend/js/instance.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
|
||||
// early as possible so the login screen is already branded. See
|
||||
// backend/routers/admin_router.py.
|
||||
|
||||
import { setInstanceDefaults } from './settings-sync.js';
|
||||
import { setCustomFavicon, applyFavicon } from './utils.js';
|
||||
|
||||
export let instanceConfig = {};
|
||||
|
||||
// Cache the bundled default logo markup so "remove logo" can restore it.
|
||||
const originalLogoHtml = {};
|
||||
|
||||
function setBrandLogo(url) {
|
||||
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
|
||||
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
|
||||
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
|
||||
if (url) {
|
||||
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
|
||||
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
|
||||
} else {
|
||||
el.innerHTML = originalLogoHtml[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applyInstanceBranding(cfg) {
|
||||
cfg = cfg || {};
|
||||
setInstanceDefaults(cfg.default_theme || {});
|
||||
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
|
||||
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
|
||||
applyFavicon(); // no arg → uses instance/built-in primary as fallback
|
||||
}
|
||||
|
||||
// Memoised so boot() and initCalendar() share a single fetch; callers can await
|
||||
// it to guarantee instance defaults are set before the first applyTheme().
|
||||
let loadPromise = null;
|
||||
export function loadInstance() {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
|
||||
instanceConfig = res.ok ? await res.json() : {};
|
||||
} catch (_) {
|
||||
instanceConfig = {};
|
||||
}
|
||||
applyInstanceBranding(instanceConfig);
|
||||
return instanceConfig;
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
// Force a re-fetch (after an admin changes branding/theme).
|
||||
export function reloadInstance() {
|
||||
loadPromise = null;
|
||||
return loadInstance();
|
||||
}
|
||||
137
frontend/js/settings-sync.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Per-setting cross-device sync for the web client.
|
||||
//
|
||||
// The server is the sole authority for WHICH settings sync (it returns a fully
|
||||
// resolved `sync_flags` map). This module owns the browser-local copy of every
|
||||
// syncable value so that "not synced" works per browser, plus the declarative
|
||||
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
|
||||
|
||||
import { LANGUAGES } from './i18n.js';
|
||||
|
||||
// Canonical default colours — the single source for the web. Reset writes these.
|
||||
export const DEFAULT_COLORS = {
|
||||
primary_color: '#58B900',
|
||||
accent_color: '#45A148',
|
||||
today_color: '#6FB669',
|
||||
text_color: '#FFFFFF',
|
||||
bg_color: '#000000',
|
||||
line_color: '#3B3B3D',
|
||||
surface_color: '#2B2B2B',
|
||||
month_divider_color: '#95D25E',
|
||||
month_label_color: '#95D25E',
|
||||
// Fine-grained element colours (see THEME.md). day_selected/today_bg are
|
||||
// applied as a subtle tint of the chosen colour; the rest are applied solid.
|
||||
hover_highlight_color: '#2A2A38',
|
||||
icon_inactive_color: '#90AA91',
|
||||
icon_active_color: '#E8E8F0',
|
||||
day_hover_color: '#2B382A',
|
||||
day_selected_color: '#88EF9A',
|
||||
day_bg_color: '#000000',
|
||||
today_bg_color: '#477650',
|
||||
};
|
||||
|
||||
// The syncable settings the web client exposes, grouped into table sections.
|
||||
// `type`: 'select' | 'toggle' | 'color' | 'icon'.
|
||||
// Option labels use i18n keys via `tk`, or a literal `label`.
|
||||
export const SETTING_GROUPS = [
|
||||
{
|
||||
titleKey: 'settings_calendar_view',
|
||||
rows: [
|
||||
{ key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
|
||||
{ v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
|
||||
{ v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
|
||||
{ v: 'agenda', tk: 'view_agenda' },
|
||||
] },
|
||||
{ key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
|
||||
{ v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
|
||||
] },
|
||||
{ key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
|
||||
{ key: 'month_view_paged', labelKey: 'settings_month_mode', type: 'select', opts: [
|
||||
{ v: false, tk: 'settings_month_mode_scroll' }, { v: true, tk: 'settings_month_mode_paged' },
|
||||
] },
|
||||
{ key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
|
||||
{ v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
|
||||
{ v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
|
||||
] },
|
||||
{ key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
|
||||
{ v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
|
||||
{ v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings_language',
|
||||
rows: [
|
||||
// Derived from the i18n registry: adding a language there adds it here.
|
||||
// Each language is listed under its own endonym, so the labels are never
|
||||
// translated — a Finn looks for "Suomi", not "Finnisch".
|
||||
{ key: 'language', labelKey: 'settings_language', type: 'select',
|
||||
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
|
||||
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings_colors',
|
||||
rows: [
|
||||
{ key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
|
||||
{ key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
|
||||
{ key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
|
||||
{ key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
|
||||
{ key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
|
||||
{ key: 'surface_color', labelKey: 'settings_surface_color', type: 'color' },
|
||||
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
|
||||
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
|
||||
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
|
||||
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
|
||||
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
|
||||
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
|
||||
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
|
||||
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
|
||||
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
|
||||
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flat list of every syncable key the web manages (table order).
|
||||
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
|
||||
|
||||
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
|
||||
// the base a user inherits and the target that a per-colour "Reset" returns to.
|
||||
// A user's own value always overrides it. See backend/routers/admin_router.py.
|
||||
export let INSTANCE_DEFAULTS = {};
|
||||
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
|
||||
// The effective default for a colour key: admin instance default → built-in.
|
||||
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
|
||||
|
||||
const LOCAL_KEY = 'settingsLocal';
|
||||
|
||||
export function loadLocal() {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
|
||||
catch (_) { return {}; }
|
||||
}
|
||||
|
||||
export function saveLocal(obj) {
|
||||
try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
|
||||
}
|
||||
|
||||
// Effective value of a syncable key: synced → server value; otherwise the
|
||||
// browser-local value (falling back to the server value if we have none yet).
|
||||
export function effectiveValue(key, server, flags, local) {
|
||||
if (flags[key]) return server[key];
|
||||
return (key in local && local[key] != null) ? local[key] : server[key];
|
||||
}
|
||||
|
||||
// Build the effective settings object: a copy of the raw server settings with
|
||||
// each syncable key resolved to its effective value. As a side effect, mirror
|
||||
// every effective value into the local copy so that flipping a flag OFF later
|
||||
// retains the currently-visible value.
|
||||
export function mergeEffective(server, flags, local) {
|
||||
const eff = { ...server };
|
||||
for (const key of SYNCABLE_KEYS) {
|
||||
const val = effectiveValue(key, server, flags, local);
|
||||
eff[key] = val;
|
||||
if (val != null) local[key] = val;
|
||||
}
|
||||
saveLocal(local);
|
||||
return eff;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
|
||||
import { getLocale } from './i18n.js';
|
||||
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() &&
|
||||
@@ -16,8 +19,28 @@ export function isPast(ev) {
|
||||
return end < new Date();
|
||||
}
|
||||
|
||||
// Title to render for an event: the server-decorated one (birthday age, group
|
||||
// prefix) wins over the raw title, which stays untouched for editing.
|
||||
export function eventTitle(ev) {
|
||||
return ev.display_title || ev.title || '';
|
||||
}
|
||||
|
||||
// Small inline cake icon for birthday events, sized to the surrounding text and
|
||||
// tinted with the current text colour so it matches every bar it's dropped into.
|
||||
export function birthdayIconSvg() {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true" '
|
||||
+ 'style="width:0.85em;height:0.85em;vertical-align:-0.1em;margin-right:2px;flex:0 0 auto">'
|
||||
+ '<path fill="currentColor" d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97'
|
||||
+ 'c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0'
|
||||
+ 'l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16'
|
||||
+ 'c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6'
|
||||
+ 'c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13'
|
||||
+ 'c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12'
|
||||
+ 'c.01-1.66-1.33-3-2.99-3z"/></svg>';
|
||||
}
|
||||
|
||||
export function formatDate(d, opts = {}) {
|
||||
return d.toLocaleDateString('de', opts);
|
||||
return d.toLocaleDateString(getLocale(), opts);
|
||||
}
|
||||
|
||||
export function dateKey(d) {
|
||||
@@ -76,27 +99,201 @@ const LINE_CONTRAST = {
|
||||
4: { border: '#5a5a78', light: '#484860' },
|
||||
};
|
||||
|
||||
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
|
||||
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
|
||||
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
|
||||
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
|
||||
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||
|
||||
export function applyTheme(settings) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
||||
// Fallback chain for a colour that has no per-user value: admin instance
|
||||
// default → built-in default (baseColor()).
|
||||
const primary = settings.primary_color || baseColor('primary_color');
|
||||
root.style.setProperty('--primary', primary);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
|
||||
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
|
||||
|
||||
const tc = TEXT_CONTRAST[settings.text_contrast || 3];
|
||||
root.style.setProperty('--text-1', tc.t1);
|
||||
root.style.setProperty('--text-2', tc.t2);
|
||||
root.style.setProperty('--text-3', tc.t3);
|
||||
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||
let textColor = settings.text_color || baseColor('text_color');
|
||||
let lineColor = settings.line_color || baseColor('line_color');
|
||||
let bgColor = settings.bg_color || baseColor('bg_color');
|
||||
|
||||
const lc = LINE_CONTRAST[settings.line_contrast || 3];
|
||||
root.style.setProperty('--border', lc.border);
|
||||
root.style.setProperty('--border-light', lc.light);
|
||||
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||
// erzwinge weiss-auf-schwarz, damit man nicht in einer unbedienbaren
|
||||
// Seite landet.
|
||||
if (contrastRatio(textColor, bgColor) < 2.5) {
|
||||
textColor = DEFAULT_TEXT_COLOR;
|
||||
bgColor = DEFAULT_BG_COLOR;
|
||||
}
|
||||
|
||||
root.style.setProperty('--text-1', textColor);
|
||||
root.style.setProperty('--text-2', shadeHex(textColor, -0.25));
|
||||
root.style.setProperty('--text-3', shadeHex(textColor, -0.55));
|
||||
|
||||
root.style.setProperty('--border', lineColor);
|
||||
root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
|
||||
|
||||
// Surface family (sidebar / top bar / cards). Explicit surface_color drives
|
||||
// it; otherwise derive from the app background as before.
|
||||
const surfaceBase = settings.surface_color || shadeHex(bgColor, 0.10);
|
||||
root.style.setProperty('--bg-app', bgColor);
|
||||
root.style.setProperty('--bg-topbar', surfaceBase);
|
||||
root.style.setProperty('--bg-sidebar', surfaceBase);
|
||||
root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10));
|
||||
root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20));
|
||||
root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34));
|
||||
|
||||
const hh = settings.hour_height || 44;
|
||||
root.style.setProperty('--hour-h', hh + 'px');
|
||||
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color'));
|
||||
|
||||
// Fine-grained element colours. Each is applied ONLY when the user set an
|
||||
// explicit value; otherwise the :root default (which references a derived
|
||||
// variable) stays in effect, so the look is unchanged until customised.
|
||||
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
|
||||
// into a subtle tint via color-mix; the rest are applied as-is.
|
||||
// User value → admin instance default (if any). When neither is set the
|
||||
// property stays unset so the derived :root default keeps the current look.
|
||||
const setIf = (varName, key) => {
|
||||
const value = settings[key] || INSTANCE_DEFAULTS[key];
|
||||
if (value) root.style.setProperty(varName, value);
|
||||
};
|
||||
setIf('--hover-highlight', 'hover_highlight_color');
|
||||
setIf('--icon-inactive-color', 'icon_inactive_color');
|
||||
setIf('--icon-active-color', 'icon_active_color');
|
||||
setIf('--day-hover-color', 'day_hover_color');
|
||||
setIf('--day-selected-base', 'day_selected_color');
|
||||
setIf('--day-bg', 'day_bg_color');
|
||||
setIf('--today-bg-base', 'today_bg_color');
|
||||
}
|
||||
|
||||
// Instance custom favicon URL (set by the branding loader). When present it
|
||||
// overrides the primary-colour tinting.
|
||||
let customFaviconUrl = null;
|
||||
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
|
||||
|
||||
// Tint the favicon (and browser theme-colour) to the current primary colour so
|
||||
// the tab icon reflects the user's theme. Called at load and after saving —
|
||||
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
|
||||
export function applyFavicon(primaryColor) {
|
||||
const color = primaryColor || baseColor('primary_color');
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (customFaviconUrl) {
|
||||
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
|
||||
link.type = 'image/png';
|
||||
link.href = customFaviconUrl;
|
||||
} else {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
|
||||
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
|
||||
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
|
||||
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
|
||||
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', color);
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
const c = (n) => {
|
||||
const v = n / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const r = c(parseInt(hex.slice(1, 3), 16));
|
||||
const g = c(parseInt(hex.slice(3, 5), 16));
|
||||
const b = c(parseInt(hex.slice(5, 7), 16));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
function contrastRatio(c1, c2) {
|
||||
try {
|
||||
const l1 = luminance(c1);
|
||||
const l2 = luminance(c2);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
} catch { return 21; }
|
||||
}
|
||||
|
||||
// ── Safe HTML rendering for event descriptions ───────────────────────────
|
||||
// External calendars (CalDAV, iCal) often store rich-text descriptions as raw
|
||||
// HTML. We render a small allowlist of formatting tags so links/line breaks
|
||||
// look right, but strip everything dangerous (scripts, event handlers, inline
|
||||
// styles) — we never execute code from a description.
|
||||
const ALLOWED_TAGS = new Set(['A', 'BR', 'P', 'DIV', 'B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'SPAN']);
|
||||
const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"])/g;
|
||||
|
||||
function makeSafeLink(href, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.textContent = text;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
return a;
|
||||
}
|
||||
|
||||
// Replace bare URLs inside a text node with clickable <a> elements.
|
||||
function linkifyTextNode(node, out) {
|
||||
const text = node.nodeValue;
|
||||
let last = 0;
|
||||
let m;
|
||||
URL_RE.lastIndex = 0;
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
if (m.index > last) out.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||
out.appendChild(makeSafeLink(m[0], m[0]));
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.appendChild(document.createTextNode(text.slice(last)));
|
||||
}
|
||||
|
||||
// Recursively copy `src` into `dest`, keeping only allowlisted tags/attributes.
|
||||
function sanitizeInto(src, dest) {
|
||||
src.childNodes.forEach(node => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
linkifyTextNode(node, dest);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const tag = node.tagName;
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
// Drop the tag but keep its (sanitized) contents.
|
||||
sanitizeInto(node, dest);
|
||||
return;
|
||||
}
|
||||
let el;
|
||||
if (tag === 'A') {
|
||||
const href = node.getAttribute('href') || '';
|
||||
if (/^(https?:|mailto:)/i.test(href)) {
|
||||
el = makeSafeLink(href, '');
|
||||
} else {
|
||||
// Unsafe/relative href: render as plain text container.
|
||||
el = document.createElement('span');
|
||||
}
|
||||
} else {
|
||||
el = document.createElement(tag.toLowerCase());
|
||||
}
|
||||
sanitizeInto(node, el);
|
||||
dest.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a sanitized HTML string for an event description, safe for innerHTML.
|
||||
export function renderDescriptionHtml(raw) {
|
||||
if (!raw) return '';
|
||||
const hasHtml = /<[a-z][\s\S]*>/i.test(raw);
|
||||
const doc = new DOMParser().parseFromString(
|
||||
hasHtml ? raw : raw.replace(/\n/g, '<br>'), 'text/html');
|
||||
const out = document.createElement('div');
|
||||
sanitizeInto(doc.body, out);
|
||||
return out.innerHTML;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
@@ -105,3 +302,24 @@ function hexToRgba(hex, alpha) {
|
||||
const b = parseInt(hex.slice(5,7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
// Brighten (positive amount) or darken (negative) a hex colour.
|
||||
// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
|
||||
// from a single user-picked colour so the whole UI stays in the same family.
|
||||
function shadeHex(hex, amount) {
|
||||
let r = parseInt(hex.slice(1,3), 16);
|
||||
let g = parseInt(hex.slice(3,5), 16);
|
||||
let b = parseInt(hex.slice(5,7), 16);
|
||||
if (amount >= 0) {
|
||||
r = Math.round(r + (255 - r) * amount);
|
||||
g = Math.round(g + (255 - g) * amount);
|
||||
b = Math.round(b + (255 - b) * amount);
|
||||
} else {
|
||||
const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
|
||||
r = Math.round(r * a);
|
||||
g = Math.round(g * a);
|
||||
b = Math.round(b * a);
|
||||
}
|
||||
const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
|
||||
return '#' + h(r) + h(g) + h(b);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v11';
|
||||
export const APP_VERSION = 'v90';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isPast } from '../utils.js';
|
||||
import { t, getLang } from '../i18n.js';
|
||||
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
if (!events.length) {
|
||||
@@ -45,13 +45,13 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
|
||||
<div class="agenda-ev-color" style="background:${color}"></div>
|
||||
<div class="agenda-ev-info">
|
||||
<div class="agenda-ev-title">${escHtml(ev.title)}</div>
|
||||
<div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</div>
|
||||
<div class="agenda-ev-meta">${timeStr}${locHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="agenda-day">
|
||||
return `<div class="agenda-day" data-date="${key}">
|
||||
<div class="agenda-date ${todayCls}">
|
||||
<div class="agenda-date-num">${date.getDate()}</div>
|
||||
<div class="agenda-date-label">
|
||||
@@ -65,6 +65,19 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
|
||||
container.innerHTML = `<div class="agenda-view">${html}</div>`;
|
||||
|
||||
// The agenda lists the whole cached range (past + future). Scroll so the
|
||||
// current date (e.g. "today" after the Today button) sits at the top — or
|
||||
// the next day with events if the current date itself has none.
|
||||
const scrollEl = container.querySelector('.agenda-view');
|
||||
if (scrollEl) {
|
||||
const curKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth()+1).padStart(2,'0')}-${String(currentDate.getDate()).padStart(2,'0')}`;
|
||||
const days = [...scrollEl.querySelectorAll('.agenda-day')];
|
||||
const target = days.find(d => d.dataset.date >= curKey) || days[days.length - 1];
|
||||
if (target) {
|
||||
scrollEl.scrollTop += target.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top;
|
||||
}
|
||||
}
|
||||
|
||||
container.querySelectorAll('.agenda-event').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||
@@ -81,7 +94,7 @@ function isTodayDate(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||
const DAY_H = 30; // day-number row height
|
||||
@@ -71,13 +71,21 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
return new Date(a.ev.start) - new Date(b.ev.start);
|
||||
});
|
||||
|
||||
// Assign lanes (greedy interval packing)
|
||||
const lanes = [];
|
||||
// Assign lanes using a 2D occupancy grid (per-column tracking).
|
||||
// This prevents gaps: an event in cols 3-6 no longer blocks col 0-2 in the same lane.
|
||||
const occupiedGrid = []; // occupiedGrid[lane] = Set<col>
|
||||
rowItems.forEach(item => {
|
||||
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
||||
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
||||
item.lane = laneIdx;
|
||||
lanes[laneIdx].colEnd = item.colStart + item.span;
|
||||
const cols = Array.from({ length: item.span }, (_, i) => item.colStart + i);
|
||||
let laneIdx = 0;
|
||||
for (;;) {
|
||||
if (!occupiedGrid[laneIdx]) occupiedGrid[laneIdx] = new Set();
|
||||
if (cols.every(c => !occupiedGrid[laneIdx].has(c))) {
|
||||
cols.forEach(c => occupiedGrid[laneIdx].add(c));
|
||||
item.lane = laneIdx;
|
||||
break;
|
||||
}
|
||||
laneIdx++;
|
||||
}
|
||||
});
|
||||
|
||||
// Track overflow per column
|
||||
@@ -102,14 +110,15 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const cL = continuesLeft ? 'continues-left' : '';
|
||||
const cR = continuesRight ? 'continues-right' : '';
|
||||
const titleEsc = escHtml(ev.title);
|
||||
const titleEsc = escHtml(eventTitle(ev));
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
const labelHtml = ev.allDay
|
||||
? titleEsc
|
||||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
|
||||
? icon + titleEsc
|
||||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${titleEsc}`;
|
||||
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
|
||||
title="${escAttr(ev.title)}">${labelHtml}</div>`;
|
||||
title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`;
|
||||
});
|
||||
|
||||
// "+N more" per column
|
||||
@@ -149,11 +158,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
|
||||
const dividerCls = dividerClasses.join(' ');
|
||||
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}">
|
||||
${monthLabel}
|
||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||
${monthLabel}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
@@ -190,11 +199,20 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
if (ev) onEventClick(ev, spanEl);
|
||||
return;
|
||||
}
|
||||
// "+N more" → navigate to day view
|
||||
// "+N more" → show overflow popup with all events for that day
|
||||
const moreEl = e.target.closest('.month-more');
|
||||
if (moreEl) {
|
||||
e.stopPropagation();
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
|
||||
const dayDate = new Date(moreEl.dataset.date + 'T00:00:00');
|
||||
const dayEvents = normed
|
||||
.filter(({ ns, ne }) => ns <= dayDate && ne >= dayDate)
|
||||
.map(({ ev }) => ev)
|
||||
.sort((a, b) => {
|
||||
if (a.allDay && !b.allDay) return -1;
|
||||
if (!a.allDay && b.allDay) return 1;
|
||||
return new Date(a.start) - new Date(b.start);
|
||||
});
|
||||
showOverflowPopup(moreEl, dayDate, dayEvents, onEventClick);
|
||||
return;
|
||||
}
|
||||
// Column click → select day
|
||||
@@ -233,7 +251,7 @@ function dateKey(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
@@ -243,3 +261,91 @@ function escHtml(s) {
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function showOverflowPopup(anchor, date, events, onEventClick) {
|
||||
document.querySelectorAll('.month-overflow-popup').forEach(p => p.remove());
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'month-overflow-popup';
|
||||
|
||||
// Header: "Mo, 3. Jul"
|
||||
const months = t('months');
|
||||
const dow = t('dow_monday');
|
||||
const dowIdx = (date.getDay() + 6) % 7; // 0=Mon
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mop-header';
|
||||
header.textContent = `${dow[dowIdx]}, ${date.getDate()}. ${months[date.getMonth()]}`;
|
||||
popup.appendChild(header);
|
||||
|
||||
events.forEach(ev => {
|
||||
const evStart = new Date(ev.start); evStart.setHours(0, 0, 0, 0);
|
||||
const evEnd = new Date(ev.end); evEnd.setHours(0, 0, 0, 0);
|
||||
// allDay end from API is exclusive → actual last day = evEnd - 1d
|
||||
const lastDay = ev.allDay ? new Date(evEnd.getTime() - 86400000) : evEnd;
|
||||
const continuesLeft = evStart < date;
|
||||
const continuesRight = lastDay > date;
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
|
||||
if (ev.allDay) {
|
||||
// All-day events → colored bar with continuation arrows
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'mop-bar'
|
||||
+ (continuesLeft ? ' continues-left' : '')
|
||||
+ (continuesRight ? ' continues-right' : '');
|
||||
bar.style.background = color;
|
||||
bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||
bar.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
popup.remove();
|
||||
onEventClick(ev, bar);
|
||||
});
|
||||
popup.appendChild(bar);
|
||||
} else {
|
||||
// Timed events → dot + time + title
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mop-row';
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mop-dot';
|
||||
dot.style.background = color;
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'mop-time';
|
||||
time.textContent = fmtTime(new Date(ev.start));
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'mop-title';
|
||||
title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||
|
||||
row.append(dot, time, title);
|
||||
row.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
popup.remove();
|
||||
onEventClick(ev, row);
|
||||
});
|
||||
popup.appendChild(row);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(popup);
|
||||
|
||||
// Position near anchor, stay in viewport
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const pw = popup.offsetWidth || 260;
|
||||
const ph = popup.offsetHeight || 200;
|
||||
let left = r.left;
|
||||
let top = r.bottom + 4;
|
||||
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
|
||||
if (top + ph > window.innerHeight - 8) top = r.top - ph - 4;
|
||||
if (left < 8) left = 8;
|
||||
if (top < 8) top = 8;
|
||||
popup.style.left = left + 'px';
|
||||
popup.style.top = top + 'px';
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', function close() {
|
||||
popup.remove();
|
||||
document.removeEventListener('click', close);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isToday, isPast, dayOfWeek } from '../utils.js';
|
||||
import { isToday, isPast, dayOfWeek, eventTitle } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||
@@ -64,7 +64,7 @@ export function renderQuarter(container, currentDate, events, onDayClick, onEven
|
||||
const dots = cellEvs.slice(0, 3).map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||
}).join('');
|
||||
const moreDot = cellEvs.length > 3
|
||||
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
|
||||
// Build the days array (7 days for week, 1 for day)
|
||||
@@ -63,14 +63,26 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const multiCls = isMultiTimed ? 'multiday-timed' : '';
|
||||
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' : '';
|
||||
// continues-left/right: compute on date-only basis for all-day events
|
||||
let evStart = new Date(ev.start);
|
||||
let evEnd = new Date(ev.end);
|
||||
if (ev.allDay) {
|
||||
evStart.setHours(0, 0, 0, 0);
|
||||
evEnd.setHours(0, 0, 0, 0);
|
||||
if (evEnd > evStart) evEnd.setDate(evEnd.getDate() - 1);
|
||||
}
|
||||
const firstDay = new Date(days[0]); firstDay.setHours(0, 0, 0, 0);
|
||||
const lastDayMidnight = new Date(days[n-1]); lastDayMidnight.setHours(24, 0, 0, 0);
|
||||
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
|
||||
const cL = evStart < firstDay ? 'continues-left' : '';
|
||||
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
|
||||
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
|
||||
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
||||
: ev.title;
|
||||
? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}`
|
||||
: eventTitle(ev);
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
|
||||
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`;
|
||||
}).join('');
|
||||
|
||||
const alldayBgCols = days.map(day =>
|
||||
@@ -113,12 +125,17 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const startStr = fmtTime(s);
|
||||
const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
|
||||
return `<div class="timed-event ${pastCls}"
|
||||
// Short events lack the vertical room to stack time over title, so render
|
||||
// them on one line (time next to title) and drop the location.
|
||||
const isShort = height < 34;
|
||||
const shortCls = isShort ? 'short' : '';
|
||||
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
return `<div class="timed-event ${pastCls} ${shortCls}"
|
||||
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">
|
||||
<div class="ev-time">${startStr}</div>
|
||||
<div class="ev-title">${escHtml(ev.title)}</div>
|
||||
<div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div>
|
||||
${locHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -236,11 +253,28 @@ function renderNowLine(container, days, hourH = 60) {
|
||||
function layoutWeekAllDay(evs, days) {
|
||||
const items = [];
|
||||
evs.forEach(ev => {
|
||||
// For all-day events, normalize to date-only with inclusive end-day
|
||||
// (iCal stores exclusive end → subtract 1). For timed events, keep
|
||||
// the original strict-overlap logic so events ending exactly at
|
||||
// midnight don't bleed into the next day.
|
||||
let ns, ne;
|
||||
if (ev.allDay) {
|
||||
ns = new Date(ev.start); ns.setHours(0, 0, 0, 0);
|
||||
ne = new Date(ev.end); ne.setHours(0, 0, 0, 0);
|
||||
if (ne > ns) ne.setDate(ne.getDate() - 1);
|
||||
}
|
||||
|
||||
let colStart = -1, colEnd = -1;
|
||||
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) {
|
||||
let matches;
|
||||
if (ev.allDay) {
|
||||
matches = ds >= ns && ds <= ne;
|
||||
} else {
|
||||
const de = new Date(day); de.setHours(24, 0, 0, 0);
|
||||
matches = new Date(ev.start) < de && new Date(ev.end) > ds;
|
||||
}
|
||||
if (matches) {
|
||||
if (colStart === -1) colStart = i;
|
||||
colEnd = i;
|
||||
}
|
||||
@@ -318,7 +352,7 @@ function isSameDay(a, b) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Calendarr",
|
||||
"short_name": "Calendarr",
|
||||
"description": "Dein privater, selbst gehosteter Kalender.",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0e0e14",
|
||||
"theme_color": "#4285f4",
|
||||
"theme_color": "#16713d",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
@@ -21,10 +26,16 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
107
frontend/sw.js
@@ -1,39 +1,21 @@
|
||||
// Calendarr Service Worker
|
||||
// Cache-first for static assets, network-first for /api/* (graceful offline)
|
||||
// Calendarr Service Worker — minimal-cache strategy
|
||||
//
|
||||
// Strategy: network-first for everything. The cache is only used as a
|
||||
// last-resort fallback when offline (so the app shell still opens). This
|
||||
// means every online request hits the network and respects the
|
||||
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
|
||||
// the entry HTML / version files). New releases take effect on the next
|
||||
// reload, no manual SW unregister required.
|
||||
|
||||
const CACHE_VERSION = 'calendarr-v11';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/static/css/app.css',
|
||||
'/static/favicon.svg',
|
||||
'/static/js/app.js',
|
||||
'/static/js/api.js',
|
||||
'/static/js/calendar.js',
|
||||
'/static/js/color-picker.js',
|
||||
'/static/js/date-picker.js',
|
||||
'/static/js/i18n.js',
|
||||
'/static/js/utils.js',
|
||||
'/static/js/version.js',
|
||||
'/static/js/views/agenda.js',
|
||||
'/static/js/views/month.js',
|
||||
'/static/js/views/quarter.js',
|
||||
'/static/js/views/week.js',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon.svg',
|
||||
];
|
||||
const CACHE_VERSION = 'calendarr-v36';
|
||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_VERSION).then(cache =>
|
||||
// Use addAll with a fallback so a single missing file doesn't abort install
|
||||
Promise.all(
|
||||
STATIC_ASSETS.map(url =>
|
||||
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||
)
|
||||
)
|
||||
Promise.all(OFFLINE_SHELL.map(url =>
|
||||
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||
))
|
||||
).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
@@ -52,7 +34,8 @@ self.addEventListener('fetch', event => {
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Network-first for API routes — fail silently if offline
|
||||
// API routes: always go to the network, no offline fallback (we'd just
|
||||
// be returning stale account/event data otherwise).
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(req).catch(() =>
|
||||
@@ -65,45 +48,29 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for navigation (HTML) and the version-defining files —
|
||||
// ensures users always get the freshest entry point so new releases
|
||||
// take effect on the next reload without a manual SW unregister.
|
||||
const isHtml = req.mode === 'navigate'
|
||||
|| url.pathname === '/'
|
||||
|| url.pathname === '/index.html';
|
||||
const isVersionFile = url.pathname === '/static/js/version.js';
|
||||
|
||||
if (isHtml || isVersionFile) {
|
||||
event.respondWith(
|
||||
fetch(req).then(resp => {
|
||||
if (resp && resp.status === 200) {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||
}
|
||||
return resp;
|
||||
}).catch(() =>
|
||||
caches.match(req).then(c => c || caches.match('/index.html'))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for everything else (static)
|
||||
// Everything else: network-first. The browser's HTTP cache (driven by
|
||||
// the server's Cache-Control headers) already throttles re-fetches —
|
||||
// the SW just makes sure offline still works for the entry HTML.
|
||||
event.respondWith(
|
||||
caches.match(req).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(req).then(resp => {
|
||||
// Only cache successful, basic-origin responses
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||
}
|
||||
return resp;
|
||||
}).catch(() => {
|
||||
// Offline fallback for navigation requests
|
||||
if (req.mode === 'navigate') return caches.match('/index.html');
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
fetch(req).then(resp => {
|
||||
// Keep a fresh copy of navigation requests / index.html for offline
|
||||
const isNavigation = req.mode === 'navigate'
|
||||
|| url.pathname === '/'
|
||||
|| url.pathname === '/index.html';
|
||||
if (isNavigation && resp && resp.status === 200) {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||
}
|
||||
return resp;
|
||||
}).catch(() => {
|
||||
// Offline fallback: only the HTML shell is served from cache, so the
|
||||
// app at least renders and can show its own offline UI.
|
||||
if (req.mode === 'navigate'
|
||||
|| url.pathname === '/'
|
||||
|| url.pathname === '/index.html') {
|
||||
return caches.match(req).then(c => c || caches.match('/index.html'));
|
||||
}
|
||||
return new Response('', { status: 503 });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
3
requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
httpx>=0.27
|
||||