Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
205
backend/ical_io.py
Normal file
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")
|
||||||
213
backend/local_events_util.py
Normal file
213
backend/local_events_util.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Shared builders for local-event API dicts.
|
||||||
|
|
||||||
|
Every local event returned by the API (the local router, the unified event
|
||||||
|
merge in caldav_router, and the group combined view) must look identical and
|
||||||
|
carry the additive collaboration fields: ``creator``, ``private``, ``type``,
|
||||||
|
and — in the group view — ``owner`` and ``is_group_event``.
|
||||||
|
|
||||||
|
Centralising this avoids the three near-duplicate dict constructions that used
|
||||||
|
to live in caldav_router.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]:
|
||||||
|
"""Build the ``creator`` payload for an event.
|
||||||
|
|
||||||
|
Returns ``{"id": int, "display_name": username}`` for a local creator,
|
||||||
|
``{"id": None, "display_name": "<name> (importiert)"}`` for an imported
|
||||||
|
event, or ``None`` when no creator info exists (legacy events).
|
||||||
|
|
||||||
|
``name_cache`` maps user_id -> username to avoid per-event DB lookups; the
|
||||||
|
creator relationship is used as a fallback.
|
||||||
|
"""
|
||||||
|
if ev.creator_id:
|
||||||
|
display = None
|
||||||
|
if name_cache is not None:
|
||||||
|
display = name_cache.get(ev.creator_id)
|
||||||
|
if display is None and ev.creator is not None:
|
||||||
|
display = ev.creator.display_name or ev.creator.username
|
||||||
|
if display is not None:
|
||||||
|
return {"id": ev.creator_id, "display_name": display}
|
||||||
|
if ev.creator_name_external:
|
||||||
|
return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def private_visibility_for(db: Session, user_id: int) -> str:
|
||||||
|
"""A user's chosen visibility for their private events ('hidden' | 'busy')."""
|
||||||
|
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
|
||||||
|
return (s.private_event_visibility if s else None) or "busy"
|
||||||
|
|
||||||
|
|
||||||
|
# Only these fields survive 'busy' anonymisation — a whitelist, so no content
|
||||||
|
# field (title/location/description/creator/calendar name/recurrence) can leak.
|
||||||
|
_BUSY_KEEP = {
|
||||||
|
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
|
||||||
|
"source", "type", "owner", "is_group_event", "display_color",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mask_busy_event(event: dict) -> dict:
|
||||||
|
"""Anonymise a private event for 'busy' visibility: keep only timing /
|
||||||
|
identity / render fields, drop ALL content."""
|
||||||
|
masked = {k: event[k] for k in _BUSY_KEEP if k in event}
|
||||||
|
masked["title"] = "Beschäftigt"
|
||||||
|
masked["location"] = ""
|
||||||
|
masked["description"] = ""
|
||||||
|
masked["calendar_name"] = ""
|
||||||
|
masked["creator"] = None
|
||||||
|
masked["color"] = None
|
||||||
|
masked["rrule"] = None
|
||||||
|
masked["exdate"] = None
|
||||||
|
masked["private"] = True
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def apply_event_privacy(
|
||||||
|
event: dict, *, owner_id, is_private: bool, requester_id: int, visibility: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Enforce another user's private-event visibility on a built event dict.
|
||||||
|
|
||||||
|
Returns the event unchanged for the requester's own events or non-private
|
||||||
|
events, ``None`` when the owner chose 'hidden', or a busy-masked copy when
|
||||||
|
the owner chose 'busy'. Used by BOTH the merge read and the combined view so
|
||||||
|
the privacy rule can never drift between them.
|
||||||
|
"""
|
||||||
|
if not is_private or owner_id == requester_id:
|
||||||
|
return event
|
||||||
|
if visibility == "hidden":
|
||||||
|
return None
|
||||||
|
return mask_busy_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
def build_local_event_dict(
|
||||||
|
ev: models.LocalEvent,
|
||||||
|
cal: models.LocalCalendar,
|
||||||
|
*,
|
||||||
|
start: Optional[str] = None,
|
||||||
|
end: Optional[str] = None,
|
||||||
|
all_day: Optional[bool] = None,
|
||||||
|
rrule: Optional[str] = ...,
|
||||||
|
creator: Optional[dict] = None,
|
||||||
|
owner: Optional[dict] = None,
|
||||||
|
is_group_event: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Build the unified dict for a single local event (or occurrence).
|
||||||
|
|
||||||
|
``start``/``end``/``all_day`` override the stored values (used when emitting
|
||||||
|
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
|
||||||
|
by the group combined view.
|
||||||
|
"""
|
||||||
|
d = {
|
||||||
|
"id": ev.uid,
|
||||||
|
"url": f"local://{ev.uid}",
|
||||||
|
"title": ev.title,
|
||||||
|
"start": ev.start if start is None else start,
|
||||||
|
"end": ev.end if end is None else end,
|
||||||
|
"allDay": ev.all_day if all_day is None else all_day,
|
||||||
|
"location": ev.location or "",
|
||||||
|
"description": ev.description or "",
|
||||||
|
"color": ev.color,
|
||||||
|
"rrule": ev.rrule if rrule is ... else rrule,
|
||||||
|
"exdate": ev.exdate,
|
||||||
|
"calendar_id": f"local-{cal.id}",
|
||||||
|
"calendar_name": cal.name,
|
||||||
|
"calendarColor": cal.color,
|
||||||
|
"source": "local",
|
||||||
|
"type": "local",
|
||||||
|
"creator": creator,
|
||||||
|
"private": bool(ev.is_private),
|
||||||
|
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
|
||||||
|
}
|
||||||
|
if owner is not None:
|
||||||
|
d["owner"] = owner
|
||||||
|
if is_group_event:
|
||||||
|
d["is_group_event"] = True
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def expand_recurring_local(
|
||||||
|
ev: models.LocalEvent,
|
||||||
|
local_cal: models.LocalCalendar,
|
||||||
|
range_start,
|
||||||
|
range_end,
|
||||||
|
*,
|
||||||
|
creator: Optional[dict] = None,
|
||||||
|
owner: Optional[dict] = None,
|
||||||
|
is_group_event: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""Expand a recurring LocalEvent into individual occurrences in the range."""
|
||||||
|
results = []
|
||||||
|
excluded = set()
|
||||||
|
if ev.exdate:
|
||||||
|
for d in ev.exdate.split(","):
|
||||||
|
d = d.strip()
|
||||||
|
if d:
|
||||||
|
excluded.add(d)
|
||||||
|
try:
|
||||||
|
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||||
|
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||||
|
|
||||||
|
if ev.all_day:
|
||||||
|
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
||||||
|
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
||||||
|
duration = ev_end - ev_start
|
||||||
|
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
||||||
|
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
||||||
|
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
||||||
|
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||||
|
for occ in occurrences:
|
||||||
|
occ_start = occ.date()
|
||||||
|
occ_key = occ_start.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
|
occ_end = occ_start + duration
|
||||||
|
results.append(build_local_event_dict(
|
||||||
|
ev, local_cal,
|
||||||
|
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
|
||||||
|
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||||
|
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
||||||
|
if ev_start.tzinfo is None:
|
||||||
|
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
||||||
|
if ev_end.tzinfo is None:
|
||||||
|
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
||||||
|
duration = ev_end - ev_start
|
||||||
|
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
||||||
|
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||||
|
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||||
|
if r_start.tzinfo is None:
|
||||||
|
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
||||||
|
if r_end.tzinfo is None:
|
||||||
|
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||||
|
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||||
|
for occ in occurrences:
|
||||||
|
occ_key = occ.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
|
occ_end = occ + duration
|
||||||
|
results.append(build_local_event_dict(
|
||||||
|
ev, local_cal,
|
||||||
|
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
|
||||||
|
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||||
|
# Fall back to a single event.
|
||||||
|
results.append(build_local_event_dict(
|
||||||
|
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
|
||||||
|
))
|
||||||
|
return results
|
||||||
131
backend/main.py
131
backend/main.py
@@ -4,15 +4,20 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# How long the browser may keep static assets before revalidating.
|
||||||
|
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
|
||||||
|
NO_CACHE = "no-cache, no-store, must-revalidate"
|
||||||
|
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -97,6 +102,20 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN reminders TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added reminders to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_reminder_minutes INTEGER"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added default_reminder_minutes to user_settings")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -109,16 +128,124 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Collaboration features (sharing, groups, creator, private) ──
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added private_event_visibility to user_settings")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added creator_id to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added creator_name_external to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added is_private to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added group_visible_calendar_id to user_settings")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added display_name to users")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Backfill display_name from username for existing rows (only where empty).
|
||||||
|
try:
|
||||||
|
conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added icon to groups")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added color to group_members")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_cache_headers(request: Request, call_next):
|
||||||
|
"""Force ≤ 2h browser cache for static assets and disable cache for the
|
||||||
|
entry HTML / SW / version file. API responses are left alone (handlers
|
||||||
|
decide their own caching)."""
|
||||||
|
response = await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Never cache: entry HTML, manifest, service worker, version marker
|
||||||
|
if (
|
||||||
|
path in ("/", "/index.html", "/manifest.json", "/sw.js")
|
||||||
|
or path == "/static/js/version.js"
|
||||||
|
):
|
||||||
|
response.headers["Cache-Control"] = NO_CACHE
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
# JS/CSS must revalidate on every load so a deploy takes effect on the next
|
||||||
|
# reload (returns a cheap 304 when unchanged). Without this, a fresh
|
||||||
|
# no-cache index.html could pair with stale 2h-cached scripts.
|
||||||
|
elif path.startswith("/static/js/") or path.startswith("/static/css/"):
|
||||||
|
response.headers["Cache-Control"] = NO_CACHE
|
||||||
|
# 2h cache for the rest of the frontend (icons, fonts, images, …)
|
||||||
|
elif path.startswith("/static/") or path.startswith("/icons/"):
|
||||||
|
response.headers["Cache-Control"] = STATIC_CACHE
|
||||||
|
# SPA fallback (everything else that isn't an API route) returns HTML;
|
||||||
|
# don't let the browser cache that either.
|
||||||
|
elif not path.startswith("/api/"):
|
||||||
|
response.headers["Cache-Control"] = NO_CACHE
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
||||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||||
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||||
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
||||||
|
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
@@ -7,7 +7,10 @@ class User(Base):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
# Login name: always lowercase, unique, used for authentication.
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
|
# Human-facing name with original casing; editable. Falls back to username.
|
||||||
|
display_name = Column(String(100), nullable=True)
|
||||||
email = Column(String(100), unique=True, nullable=True)
|
email = Column(String(100), unique=True, nullable=True)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
@@ -34,6 +37,11 @@ class User(Base):
|
|||||||
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
|
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display(self) -> str:
|
||||||
|
"""The name to show users: display_name if set, else the login name."""
|
||||||
|
return self.display_name or self.username
|
||||||
|
|
||||||
|
|
||||||
class CalDAVAccount(Base):
|
class CalDAVAccount(Base):
|
||||||
__tablename__ = "caldav_accounts"
|
__tablename__ = "caldav_accounts"
|
||||||
@@ -84,6 +92,18 @@ class UserSettings(Base):
|
|||||||
language = Column(String(5), default="de")
|
language = Column(String(5), default="de")
|
||||||
month_divider_color = Column(String(7), default="#7090c0")
|
month_divider_color = Column(String(7), default="#7090c0")
|
||||||
month_label_color = Column(String(7), default="#7090c0")
|
month_label_color = Column(String(7), default="#7090c0")
|
||||||
|
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
|
||||||
|
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
|
||||||
|
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
||||||
|
# How this user's private events appear to other group members:
|
||||||
|
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
||||||
|
private_event_visibility = Column(String(10), default="busy")
|
||||||
|
# The single local calendar this user shares into all their groups
|
||||||
|
# (combined view shows only this calendar per member). NULL = share nothing.
|
||||||
|
group_visible_calendar_id = Column(Integer, nullable=True)
|
||||||
|
# Default reminder in minutes-before-start applied to all events client-side
|
||||||
|
# (0 = at start time). NULL = no default reminder.
|
||||||
|
default_reminder_minutes = Column(Integer, nullable=True)
|
||||||
|
|
||||||
user = relationship("User", back_populates="settings")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
@@ -116,8 +136,17 @@ class LocalEvent(Base):
|
|||||||
color = Column(String(7), nullable=True)
|
color = Column(String(7), nullable=True)
|
||||||
rrule = Column(Text, nullable=True)
|
rrule = Column(Text, nullable=True)
|
||||||
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
|
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
|
||||||
|
# Comma-separated minutes-before-start for reminders, e.g. "10,60" (0 = at start).
|
||||||
|
reminders = Column(Text, nullable=True)
|
||||||
|
# Creator: set server-side from the auth token on create, never from the client.
|
||||||
|
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
# For imported events without a local user (from the .ics ORGANIZER field).
|
||||||
|
creator_name_external = Column(Text, nullable=True)
|
||||||
|
# Private events are filtered for other group members per their visibility setting.
|
||||||
|
is_private = Column(Boolean, default=False)
|
||||||
|
|
||||||
calendar = relationship("LocalCalendar", back_populates="events")
|
calendar = relationship("LocalCalendar", back_populates="events")
|
||||||
|
creator = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class ICalSubscription(Base):
|
class ICalSubscription(Base):
|
||||||
@@ -216,3 +245,76 @@ class HomeAssistantCalendar(Base):
|
|||||||
sidebar_hidden = Column(Boolean, default=False)
|
sidebar_hidden = Column(Boolean, default=False)
|
||||||
|
|
||||||
account = relationship("HomeAssistantAccount", back_populates="calendars")
|
account = relationship("HomeAssistantAccount", back_populates="calendars")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarShare(Base):
|
||||||
|
"""A local calendar shared with another Calendarr user."""
|
||||||
|
|
||||||
|
__tablename__ = "calendar_shares"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
permission = Column(String(20), default="read") # 'read' | 'read_write'
|
||||||
|
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||||
|
|
||||||
|
calendar = relationship("LocalCalendar")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
icon = Column(String(16), nullable=True) # emoji shown for the group
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||||
|
|
||||||
|
members = relationship(
|
||||||
|
"GroupMember", back_populates="group", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
group_calendar = relationship(
|
||||||
|
"GroupCalendar", back_populates="group", uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(Base):
|
||||||
|
__tablename__ = "group_members"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
role = Column(String(10), default="member") # 'owner' | 'member'
|
||||||
|
color = Column(String(7), nullable=True) # this member's colour within the group
|
||||||
|
joined_at = Column(String(50), nullable=True) # ISO 8601
|
||||||
|
|
||||||
|
group = relationship("Group", back_populates="members")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCalendar(Base):
|
||||||
|
"""1:1 link between a group and its shared local calendar."""
|
||||||
|
|
||||||
|
__tablename__ = "group_calendars"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("group_id", name="uq_group_calendar_group"),
|
||||||
|
UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
|
||||||
|
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||||
|
|
||||||
|
group = relationship("Group", back_populates="group_calendar")
|
||||||
|
calendar = relationship("LocalCalendar")
|
||||||
|
|||||||
126
backend/permissions.py
Normal file
126
backend/permissions.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Central access control for local calendars.
|
||||||
|
|
||||||
|
Local calendars are visible/writable to a user if any of the following holds:
|
||||||
|
- the user owns the calendar (LocalCalendar.user_id),
|
||||||
|
- the calendar is shared with the user (calendar_shares; write needs 'read_write'),
|
||||||
|
- the calendar is a group calendar and the user is a member of that group
|
||||||
|
(members get read & write).
|
||||||
|
|
||||||
|
These helpers replace the scattered owner-only filters so sharing and groups
|
||||||
|
work consistently across every local-calendar endpoint and the event merge read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]:
|
||||||
|
return (
|
||||||
|
db.query(models.CalendarShare)
|
||||||
|
.filter(
|
||||||
|
models.CalendarShare.calendar_id == calendar_id,
|
||||||
|
models.CalendarShare.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool:
|
||||||
|
gc = (
|
||||||
|
db.query(models.GroupCalendar)
|
||||||
|
.filter(models.GroupCalendar.calendar_id == calendar_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not gc:
|
||||||
|
return False
|
||||||
|
member = (
|
||||||
|
db.query(models.GroupMember)
|
||||||
|
.filter(
|
||||||
|
models.GroupMember.group_id == gc.group_id,
|
||||||
|
models.GroupMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return member is not None
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_local_calendar(
|
||||||
|
db: Session,
|
||||||
|
user: models.User,
|
||||||
|
calendar_id: int,
|
||||||
|
*,
|
||||||
|
require_write: bool = False,
|
||||||
|
) -> models.LocalCalendar:
|
||||||
|
"""Return the calendar if the user may access it, else raise 404/403.
|
||||||
|
|
||||||
|
404 when the calendar does not exist or is not visible to the user (so we
|
||||||
|
don't leak existence). 403 when it is visible (read) but write is required.
|
||||||
|
"""
|
||||||
|
cal = (
|
||||||
|
db.query(models.LocalCalendar)
|
||||||
|
.filter(models.LocalCalendar.id == calendar_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cal:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
|
||||||
|
if cal.user_id == user.id:
|
||||||
|
return cal # owner: full access
|
||||||
|
|
||||||
|
if _is_group_calendar_member(db, calendar_id, user.id):
|
||||||
|
return cal # group members get read & write
|
||||||
|
|
||||||
|
share = _share_for(db, calendar_id, user.id)
|
||||||
|
if share is None:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
if require_write and share.permission != "read_write":
|
||||||
|
raise HTTPException(403, "You only have read access to this calendar")
|
||||||
|
return cal
|
||||||
|
|
||||||
|
|
||||||
|
def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
|
||||||
|
"""Return the calendar only if the user owns it, else raise 404."""
|
||||||
|
cal = (
|
||||||
|
db.query(models.LocalCalendar)
|
||||||
|
.filter(
|
||||||
|
models.LocalCalendar.id == calendar_id,
|
||||||
|
models.LocalCalendar.user_id == user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cal:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
return cal
|
||||||
|
|
||||||
|
|
||||||
|
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||||
|
"""All local calendar ids the user may read: own + shared + group calendars."""
|
||||||
|
ids: set[int] = set()
|
||||||
|
|
||||||
|
own = (
|
||||||
|
db.query(models.LocalCalendar.id)
|
||||||
|
.filter(models.LocalCalendar.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids.update(r[0] for r in own)
|
||||||
|
|
||||||
|
shared = (
|
||||||
|
db.query(models.CalendarShare.calendar_id)
|
||||||
|
.filter(models.CalendarShare.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids.update(r[0] for r in shared)
|
||||||
|
|
||||||
|
group_cals = (
|
||||||
|
db.query(models.GroupCalendar.calendar_id)
|
||||||
|
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
|
||||||
|
.filter(models.GroupMember.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids.update(r[0] for r in group_cals)
|
||||||
|
|
||||||
|
return list(ids)
|
||||||
@@ -32,7 +32,12 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _user_dict(user: models.User) -> dict:
|
def _user_dict(user: models.User) -> dict:
|
||||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name or user.username,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setup-required")
|
@router.get("/setup-required")
|
||||||
@@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(400, "Setup already completed")
|
raise HTTPException(400, "Setup already completed")
|
||||||
user = models.User(
|
user = models.User(
|
||||||
username=req.username.lower(),
|
username=req.username.lower(),
|
||||||
|
display_name=req.username.strip(), # keep the original casing for display
|
||||||
email=req.email,
|
email=req.email,
|
||||||
password_hash=get_password_hash(req.password),
|
password_hash=get_password_hash(req.password),
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
@@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)):
|
|||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name or current_user.username,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
"is_admin": current_user.is_admin,
|
"is_admin": current_user.is_admin,
|
||||||
"has_avatar": current_user.avatar_filename is not None,
|
"has_avatar": current_user.avatar_filename is not None,
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ from sqlalchemy import or_
|
|||||||
|
|
||||||
import caldav_client
|
import caldav_client
|
||||||
import models
|
import models
|
||||||
|
import permissions
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from local_events_util import (
|
||||||
|
apply_event_privacy,
|
||||||
|
build_local_event_dict,
|
||||||
|
expand_recurring_local,
|
||||||
|
private_visibility_for,
|
||||||
|
resolve_creator,
|
||||||
|
)
|
||||||
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
|
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -82,101 +90,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
|
||||||
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
|
||||||
results = []
|
|
||||||
# Parse excluded dates
|
|
||||||
excluded = set()
|
|
||||||
if ev.exdate:
|
|
||||||
for d in ev.exdate.split(","):
|
|
||||||
d = d.strip()
|
|
||||||
if d:
|
|
||||||
excluded.add(d)
|
|
||||||
try:
|
|
||||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
|
||||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
|
||||||
|
|
||||||
if ev.all_day:
|
|
||||||
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
|
||||||
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
|
||||||
duration = ev_end - ev_start
|
|
||||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
|
||||||
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
|
||||||
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
|
||||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
|
||||||
for occ in occurrences:
|
|
||||||
occ_start = occ.date()
|
|
||||||
occ_key = occ_start.strftime("%Y%m%d")
|
|
||||||
if occ_key in excluded:
|
|
||||||
continue
|
|
||||||
occ_end = occ_start + duration
|
|
||||||
results.append({
|
|
||||||
"id": ev.uid,
|
|
||||||
"url": f"local://{ev.uid}",
|
|
||||||
"title": ev.title,
|
|
||||||
"start": occ_start.isoformat(),
|
|
||||||
"end": occ_end.isoformat(),
|
|
||||||
"allDay": True,
|
|
||||||
"location": ev.location or "",
|
|
||||||
"description": ev.description or "",
|
|
||||||
"color": ev.color,
|
|
||||||
"rrule": ev.rrule,
|
|
||||||
"calendar_id": f"local-{local_cal.id}",
|
|
||||||
"calendar_name": local_cal.name,
|
|
||||||
"calendarColor": local_cal.color,
|
|
||||||
"source": "local",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
|
||||||
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
|
||||||
if ev_start.tzinfo is None:
|
|
||||||
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
|
||||||
if ev_end.tzinfo is None:
|
|
||||||
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
|
||||||
duration = ev_end - ev_start
|
|
||||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
|
||||||
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
|
||||||
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
|
||||||
if r_start.tzinfo is None:
|
|
||||||
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
|
||||||
if r_end.tzinfo is None:
|
|
||||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
|
||||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
|
||||||
for occ in occurrences:
|
|
||||||
occ_key = occ.strftime("%Y%m%d")
|
|
||||||
if occ_key in excluded:
|
|
||||||
continue
|
|
||||||
occ_end = occ + duration
|
|
||||||
results.append({
|
|
||||||
"id": ev.uid,
|
|
||||||
"url": f"local://{ev.uid}",
|
|
||||||
"title": ev.title,
|
|
||||||
"start": occ.isoformat(),
|
|
||||||
"end": occ_end.isoformat(),
|
|
||||||
"allDay": False,
|
|
||||||
"location": ev.location or "",
|
|
||||||
"description": ev.description or "",
|
|
||||||
"color": ev.color,
|
|
||||||
"rrule": ev.rrule,
|
|
||||||
"calendar_id": f"local-{local_cal.id}",
|
|
||||||
"calendar_name": local_cal.name,
|
|
||||||
"calendarColor": local_cal.color,
|
|
||||||
"source": "local",
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
|
||||||
# Fall back to single event
|
|
||||||
results.append({
|
|
||||||
"id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
|
|
||||||
"start": ev.start, "end": ev.end, "allDay": ev.all_day,
|
|
||||||
"location": ev.location or "", "description": ev.description or "",
|
|
||||||
"color": ev.color, "rrule": ev.rrule,
|
|
||||||
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
|
|
||||||
"calendarColor": local_cal.color, "source": "local",
|
|
||||||
})
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_url(url: str) -> str:
|
def _normalize_url(url: str) -> str:
|
||||||
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
|
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -417,15 +330,25 @@ def get_events(
|
|||||||
"Error fetching calendar %s: %s", calendar.id, exc
|
"Error fetching calendar %s: %s", calendar.id, exc
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Local calendar events ─────────────────────────────
|
# ── Local calendar events (own + shared + group calendars) ─────────────
|
||||||
|
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
||||||
local_calendars = (
|
local_calendars = (
|
||||||
db.query(models.LocalCalendar)
|
db.query(models.LocalCalendar)
|
||||||
.filter(
|
.filter(
|
||||||
models.LocalCalendar.user_id == current_user.id,
|
models.LocalCalendar.id.in_(readable_ids),
|
||||||
models.LocalCalendar.enabled == True,
|
models.LocalCalendar.enabled == True,
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
) if readable_ids else []
|
||||||
|
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||||
|
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||||
|
vis_cache: dict = {}
|
||||||
|
|
||||||
|
def vis_for(uid: int) -> str:
|
||||||
|
if uid not in vis_cache:
|
||||||
|
vis_cache[uid] = private_visibility_for(db, uid)
|
||||||
|
return vis_cache[uid]
|
||||||
|
|
||||||
for local_cal in local_calendars:
|
for local_cal in local_calendars:
|
||||||
local_events = (
|
local_events = (
|
||||||
db.query(models.LocalEvent)
|
db.query(models.LocalEvent)
|
||||||
@@ -441,25 +364,28 @@ def get_events(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for ev in local_events:
|
for ev in local_events:
|
||||||
|
creator = resolve_creator(ev, name_cache=name_cache)
|
||||||
|
# A private event belonging to someone else (shared calendar or group
|
||||||
|
# calendar) must honour that owner's private_event_visibility, exactly
|
||||||
|
# like the group combined view — otherwise private titles/locations
|
||||||
|
# leak through the ordinary calendar read.
|
||||||
|
owner_id = ev.creator_id or local_cal.user_id
|
||||||
|
is_priv = bool(ev.is_private)
|
||||||
|
foreign_private = is_priv and owner_id != current_user.id
|
||||||
|
visibility = vis_for(owner_id) if foreign_private else "busy"
|
||||||
|
if foreign_private and visibility == "hidden":
|
||||||
|
continue
|
||||||
if ev.rrule:
|
if ev.rrule:
|
||||||
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
|
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)
|
||||||
else:
|
else:
|
||||||
all_events.append({
|
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
|
||||||
"id": ev.uid,
|
for b in built:
|
||||||
"url": f"local://{ev.uid}",
|
b = apply_event_privacy(
|
||||||
"title": ev.title,
|
b, owner_id=owner_id, is_private=is_priv,
|
||||||
"start": ev.start,
|
requester_id=current_user.id, visibility=visibility,
|
||||||
"end": ev.end,
|
)
|
||||||
"allDay": ev.all_day,
|
if b is not None:
|
||||||
"location": ev.location or "",
|
all_events.append(b)
|
||||||
"description": ev.description or "",
|
|
||||||
"color": ev.color,
|
|
||||||
"rrule": None,
|
|
||||||
"calendar_id": f"local-{local_cal.id}",
|
|
||||||
"calendar_name": local_cal.name,
|
|
||||||
"calendarColor": local_cal.color,
|
|
||||||
"source": "local",
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── iCal subscription events ──────────────────────────
|
# ── iCal subscription events ──────────────────────────
|
||||||
ical_subs = (
|
ical_subs = (
|
||||||
|
|||||||
448
backend/routers/groups_router.py
Normal file
448
backend/routers/groups_router.py
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
"""Groups: shared group calendar + combined member-calendar overlay view.
|
||||||
|
|
||||||
|
A group has members and exactly one group calendar (a local calendar owned by
|
||||||
|
the creator, linked via group_calendars). Members get read/write on the group
|
||||||
|
calendar (enforced by permissions.accessible_local_calendar). The combined view
|
||||||
|
overlays every member's local calendars plus the group calendar, applying each
|
||||||
|
member's private-event visibility setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import models
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
|
||||||
|
# Distinct per-member colours (server-defined so every client shows the same).
|
||||||
|
MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"]
|
||||||
|
|
||||||
|
|
||||||
|
def _next_member_color(db: Session, group_id: int) -> str:
|
||||||
|
n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count()
|
||||||
|
return MEMBER_PALETTE[n % len(MEMBER_PALETTE)]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
member_ids: List[int] = []
|
||||||
|
icon: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAdd(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]:
|
||||||
|
return (
|
||||||
|
db.query(models.GroupMember)
|
||||||
|
.filter(
|
||||||
|
models.GroupMember.group_id == group_id,
|
||||||
|
models.GroupMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember:
|
||||||
|
m = _membership(db, group.id, user.id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(403, "You are not a member of this group")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _require_owner(db: Session, group: models.Group, user: models.User) -> None:
|
||||||
|
m = _membership(db, group.id, user.id)
|
||||||
|
if not m or m.role != "owner":
|
||||||
|
raise HTTPException(403, "Only the group owner may do this")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_group_or_404(db: Session, group_id: int) -> models.Group:
|
||||||
|
g = db.query(models.Group).filter(models.Group.id == group_id).first()
|
||||||
|
if not g:
|
||||||
|
raise HTTPException(404, "Group not found")
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
|
||||||
|
gc = (
|
||||||
|
db.query(models.GroupCalendar)
|
||||||
|
.filter(models.GroupCalendar.group_id == group_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return gc.calendar_id if gc else None
|
||||||
|
|
||||||
|
|
||||||
|
def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]:
|
||||||
|
if calendar_id is None:
|
||||||
|
return None
|
||||||
|
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
|
||||||
|
return cal.color if cal else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
def create_group(
|
||||||
|
data: GroupCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = models.Group(name=data.name, icon=(data.icon or None),
|
||||||
|
created_by=current_user.id, created_at=_now_iso())
|
||||||
|
db.add(group)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Creator is owner; add the requested members (deduped, excluding creator).
|
||||||
|
# Each member gets a distinct colour from the palette by join order.
|
||||||
|
db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner",
|
||||||
|
color=MEMBER_PALETTE[0], joined_at=_now_iso()))
|
||||||
|
seen = {current_user.id}
|
||||||
|
idx = 1
|
||||||
|
for uid in data.member_ids:
|
||||||
|
if uid in seen:
|
||||||
|
continue
|
||||||
|
if not db.query(models.User).filter(models.User.id == uid).first():
|
||||||
|
continue
|
||||||
|
db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member",
|
||||||
|
color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso()))
|
||||||
|
seen.add(uid)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# Auto-create the group calendar (a local calendar owned by the creator).
|
||||||
|
cal = models.LocalCalendar(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=f"{data.name} (Gruppe)",
|
||||||
|
color=PALETTE[group.id % len(PALETTE)],
|
||||||
|
)
|
||||||
|
db.add(cal)
|
||||||
|
db.flush()
|
||||||
|
db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(group)
|
||||||
|
return _group_detail(db, group, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_groups(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
memberships = (
|
||||||
|
db.query(models.GroupMember)
|
||||||
|
.filter(models.GroupMember.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for m in memberships:
|
||||||
|
group = db.query(models.Group).filter(models.Group.id == m.group_id).first()
|
||||||
|
if not group:
|
||||||
|
continue
|
||||||
|
member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count()
|
||||||
|
gcal_id = _group_calendar_id(db, group.id)
|
||||||
|
out.append({
|
||||||
|
"id": group.id,
|
||||||
|
"name": group.name,
|
||||||
|
"icon": group.icon,
|
||||||
|
"role": m.role,
|
||||||
|
"member_count": member_count,
|
||||||
|
"group_calendar_id": gcal_id,
|
||||||
|
"group_calendar_color": _group_calendar_color(db, gcal_id),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict:
|
||||||
|
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all()
|
||||||
|
member_dicts = []
|
||||||
|
for i, m in enumerate(members):
|
||||||
|
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||||
|
member_dicts.append({
|
||||||
|
"id": m.user_id,
|
||||||
|
"display_name": (u.display_name or u.username) if u else None,
|
||||||
|
"role": m.role,
|
||||||
|
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
||||||
|
})
|
||||||
|
gcal_id = _group_calendar_id(db, group.id)
|
||||||
|
return {
|
||||||
|
"id": group.id,
|
||||||
|
"name": group.name,
|
||||||
|
"icon": group.icon,
|
||||||
|
"created_by": group.created_by,
|
||||||
|
"members": member_dicts,
|
||||||
|
"group_calendar_id": gcal_id,
|
||||||
|
"group_calendar_color": _group_calendar_color(db, gcal_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{group_id}")
|
||||||
|
def update_group(
|
||||||
|
group_id: int,
|
||||||
|
data: GroupUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
_require_owner(db, group, current_user)
|
||||||
|
if data.name is not None and data.name.strip():
|
||||||
|
group.name = data.name.strip()
|
||||||
|
if data.icon is not None:
|
||||||
|
group.icon = data.icon or None
|
||||||
|
db.commit()
|
||||||
|
return _group_detail(db, group, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}")
|
||||||
|
def get_group(
|
||||||
|
group_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
_require_member(db, group, current_user)
|
||||||
|
return _group_detail(db, group, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{group_id}/members")
|
||||||
|
def add_member(
|
||||||
|
group_id: int,
|
||||||
|
data: MemberAdd,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
_require_owner(db, group, current_user)
|
||||||
|
if not db.query(models.User).filter(models.User.id == data.user_id).first():
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if _membership(db, group_id, data.user_id):
|
||||||
|
return {"ok": True} # already a member
|
||||||
|
db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member",
|
||||||
|
color=_next_member_color(db, group_id), joined_at=_now_iso()))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MemberColorUpdate(BaseModel):
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{group_id}/members/{user_id}/color")
|
||||||
|
def set_member_color(
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
data: MemberColorUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
# Owner may recolour anyone; a member may recolour themselves.
|
||||||
|
if user_id != current_user.id:
|
||||||
|
_require_owner(db, group, current_user)
|
||||||
|
else:
|
||||||
|
_require_member(db, group, current_user)
|
||||||
|
m = _membership(db, group_id, user_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Member not found")
|
||||||
|
m.color = data.color
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}/members/{user_id}")
|
||||||
|
def remove_member(
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
# Owner can remove anyone; a member may remove themselves (leave).
|
||||||
|
if user_id != current_user.id:
|
||||||
|
_require_owner(db, group, current_user)
|
||||||
|
else:
|
||||||
|
_require_member(db, group, current_user)
|
||||||
|
target = _membership(db, group_id, user_id)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Member not found")
|
||||||
|
if target.role == "owner":
|
||||||
|
raise HTTPException(422, "The owner cannot be removed; delete the group instead")
|
||||||
|
db.delete(target)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}")
|
||||||
|
def delete_group(
|
||||||
|
group_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
_require_owner(db, group, current_user)
|
||||||
|
# Remove the group calendar (and its events) too.
|
||||||
|
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
|
||||||
|
if gc:
|
||||||
|
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
|
||||||
|
if cal:
|
||||||
|
db.delete(cal) # cascades to events
|
||||||
|
db.delete(group) # cascades to members + group_calendar link
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_name(name: Optional[str]) -> str:
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
return name.split(" ", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
|
||||||
|
owner: Optional[dict], me_id: int) -> str:
|
||||||
|
"""Server-side display title for the combined view so every client (web,
|
||||||
|
iOS, Android) renders identically: another member's / creator's first name
|
||||||
|
is prefixed. No icon glyph is embedded — group icons are semantic keys the
|
||||||
|
clients render as native vector icons, and group-calendar events are
|
||||||
|
distinguished by their (group) colour. The raw `title` stays for editing."""
|
||||||
|
if is_group:
|
||||||
|
if creator and creator.get("id") is not None and creator.get("id") != me_id:
|
||||||
|
return f"{_first_name(creator.get('display_name'))}: {title}"
|
||||||
|
return title
|
||||||
|
if owner and owner.get("id") is not None and owner.get("id") != me_id:
|
||||||
|
return f"{_first_name(owner.get('display_name'))}: {title}"
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}/combined")
|
||||||
|
def combined_events(
|
||||||
|
group_id: int,
|
||||||
|
start: str = Query(...),
|
||||||
|
end: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
group = _get_group_or_404(db, group_id)
|
||||||
|
_require_member(db, group, current_user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||||
|
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid date format — use ISO 8601")
|
||||||
|
if start_dt.tzinfo is None:
|
||||||
|
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||||
|
if end_dt.tzinfo is None:
|
||||||
|
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
|
||||||
|
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||||
|
visibility_cache: dict[int, str] = {}
|
||||||
|
|
||||||
|
def visibility_for(user_id: int) -> str:
|
||||||
|
if user_id not in visibility_cache:
|
||||||
|
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
|
||||||
|
visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy"
|
||||||
|
return visibility_cache[user_id]
|
||||||
|
|
||||||
|
group_cal_id = _group_calendar_id(db, group_id)
|
||||||
|
group_cal_color = _group_calendar_color(db, group_cal_id)
|
||||||
|
# Server-defined colours so every client renders members/group consistently.
|
||||||
|
member_color = {
|
||||||
|
m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)])
|
||||||
|
for i, m in enumerate(members)
|
||||||
|
}
|
||||||
|
all_events: list[dict] = []
|
||||||
|
|
||||||
|
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
|
||||||
|
owner_user = name_cache.get(owner_id)
|
||||||
|
owner = {"id": owner_id, "display_name": owner_user}
|
||||||
|
events = (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(
|
||||||
|
models.LocalEvent.calendar_id == cal.id,
|
||||||
|
or_(
|
||||||
|
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
|
||||||
|
models.LocalEvent.rrule != None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for ev in events:
|
||||||
|
creator_owner_id = ev.creator_id or owner_id
|
||||||
|
# Private filtering for events that belong to someone else.
|
||||||
|
if ev.is_private and creator_owner_id != current_user.id:
|
||||||
|
vis = visibility_for(creator_owner_id)
|
||||||
|
if vis == "hidden":
|
||||||
|
continue
|
||||||
|
creator = None
|
||||||
|
if ev.creator_id and name_cache.get(ev.creator_id):
|
||||||
|
creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]}
|
||||||
|
elif ev.creator_name_external:
|
||||||
|
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
|
||||||
|
|
||||||
|
if ev.rrule:
|
||||||
|
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group)
|
||||||
|
else:
|
||||||
|
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
|
||||||
|
|
||||||
|
for b in built:
|
||||||
|
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
||||||
|
b = mask_busy_event(b)
|
||||||
|
# Colour to render with: the group calendar's colour for group
|
||||||
|
# events, otherwise the owning member's group colour.
|
||||||
|
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
||||||
|
# Decorated title (group icon / owner name) computed server-side
|
||||||
|
# so all clients render identically; raw `title` kept for editing.
|
||||||
|
b["display_title"] = _decorate_title(
|
||||||
|
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||||
|
owner=owner, me_id=current_user.id,
|
||||||
|
)
|
||||||
|
all_events.append(b)
|
||||||
|
|
||||||
|
# Each member shares exactly one calendar into their groups, chosen in their
|
||||||
|
# settings (group_visible_calendar_id). Only that calendar is overlaid.
|
||||||
|
for m in members:
|
||||||
|
settings = (
|
||||||
|
db.query(models.UserSettings)
|
||||||
|
.filter(models.UserSettings.user_id == m.user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
visible_id = settings.group_visible_calendar_id if settings else None
|
||||||
|
if visible_id is None or visible_id == group_cal_id:
|
||||||
|
continue
|
||||||
|
cal = (
|
||||||
|
db.query(models.LocalCalendar)
|
||||||
|
.filter(
|
||||||
|
models.LocalCalendar.id == visible_id,
|
||||||
|
models.LocalCalendar.user_id == m.user_id, # must be the member's own
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if cal:
|
||||||
|
emit_calendar(cal, m.user_id, is_group=False)
|
||||||
|
|
||||||
|
# The group calendar itself.
|
||||||
|
if group_cal_id is not None:
|
||||||
|
group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first()
|
||||||
|
if group_cal:
|
||||||
|
emit_calendar(group_cal, group_cal.user_id, is_group=True)
|
||||||
|
|
||||||
|
return {"events": all_events}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import ical_io
|
||||||
import models
|
import models
|
||||||
|
import permissions
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from local_events_util import build_local_event_dict, resolve_creator
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
class CalendarCreate(BaseModel):
|
class CalendarCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
color: str = "#34a853"
|
color: str = "#34a853"
|
||||||
@@ -33,6 +42,8 @@ class EventCreate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
rrule: Optional[str] = None
|
rrule: Optional[str] = None
|
||||||
|
private: bool = False
|
||||||
|
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
|
||||||
|
|
||||||
|
|
||||||
class EventUpdate(BaseModel):
|
class EventUpdate(BaseModel):
|
||||||
@@ -45,35 +56,34 @@ class EventUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
rrule: Optional[str] = None
|
rrule: Optional[str] = None
|
||||||
exdate: Optional[str] = None
|
exdate: Optional[str] = None
|
||||||
|
private: Optional[bool] = None
|
||||||
|
reminders: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
class ShareCreate(BaseModel):
|
||||||
return {
|
user_id: int
|
||||||
|
permission: str = "read"
|
||||||
|
|
||||||
|
|
||||||
|
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||||
|
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
|
||||||
|
d = {
|
||||||
"id": cal.id,
|
"id": cal.id,
|
||||||
"name": cal.name,
|
"name": cal.name,
|
||||||
"color": cal.color,
|
"color": cal.color,
|
||||||
"enabled": cal.enabled,
|
"enabled": cal.enabled,
|
||||||
|
"type": "local",
|
||||||
|
"owned": owned,
|
||||||
}
|
}
|
||||||
|
if shared_by is not None:
|
||||||
|
d["shared_by"] = shared_by
|
||||||
|
if permission is not None:
|
||||||
|
d["permission"] = permission
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
|
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
|
||||||
return {
|
return build_local_event_dict(ev, cal, creator=resolve_creator(ev))
|
||||||
"id": ev.uid,
|
|
||||||
"url": f"local://{ev.uid}",
|
|
||||||
"title": ev.title,
|
|
||||||
"start": ev.start,
|
|
||||||
"end": ev.end,
|
|
||||||
"allDay": ev.all_day,
|
|
||||||
"location": ev.location or "",
|
|
||||||
"description": ev.description or "",
|
|
||||||
"color": ev.color,
|
|
||||||
"rrule": ev.rrule,
|
|
||||||
"exdate": ev.exdate,
|
|
||||||
"calendar_id": f"local-{cal.id}",
|
|
||||||
"calendar_name": cal.name,
|
|
||||||
"calendarColor": cal.color,
|
|
||||||
"source": "local",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Calendar CRUD ─────────────────────────────────────────
|
# ── Calendar CRUD ─────────────────────────────────────────
|
||||||
@@ -83,12 +93,70 @@ def list_calendars(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
cals = (
|
# Map calendar_id -> group name for every group the user belongs to, so we
|
||||||
|
# can flag group calendars as such even when the user owns them (the creator
|
||||||
|
# owns the group calendar — it must still be marked group:true).
|
||||||
|
group_cal_map = {
|
||||||
|
cal_id: name
|
||||||
|
for cal_id, name in (
|
||||||
|
db.query(models.GroupCalendar.calendar_id, models.Group.name)
|
||||||
|
.join(models.Group, models.Group.id == models.GroupCalendar.group_id)
|
||||||
|
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
|
||||||
|
.filter(models.GroupMember.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Own calendars
|
||||||
|
own = (
|
||||||
db.query(models.LocalCalendar)
|
db.query(models.LocalCalendar)
|
||||||
.filter(models.LocalCalendar.user_id == current_user.id)
|
.filter(models.LocalCalendar.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [_cal_dict(c) for c in cals]
|
result = []
|
||||||
|
for c in own:
|
||||||
|
d = _cal_dict(c, owned=True)
|
||||||
|
if c.id in group_cal_map:
|
||||||
|
d["group"] = True
|
||||||
|
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
seen_ids = {c.id for c in own}
|
||||||
|
|
||||||
|
# Calendars shared with this user
|
||||||
|
shares = (
|
||||||
|
db.query(models.CalendarShare)
|
||||||
|
.filter(models.CalendarShare.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for share in shares:
|
||||||
|
cal = share.calendar
|
||||||
|
if cal is None or cal.id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(cal.id)
|
||||||
|
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||||
|
d = _cal_dict(
|
||||||
|
cal, owned=False,
|
||||||
|
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||||
|
permission=share.permission,
|
||||||
|
)
|
||||||
|
if cal.id in group_cal_map:
|
||||||
|
d["group"] = True
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
# Group calendars reached via membership (read_write) that aren't already
|
||||||
|
# listed, so members can select/see the group calendar.
|
||||||
|
for cal_id, group_name in group_cal_map.items():
|
||||||
|
if cal_id in seen_ids:
|
||||||
|
continue
|
||||||
|
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first()
|
||||||
|
if not cal:
|
||||||
|
continue
|
||||||
|
seen_ids.add(cal_id)
|
||||||
|
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
||||||
|
d["group"] = True
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/calendars")
|
@router.post("/calendars")
|
||||||
@@ -164,16 +232,10 @@ def create_event(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
cal = (
|
# Owner, shared (read_write), or group-member calendars are writable.
|
||||||
db.query(models.LocalCalendar)
|
cal = permissions.accessible_local_calendar(
|
||||||
.filter(
|
db, current_user, data.calendar_id, require_write=True
|
||||||
models.LocalCalendar.id == data.calendar_id,
|
|
||||||
models.LocalCalendar.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
if not cal:
|
|
||||||
raise HTTPException(404, "Calendar not found")
|
|
||||||
|
|
||||||
ev = models.LocalEvent(
|
ev = models.LocalEvent(
|
||||||
calendar_id=cal.id,
|
calendar_id=cal.id,
|
||||||
@@ -186,11 +248,23 @@ def create_event(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
color=data.color,
|
color=data.color,
|
||||||
rrule=data.rrule,
|
rrule=data.rrule,
|
||||||
|
is_private=data.private,
|
||||||
|
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
|
||||||
|
creator_id=current_user.id, # server-side, never from the client
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ev)
|
db.refresh(ev)
|
||||||
return _event_dict(ev, cal)
|
return _event_dict(ev, cal, db)
|
||||||
|
|
||||||
|
|
||||||
|
def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent:
|
||||||
|
ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(404, "Event not found")
|
||||||
|
# Raises 404/403 unless the user may write this event's calendar.
|
||||||
|
permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True)
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
@router.put("/events/{uid}")
|
@router.put("/events/{uid}")
|
||||||
@@ -200,17 +274,9 @@ def update_event(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ev = (
|
ev = _writable_event(db, current_user, uid)
|
||||||
db.query(models.LocalEvent)
|
if data.private is not None:
|
||||||
.join(models.LocalCalendar)
|
ev.is_private = data.private
|
||||||
.filter(
|
|
||||||
models.LocalEvent.uid == uid,
|
|
||||||
models.LocalCalendar.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not ev:
|
|
||||||
raise HTTPException(404, "Event not found")
|
|
||||||
if data.title is not None:
|
if data.title is not None:
|
||||||
ev.title = data.title
|
ev.title = data.title
|
||||||
if data.start is not None:
|
if data.start is not None:
|
||||||
@@ -233,6 +299,8 @@ def update_event(
|
|||||||
if data.exdate not in dates:
|
if data.exdate not in dates:
|
||||||
dates.append(data.exdate)
|
dates.append(data.exdate)
|
||||||
ev.exdate = ",".join(dates)
|
ev.exdate = ",".join(dates)
|
||||||
|
if data.reminders is not None:
|
||||||
|
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -243,17 +311,219 @@ def delete_event(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ev = (
|
ev = _writable_event(db, current_user, uid)
|
||||||
db.query(models.LocalEvent)
|
|
||||||
.join(models.LocalCalendar)
|
|
||||||
.filter(
|
|
||||||
models.LocalEvent.uid == uid,
|
|
||||||
models.LocalCalendar.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not ev:
|
|
||||||
raise HTTPException(404, "Event not found")
|
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sharing (owner only) ──────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/calendars/{calendar_id}/shares")
|
||||||
|
def list_shares(
|
||||||
|
calendar_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||||
|
shares = (
|
||||||
|
db.query(models.CalendarShare)
|
||||||
|
.filter(models.CalendarShare.calendar_id == calendar_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for s in shares:
|
||||||
|
u = db.query(models.User).filter(models.User.id == s.user_id).first()
|
||||||
|
out.append({
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"display_name": (u.display_name or u.username) if u else None,
|
||||||
|
"permission": s.permission,
|
||||||
|
"created_at": s.created_at,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendars/{calendar_id}/shares")
|
||||||
|
def add_share(
|
||||||
|
calendar_id: int,
|
||||||
|
data: ShareCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||||
|
if data.permission not in ("read", "read_write"):
|
||||||
|
raise HTTPException(422, "permission must be 'read' or 'read_write'")
|
||||||
|
target = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if target.id == current_user.id:
|
||||||
|
raise HTTPException(422, "Cannot share a calendar with yourself")
|
||||||
|
|
||||||
|
share = (
|
||||||
|
db.query(models.CalendarShare)
|
||||||
|
.filter(
|
||||||
|
models.CalendarShare.calendar_id == calendar_id,
|
||||||
|
models.CalendarShare.user_id == data.user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if share:
|
||||||
|
share.permission = data.permission # update existing
|
||||||
|
else:
|
||||||
|
share = models.CalendarShare(
|
||||||
|
calendar_id=calendar_id,
|
||||||
|
user_id=data.user_id,
|
||||||
|
permission=data.permission,
|
||||||
|
created_at=_now_iso(),
|
||||||
|
)
|
||||||
|
db.add(share)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/calendars/{calendar_id}/shares/{user_id}")
|
||||||
|
def remove_share(
|
||||||
|
calendar_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||||
|
share = (
|
||||||
|
db.query(models.CalendarShare)
|
||||||
|
.filter(
|
||||||
|
models.CalendarShare.calendar_id == calendar_id,
|
||||||
|
models.CalendarShare.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(404, "Share not found")
|
||||||
|
db.delete(share)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── iCal Import / Export (local calendars only) ───────────
|
||||||
|
|
||||||
|
def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict:
|
||||||
|
parsed = ical_io.parse_ics(raw)
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = list(parsed["errors"])
|
||||||
|
# local_events.uid is globally unique. Dedupe against the DB AND within this
|
||||||
|
# file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a
|
||||||
|
# UID (RECURRENCE-ID overrides), which would otherwise violate the constraint.
|
||||||
|
seen_uids: set[str] = set()
|
||||||
|
for item in parsed["events"]:
|
||||||
|
uid = item.get("uid") or str(uuid.uuid4())
|
||||||
|
if uid in seen_uids:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
seen_uids.add(uid)
|
||||||
|
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
ev = models.LocalEvent(
|
||||||
|
calendar_id=cal.id,
|
||||||
|
uid=uid,
|
||||||
|
title=item.get("title") or "(ohne Titel)",
|
||||||
|
start=item["start"],
|
||||||
|
end=item["end"],
|
||||||
|
all_day=item.get("all_day", False),
|
||||||
|
location=item.get("location"),
|
||||||
|
description=item.get("description"),
|
||||||
|
rrule=item.get("rrule"),
|
||||||
|
exdate=item.get("exdate"),
|
||||||
|
creator_name_external=item.get("organizer"),
|
||||||
|
)
|
||||||
|
db.add(ev)
|
||||||
|
imported += 1
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise ValueError(f"Import fehlgeschlagen: {exc}")
|
||||||
|
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM).
|
||||||
|
MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_capped(file: UploadFile) -> bytes:
|
||||||
|
raw = await file.read(MAX_ICS_BYTES + 1)
|
||||||
|
if len(raw) > MAX_ICS_BYTES:
|
||||||
|
raise HTTPException(413, "Datei zu groß (max. 5 MB)")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendars/{calendar_id}/import")
|
||||||
|
async def import_calendar(
|
||||||
|
calendar_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||||
|
raw = await _read_capped(file)
|
||||||
|
try:
|
||||||
|
return _import_ics_into(cal, raw, db)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_generic(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
calendar_id: Optional[int] = Form(None),
|
||||||
|
create_calendar: bool = Form(False),
|
||||||
|
calendar_name: Optional[str] = Form(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Read (capped) first so an oversized upload can't leave an empty calendar.
|
||||||
|
raw = await _read_capped(file)
|
||||||
|
if create_calendar:
|
||||||
|
cal = models.LocalCalendar(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=(calendar_name or "Importiert")[:120],
|
||||||
|
)
|
||||||
|
db.add(cal)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cal)
|
||||||
|
elif calendar_id is not None:
|
||||||
|
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _import_ics_into(cal, raw, db)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
result["calendar_id"] = cal.id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendars/{calendar_id}/export")
|
||||||
|
def export_calendar(
|
||||||
|
calendar_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
||||||
|
events = (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# Resolve creator display names for ORGANIZER.
|
||||||
|
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||||
|
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
|
||||||
|
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
|
||||||
|
return Response(
|
||||||
|
content=ics,
|
||||||
|
media_type="text/calendar",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import base64
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -8,11 +9,13 @@ import qrcode
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
import models
|
import models
|
||||||
from auth import get_current_user, get_password_hash, verify_password
|
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
||||||
from database import DATA_DIR, get_db
|
from database import DATA_DIR, get_db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -25,7 +28,16 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
|||||||
|
|
||||||
# ── Schemas ───────────────────────────────────────────────
|
# ── Schemas ───────────────────────────────────────────────
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
email: Optional[str] = None
|
# Length caps (SQLite ignores VARCHAR limits, so enforce here).
|
||||||
|
email: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
display_name: Optional[str] = Field(default=None, max_length=80)
|
||||||
|
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_controls(s: str) -> str:
|
||||||
|
"""Remove control characters (defends against injected newlines / NULs that
|
||||||
|
could be reflected in other clients' calendar/sharing/group views)."""
|
||||||
|
return re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||||
|
|
||||||
|
|
||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
@@ -47,6 +59,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
|
|||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name or current_user.username,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
"is_admin": current_user.is_admin,
|
"is_admin": current_user.is_admin,
|
||||||
"has_avatar": current_user.avatar_filename is not None,
|
"has_avatar": current_user.avatar_filename is not None,
|
||||||
@@ -60,10 +73,47 @@ def update_profile(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
result = {"ok": True}
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
current_user.email = data.email or None
|
email = _strip_controls(data.email)
|
||||||
|
if email:
|
||||||
|
if "@" not in email or "." not in email.split("@")[-1]:
|
||||||
|
raise HTTPException(422, "Invalid email address")
|
||||||
|
clash = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(func.lower(models.User.email) == email.lower(),
|
||||||
|
models.User.id != current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if clash:
|
||||||
|
raise HTTPException(400, "Email already in use")
|
||||||
|
current_user.email = email
|
||||||
|
else:
|
||||||
|
current_user.email = None
|
||||||
|
if data.display_name is not None:
|
||||||
|
dn = _strip_controls(data.display_name)
|
||||||
|
current_user.display_name = dn or current_user.username
|
||||||
|
if data.username is not None:
|
||||||
|
new_login = _strip_controls(data.username).lower()
|
||||||
|
if not new_login:
|
||||||
|
raise HTTPException(422, "Login name cannot be empty")
|
||||||
|
if new_login != current_user.username:
|
||||||
|
taken = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(func.lower(models.User.username) == new_login,
|
||||||
|
models.User.id != current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if taken:
|
||||||
|
raise HTTPException(400, "Username already taken")
|
||||||
|
current_user.username = new_login
|
||||||
|
db.commit()
|
||||||
|
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||||
|
# token, so hand back a fresh one for the client to store.
|
||||||
|
result["access_token"] = create_access_token({"sub": new_login})
|
||||||
|
return result
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Avatar ────────────────────────────────────────────────
|
# ── Avatar ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ class SettingsUpdate(BaseModel):
|
|||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
month_divider_color: Optional[str] = None
|
month_divider_color: Optional[str] = None
|
||||||
month_label_color: Optional[str] = None
|
month_label_color: Optional[str] = None
|
||||||
|
text_color: Optional[str] = None
|
||||||
|
line_color: Optional[str] = None
|
||||||
|
bg_color: Optional[str] = None
|
||||||
|
private_event_visibility: Optional[str] = None
|
||||||
|
group_visible_calendar_id: Optional[int] = None
|
||||||
|
default_reminder_minutes: Optional[int] = None # null = off
|
||||||
|
|
||||||
|
|
||||||
def _settings_dict(s: models.UserSettings) -> dict:
|
def _settings_dict(s: models.UserSettings) -> dict:
|
||||||
@@ -40,6 +46,12 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"language": s.language or "de",
|
"language": s.language or "de",
|
||||||
"month_divider_color": s.month_divider_color or "#7090c0",
|
"month_divider_color": s.month_divider_color or "#7090c0",
|
||||||
"month_label_color": s.month_label_color or "#7090c0",
|
"month_label_color": s.month_label_color or "#7090c0",
|
||||||
|
"text_color": s.text_color,
|
||||||
|
"line_color": s.line_color,
|
||||||
|
"bg_color": s.bg_color,
|
||||||
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -76,8 +88,19 @@ def update_settings(
|
|||||||
settings = models.UserSettings(user_id=current_user.id)
|
settings = models.UserSettings(user_id=current_user.id)
|
||||||
db.add(settings)
|
db.add(settings)
|
||||||
|
|
||||||
for field, value in data.model_dump(exclude_none=True).items():
|
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
|
||||||
setattr(settings, field, value)
|
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
|
||||||
|
|
||||||
|
# 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", "group_visible_calendar_id", "default_reminder_minutes"}
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field in NULLABLE_OVERRIDES:
|
||||||
|
setattr(settings, field, value or None)
|
||||||
|
elif value is not None:
|
||||||
|
setattr(settings, field, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _user_dict(u: models.User) -> dict:
|
def _user_dict(u: models.User) -> dict:
|
||||||
return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin}
|
return {
|
||||||
|
"id": u.id,
|
||||||
|
"username": u.username,
|
||||||
|
"display_name": u.display_name or u.username,
|
||||||
|
"email": u.email,
|
||||||
|
"is_admin": u.is_admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@@ -35,6 +41,25 @@ def list_users(
|
|||||||
return [_user_dict(u) for u in db.query(models.User).all()]
|
return [_user_dict(u) for u in db.query(models.User).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/directory")
|
||||||
|
def user_directory(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Lightweight list of all users (id + display_name) for sharing/group pickers.
|
||||||
|
|
||||||
|
Available to any authenticated user (unlike GET / which is admin-only).
|
||||||
|
Excludes the requesting user.
|
||||||
|
"""
|
||||||
|
users = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(models.User.id != current_user.id)
|
||||||
|
.order_by(models.User.username)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
def create_user(
|
def create_user(
|
||||||
req: CreateUserRequest,
|
req: CreateUserRequest,
|
||||||
@@ -45,6 +70,7 @@ def create_user(
|
|||||||
raise HTTPException(400, "Username already taken")
|
raise HTTPException(400, "Username already taken")
|
||||||
user = models.User(
|
user = models.User(
|
||||||
username=req.username.lower(),
|
username=req.username.lower(),
|
||||||
|
display_name=req.username.strip(), # keep the original casing for display
|
||||||
email=req.email,
|
email=req.email,
|
||||||
password_hash=get_password_hash(req.password),
|
password_hash=get_password_hash(req.password),
|
||||||
is_admin=req.is_admin,
|
is_admin=req.is_admin,
|
||||||
|
|||||||
61
backend/tests/conftest.py
Normal file
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}"}
|
||||||
411
backend/tests/test_collaboration.py
Normal file
411
backend/tests/test_collaboration.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
"""Tests for sharing, group permissions, the iCal parser, and private filtering."""
|
||||||
|
|
||||||
|
from conftest import register_admin, create_user, auth
|
||||||
|
|
||||||
|
RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_calendar(client, token, name="Cal"):
|
||||||
|
r = client.post("/api/local/calendars", headers=auth(token), json={"name": name})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(client, token, cal_id, title="Event", private=False,
|
||||||
|
start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"):
|
||||||
|
r = client.post("/api/local/events", headers=auth(token), json={
|
||||||
|
"calendar_id": cal_id, "title": title, "start": start, "end": end,
|
||||||
|
"private": private,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sharing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_share_read_then_read_write(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
|
||||||
|
cal_id = _make_calendar(client, admin, "Admins Kalender")
|
||||||
|
ev = _make_event(client, admin, cal_id, "Meeting")
|
||||||
|
|
||||||
|
# Creator field populated server-side.
|
||||||
|
assert ev["creator"]["display_name"] == "admin"
|
||||||
|
assert ev["type"] == "local"
|
||||||
|
|
||||||
|
# Share read-only with bob.
|
||||||
|
r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
|
||||||
|
json={"user_id": b_id, "permission": "read"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Bob sees the shared calendar with shared_by.
|
||||||
|
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
|
||||||
|
shared = [c for c in cals if not c["owned"]]
|
||||||
|
assert len(shared) == 1
|
||||||
|
assert shared[0]["shared_by"] == "admin"
|
||||||
|
assert shared[0]["permission"] == "read"
|
||||||
|
|
||||||
|
# Bob sees the event in the merged read.
|
||||||
|
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
|
||||||
|
assert any(e["title"] == "Meeting" for e in events)
|
||||||
|
|
||||||
|
# Bob cannot write (read-only) -> 403.
|
||||||
|
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||||
|
"calendar_id": cal_id, "title": "Nope",
|
||||||
|
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
|
||||||
|
})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
# Upgrade to read_write -> bob can write.
|
||||||
|
client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
|
||||||
|
json={"user_id": b_id, "permission": "read_write"})
|
||||||
|
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||||
|
"calendar_id": cal_id, "title": "Bobs Eintrag",
|
||||||
|
"start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# Created by bob.
|
||||||
|
assert r.json()["creator"]["display_name"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_cannot_manage_shares(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
cal_id = _make_calendar(client, admin)
|
||||||
|
# Bob (no access at all) cannot list shares -> 404 (existence hidden).
|
||||||
|
r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unshared_calendar_invisible(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
_b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
cal_id = _make_calendar(client, admin)
|
||||||
|
_make_event(client, admin, cal_id, "Privat")
|
||||||
|
events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
|
||||||
|
assert not any(e["title"] == "Privat" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Groups ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_group_create_and_members(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
c_id, c_tok = create_user(client, admin, "carol")
|
||||||
|
|
||||||
|
r = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Familie", "member_ids": [b_id]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
group = r.json()
|
||||||
|
gid = group["id"]
|
||||||
|
assert group["group_calendar_id"] is not None
|
||||||
|
assert {m["display_name"] for m in group["members"]} == {"admin", "bob"}
|
||||||
|
|
||||||
|
# Both members see the group.
|
||||||
|
assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json())
|
||||||
|
# Carol is not a member.
|
||||||
|
assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json())
|
||||||
|
assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403
|
||||||
|
|
||||||
|
# Only owner adds members.
|
||||||
|
assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok),
|
||||||
|
json={"user_id": c_id}).status_code == 403
|
||||||
|
assert client.post(f"/api/groups/{gid}/members", headers=auth(admin),
|
||||||
|
json={"user_id": c_id}).status_code == 200
|
||||||
|
|
||||||
|
# Member can leave; owner cannot be removed.
|
||||||
|
assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200
|
||||||
|
admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"]
|
||||||
|
assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_members_can_write_group_calendar(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gcal = group["group_calendar_id"]
|
||||||
|
# Bob (member, not owner of the calendar) can create in the group calendar.
|
||||||
|
r = client.post("/api/local/events", headers=auth(b_tok), json={
|
||||||
|
"calendar_id": gcal, "title": "Teamtermin",
|
||||||
|
"start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_member_colors_and_display_color(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gid = group["id"]
|
||||||
|
gcal = group["group_calendar_id"]
|
||||||
|
|
||||||
|
# Each member has a server-assigned colour; the group exposes its calendar colour.
|
||||||
|
detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
|
||||||
|
assert all(m.get("color") for m in detail["members"])
|
||||||
|
assert detail.get("group_calendar_color")
|
||||||
|
|
||||||
|
# Owner can recolour a member.
|
||||||
|
r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin),
|
||||||
|
json={"color": "#123456"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
|
||||||
|
assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"])
|
||||||
|
|
||||||
|
# Bob shares a calendar with an event; combined events carry display_color.
|
||||||
|
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||||
|
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||||
|
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||||
|
_make_event(client, admin, gcal, "Gruppentermin")
|
||||||
|
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
|
||||||
|
by_title = {e["title"]: e for e in evs}
|
||||||
|
assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour
|
||||||
|
assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_calendar_listed_for_member(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gcal = group["group_calendar_id"]
|
||||||
|
# Bob (member, not owner) sees the group calendar in his local list, flagged.
|
||||||
|
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
|
||||||
|
gc = [c for c in cals if c["id"] == gcal]
|
||||||
|
assert gc and gc[0].get("group") is True
|
||||||
|
assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_view_marks_owner_and_group_event(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gid = group["id"]
|
||||||
|
gcal = group["group_calendar_id"]
|
||||||
|
|
||||||
|
# Bob's own calendar + event; Bob designates it as his group-visible calendar.
|
||||||
|
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||||
|
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||||
|
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||||
|
# A group-calendar event.
|
||||||
|
_make_event(client, admin, gcal, "Gruppentermin")
|
||||||
|
|
||||||
|
events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
|
||||||
|
titles = {e["title"]: e for e in events}
|
||||||
|
assert "Bobs Termin" in titles
|
||||||
|
assert titles["Bobs Termin"]["owner"]["display_name"] == "bob"
|
||||||
|
assert titles["Bobs Termin"].get("is_group_event") is not True
|
||||||
|
assert "Gruppentermin" in titles
|
||||||
|
assert titles["Gruppentermin"]["is_group_event"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Private filtering ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _combined_titles(client, token, gid):
|
||||||
|
evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"]
|
||||||
|
return evs
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_visibility_hidden_and_busy(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gid = group["id"]
|
||||||
|
|
||||||
|
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||||
|
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||||
|
_make_event(client, b_tok, b_cal, "Geheimes", private=True,
|
||||||
|
start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00")
|
||||||
|
|
||||||
|
# Bob sees his own private event in full.
|
||||||
|
own = _combined_titles(client, b_tok, gid)
|
||||||
|
assert any(e["title"] == "Geheimes" for e in own)
|
||||||
|
|
||||||
|
# Default visibility = busy -> admin sees it as anonymous "Beschäftigt".
|
||||||
|
seen = _combined_titles(client, admin, gid)
|
||||||
|
busy = [e for e in seen if e["start"].startswith("2026-06-15")]
|
||||||
|
assert busy and all(e["title"] == "Beschäftigt" for e in busy)
|
||||||
|
assert all(e["location"] == "" and e["description"] == "" for e in busy)
|
||||||
|
|
||||||
|
# Switch bob to hidden -> admin no longer sees it at all.
|
||||||
|
client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"})
|
||||||
|
seen2 = _combined_titles(client, admin, gid)
|
||||||
|
assert not any(e["start"].startswith("2026-06-15") for e in seen2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_calendar_hidden_until_designated(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gid = group["id"]
|
||||||
|
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||||
|
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||||
|
|
||||||
|
# Not designated yet -> admin doesn't see Bob's calendar in the combined view.
|
||||||
|
seen = _combined_titles(client, admin, gid)
|
||||||
|
assert not any(e["title"] == "Bobs Termin" for e in seen)
|
||||||
|
|
||||||
|
# After Bob designates it, it appears.
|
||||||
|
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||||
|
seen2 = _combined_titles(client, admin, gid)
|
||||||
|
assert any(e["title"] == "Bobs Termin" for e in seen2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_name_case_preserved_and_login_case_insensitive(client):
|
||||||
|
# Setup with mixed-case name: login name lowercased, display name kept.
|
||||||
|
r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
user = r.json()["user"]
|
||||||
|
assert user["username"] == "guido"
|
||||||
|
assert user["display_name"] == "Guido"
|
||||||
|
|
||||||
|
# Login is case-insensitive (typing the display name "GUIDO" works).
|
||||||
|
r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"})
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
tok = r2.json()["access_token"]
|
||||||
|
|
||||||
|
# /me reflects the cased display name.
|
||||||
|
me = client.get("/api/auth/me", headers=auth(tok)).json()
|
||||||
|
assert me["display_name"] == "Guido" and me["username"] == "guido"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_login_name_returns_new_token(client):
|
||||||
|
admin = register_admin(client, "alice")
|
||||||
|
# Change display name (no token change).
|
||||||
|
r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."})
|
||||||
|
assert r.status_code == 200 and "access_token" not in r.json()
|
||||||
|
# Change login name -> fresh token, references survive (id is stable).
|
||||||
|
r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"})
|
||||||
|
assert r2.status_code == 200 and r2.json().get("access_token")
|
||||||
|
new_tok = r2.json()["access_token"]
|
||||||
|
me = client.get("/api/auth/me", headers=auth(new_tok)).json()
|
||||||
|
assert me["username"] == "alice2" and me["display_name"] == "Alice W."
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_visibility_validation(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ── iCal import/export ────────────────────────────────────
|
||||||
|
|
||||||
|
SAMPLE_ICS = b"""BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:evt-1@test
|
||||||
|
SUMMARY:Importiert 1
|
||||||
|
DTSTART:20260620T100000Z
|
||||||
|
DTEND:20260620T110000Z
|
||||||
|
LOCATION:Buero
|
||||||
|
ORGANIZER;CN=Max Mustermann:mailto:max@example.com
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:evt-2@test
|
||||||
|
SUMMARY:Importiert 2
|
||||||
|
DTSTART;VALUE=DATE:20260621
|
||||||
|
DTEND;VALUE=DATE:20260622
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_parser_roundtrip():
|
||||||
|
import ical_io
|
||||||
|
parsed = ical_io.parse_ics(SAMPLE_ICS)
|
||||||
|
assert len(parsed["events"]) == 2
|
||||||
|
ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test")
|
||||||
|
assert ev1["title"] == "Importiert 1"
|
||||||
|
assert ev1["location"] == "Buero"
|
||||||
|
assert ev1["organizer"] == "Max Mustermann"
|
||||||
|
assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
|
||||||
|
ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test")
|
||||||
|
assert ev2["all_day"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_dedupes_by_uid(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
cal_id = _make_calendar(client, admin, "Import-Ziel")
|
||||||
|
|
||||||
|
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
|
||||||
|
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["imported"] == 2 and body["skipped"] == 0
|
||||||
|
|
||||||
|
# Re-import -> all skipped (UID dedupe).
|
||||||
|
files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
|
||||||
|
r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||||
|
assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2
|
||||||
|
|
||||||
|
# Imported events carry the external creator name.
|
||||||
|
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||||
|
imported = [e for e in events if e["title"] == "Importiert 1"]
|
||||||
|
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
|
||||||
|
|
||||||
|
|
||||||
|
DUP_UID_ICS = b"""BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nextcloud
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:recurring@nc
|
||||||
|
SUMMARY:Standup
|
||||||
|
DTSTART:20260601T090000Z
|
||||||
|
DTEND:20260601T091500Z
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:recurring@nc
|
||||||
|
RECURRENCE-ID:20260608T090000Z
|
||||||
|
SUMMARY:Standup verschoben
|
||||||
|
DTSTART:20260608T100000Z
|
||||||
|
DTEND:20260608T101500Z
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_handles_duplicate_uid_in_file(client):
|
||||||
|
"""Nextcloud exports recurring events as multiple VEVENTs sharing a UID;
|
||||||
|
importing must not 500 on the unique constraint."""
|
||||||
|
admin = register_admin(client)
|
||||||
|
cal_id = _make_calendar(client, admin, "NC")
|
||||||
|
files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")}
|
||||||
|
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["imported"] == 1 and body["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_contains_organizer_and_rrule(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
cal_id = _make_calendar(client, admin, "Export-Test")
|
||||||
|
_make_event(client, admin, cal_id, "Wöchentlich")
|
||||||
|
# Add a recurring rule via update.
|
||||||
|
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||||
|
uid = next(e["id"] for e in events if e["title"] == "Wöchentlich")
|
||||||
|
client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"})
|
||||||
|
|
||||||
|
r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/calendar")
|
||||||
|
body = r.text
|
||||||
|
assert "BEGIN:VCALENDAR" in body
|
||||||
|
assert "ORGANIZER" in body and "admin" in body
|
||||||
|
assert "RRULE" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_export_only_local(client):
|
||||||
|
"""Import/export endpoints reject non-existent / inaccessible calendars."""
|
||||||
|
admin = register_admin(client)
|
||||||
|
_b, b_tok = create_user(client, admin, "bob")
|
||||||
|
cal_id = _make_calendar(client, admin, "Privat")
|
||||||
|
# Bob has no access -> 404 on export.
|
||||||
|
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
|
||||||
@@ -55,49 +55,129 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.flex-col { display: flex; flex-direction: column; }
|
.flex-col { display: flex; flex-direction: column; }
|
||||||
.gap-8 { gap: 8px; }
|
.gap-8 { gap: 8px; }
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────────────────────────── */
|
/* ── Buttons ──────────────────────────────────────────────
|
||||||
|
Modern pill style: fully rounded, subtle coloured shadow on the
|
||||||
|
prominent variants, lift on hover, snap back on press. The
|
||||||
|
primary-coloured glow follows --primary via color-mix(), so it adapts
|
||||||
|
when the user changes the theme colour in settings. */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
padding: 8px 16px; border-radius: 20px;
|
gap: 8px;
|
||||||
font-weight: 500; transition: background var(--transition), color var(--transition);
|
padding: 10px 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 500; font-size: 14px;
|
||||||
|
letter-spacing: .1px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
color var(--transition),
|
||||||
|
border-color var(--transition),
|
||||||
|
box-shadow .18s ease,
|
||||||
|
transform .12s ease,
|
||||||
|
filter var(--transition);
|
||||||
}
|
}
|
||||||
|
.btn:active { transform: translateY(0) scale(.985); transition-duration: .05s; }
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(66,133,244,.28);
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
}
|
}
|
||||||
.btn-primary:hover { filter: brightness(1.12); }
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 18px rgba(66,133,244,.42);
|
||||||
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--bg-hover); }
|
.btn-secondary:hover {
|
||||||
.btn-ghost { color: var(--primary); }
|
background: var(--bg-hover);
|
||||||
.btn-ghost:hover { background: var(--primary-dim); }
|
border-color: var(--primary);
|
||||||
.btn-danger { background: var(--accent); color: #fff; }
|
transform: translateY(-1px);
|
||||||
.btn-danger:hover { filter: brightness(1.1); }
|
}
|
||||||
.btn-full { width: 100%; justify-content: center; }
|
|
||||||
|
.btn-ghost {
|
||||||
|
color: var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--primary-dim);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(234,67,53,.28);
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 18px rgba(234,67,53,.42);
|
||||||
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full { width: 100%; }
|
||||||
|
|
||||||
|
/* The big sidebar "Erstellen" button: same pill aesthetic, primary tinted,
|
||||||
|
lives in the calm dark sidebar so the shadow is a touch stronger. */
|
||||||
.btn-fab {
|
.btn-fab {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 12px 20px; border-radius: 24px;
|
padding: 12px 22px;
|
||||||
background: var(--bg-surface);
|
border-radius: 999px;
|
||||||
color: var(--text-1);
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
margin: 16px 12px 8px;
|
margin: 16px 12px 8px;
|
||||||
transition: background var(--transition), box-shadow var(--transition);
|
box-shadow: 0 4px 14px rgba(66,133,244,.32);
|
||||||
|
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
box-shadow .18s ease,
|
||||||
|
transform .12s ease,
|
||||||
|
filter var(--transition);
|
||||||
}
|
}
|
||||||
.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); }
|
.btn-fab:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 22px rgba(66,133,244,.5);
|
||||||
|
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 50%, transparent);
|
||||||
|
}
|
||||||
|
.btn-fab:active { transform: translateY(0) scale(.985); }
|
||||||
|
|
||||||
|
/* Circular icon buttons (topbar nav, modal close, etc.) */
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 40px; height: 40px; border-radius: 50%;
|
width: 40px; height: 40px;
|
||||||
color: var(--text-2); transition: background var(--transition);
|
border-radius: 50%;
|
||||||
|
color: var(--text-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
color var(--transition),
|
||||||
|
transform .1s ease;
|
||||||
}
|
}
|
||||||
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||||
|
.icon-btn:active { transform: scale(.92); }
|
||||||
|
.icon-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Auth Screens ───────────────────────────────────────── */
|
/* ── Auth Screens ───────────────────────────────────────── */
|
||||||
.auth-screen {
|
.auth-screen {
|
||||||
@@ -140,15 +220,22 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.form-group input, .form-group select, .form-group textarea {
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 11px 14px;
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.form-group input:hover:not(:focus),
|
||||||
|
.form-group select:hover:not(:focus),
|
||||||
|
.form-group textarea:hover:not(:focus) {
|
||||||
|
border-color: var(--text-3);
|
||||||
|
}
|
||||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(66,133,244,.18);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
}
|
}
|
||||||
.form-group textarea { resize: vertical; }
|
.form-group textarea { resize: vertical; }
|
||||||
|
|
||||||
@@ -448,8 +535,10 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
|
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
|
||||||
.cal-item-remove { opacity: 0; }
|
/* Hide the remove/eye button until hover so the calendar name uses the full
|
||||||
.cal-item:hover .cal-item-remove { opacity: 1; }
|
width and only truncates while the button is visible. */
|
||||||
|
.cal-item-remove { display: none; }
|
||||||
|
.cal-item:hover .cal-item-remove { display: inline-flex; }
|
||||||
|
|
||||||
/* ── Month View ─────────────────────────────────────────── */
|
/* ── Month View ─────────────────────────────────────────── */
|
||||||
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||||
@@ -493,7 +582,8 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.cell-day {
|
.cell-day {
|
||||||
font-size: 12px; font-weight: 500; color: var(--text-2);
|
font-size: 12px; font-weight: 500; color: var(--text-2);
|
||||||
width: 26px; height: 26px;
|
width: 26px; height: 26px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
vertical-align: top;
|
||||||
border-radius: 50%; flex-shrink: 0;
|
border-radius: 50%; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
|
.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
|
||||||
@@ -502,13 +592,8 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.month-col {
|
.month-col {
|
||||||
position: relative; /* anchor for divider pseudo-elements */
|
position: relative; /* anchor for divider pseudo-elements */
|
||||||
}
|
}
|
||||||
.month-col.first-of-month {
|
/* first-of-month keeps the normal block layout (number top-left); the month
|
||||||
display: flex;
|
label sits inline next to the number via .cell-day/.month-marker below. */
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
/* Dividers via pseudo-elements so they render above events (z-index 2) */
|
/* Dividers via pseudo-elements so they render above events (z-index 2) */
|
||||||
.month-col.month-divider-left::before {
|
.month-col.month-divider-left::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -552,14 +637,14 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.month-marker {
|
.month-marker {
|
||||||
font-size: 14px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
letter-spacing: .5px;
|
letter-spacing: .3px;
|
||||||
color: var(--month-label-color, #7090c0);
|
color: var(--month-label-color, #7090c0);
|
||||||
line-height: 1;
|
line-height: 26px; /* align with the day-number circle */
|
||||||
padding: 0 2px;
|
margin-left: 4px;
|
||||||
margin: 0 0 2px 4px;
|
vertical-align: top;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3; /* above events overlay (z-index 2) */
|
z-index: 3; /* above events overlay (z-index 2) */
|
||||||
}
|
}
|
||||||
@@ -568,10 +653,10 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
/* Push events overlay down when row contains a first-of-month cell so the
|
/* Month marker now sits inline next to the day number, so the header height is
|
||||||
day "1" (which sits below the month marker) isn't hidden by event bars */
|
uniform and the events overlay needs no extra offset for month-start weeks. */
|
||||||
.month-row.has-month-marker .month-events-overlay {
|
.month-row.has-month-marker .month-events-overlay {
|
||||||
top: 56px;
|
top: 30px;
|
||||||
}
|
}
|
||||||
/* Events overlay — pointer-events:none so clicks pass to columns */
|
/* Events overlay — pointer-events:none so clicks pass to columns */
|
||||||
.month-events-overlay {
|
.month-events-overlay {
|
||||||
@@ -888,46 +973,131 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.ctx-item:hover { background: var(--bg-hover); }
|
.ctx-item:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
/* ── Event Popup ────────────────────────────────────────── */
|
/* ── Event Popup ──────────────────────────────────────────
|
||||||
|
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
||||||
|
Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
|
||||||
|
bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
|
||||||
|
modern und lässt dem Titel die meiste Breite. */
|
||||||
.event-popup {
|
.event-popup {
|
||||||
position: fixed; z-index: 600;
|
position: fixed; z-index: 600;
|
||||||
background: var(--bg-surface);
|
width: 320px;
|
||||||
border: 1px solid var(--border);
|
background: var(--bg-surface); /* fallback for no color-mix */
|
||||||
border-radius: var(--radius);
|
background: color-mix(in srgb, var(--bg-surface) 86%, transparent);
|
||||||
width: 300px;
|
-webkit-backdrop-filter: blur(22px) saturate(1.6);
|
||||||
box-shadow: var(--shadow-lg);
|
backdrop-filter: blur(22px) saturate(1.6);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,.05),
|
||||||
|
0 12px 40px rgba(0,0,0,.55),
|
||||||
|
0 2px 8px rgba(0,0,0,.4);
|
||||||
|
overflow: hidden;
|
||||||
|
transform-origin: top left;
|
||||||
|
animation: popupIn .18s cubic-bezier(.2,.8,.2,1);
|
||||||
}
|
}
|
||||||
|
@keyframes popupIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px) scale(.97); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.event-popup { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.popup-header {
|
.popup-header {
|
||||||
display: flex; align-items: center; gap: 8px;
|
position: relative;
|
||||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
display: flex; align-items: flex-start; gap: 11px;
|
||||||
|
padding: 14px 10px 13px 18px;
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent);
|
||||||
}
|
}
|
||||||
.popup-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
/* Slim accent strip in the event's colour. */
|
||||||
.popup-header h4 { flex: 1; font-size: 15px; font-weight: 500; }
|
.popup-header::before {
|
||||||
.popup-action, .popup-close { width: 32px; height: 32px; font-size: 16px; }
|
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||||
.popup-body { padding: 12px 16px; }
|
background: var(--ev-color, var(--primary));
|
||||||
.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
|
}
|
||||||
.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; }
|
.popup-color-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--ev-color, var(--primary));
|
||||||
|
flex-shrink: 0; margin-top: 6px;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ev-color, var(--primary)) 22%, transparent);
|
||||||
|
}
|
||||||
|
.popup-header h4 {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
line-height: 1.35; letter-spacing: -.01em;
|
||||||
|
color: var(--text-1);
|
||||||
|
word-break: break-word;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.popup-icon-btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-3);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
color var(--transition),
|
||||||
|
transform .12s ease;
|
||||||
|
}
|
||||||
|
.popup-icon-btn svg { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; }
|
||||||
|
.popup-icon-btn:hover {
|
||||||
|
background: rgba(66,133,244,.16);
|
||||||
|
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.popup-icon-btn-danger:hover {
|
||||||
|
background: rgba(234,67,53,.16);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.popup-icon-btn-close:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.popup-icon-btn:active { transform: scale(.88); }
|
||||||
|
|
||||||
|
.popup-body { padding: 12px 16px 14px; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.popup-row { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.45; color: var(--text-2); }
|
||||||
|
.popup-row-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; fill: var(--text-3); }
|
||||||
|
#popup-time { color: var(--text-1); font-weight: 500; }
|
||||||
|
.popup-row-desc { color: var(--text-1); }
|
||||||
|
.popup-row-desc span { white-space: pre-wrap; }
|
||||||
|
#popup-creator { font-style: italic; }
|
||||||
|
|
||||||
.popup-copy-menu {
|
.popup-copy-menu {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||||
padding: 4px 0;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
.popup-copy-label {
|
.popup-copy-label {
|
||||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||||
letter-spacing: .5px; color: var(--text-3);
|
letter-spacing: .5px; color: var(--text-3);
|
||||||
padding: 4px 14px 6px;
|
padding: 4px 10px 6px;
|
||||||
}
|
}
|
||||||
.popup-copy-item {
|
.popup-copy-item {
|
||||||
display: flex; align-items: center; gap: 9px;
|
display: flex; align-items: center; gap: 9px;
|
||||||
padding: 7px 14px; cursor: pointer; font-size: 13px; color: var(--text-1);
|
padding: 8px 10px; cursor: pointer; font-size: 13px; color: var(--text-1);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.popup-copy-item:hover { background: var(--bg-hover); }
|
.popup-copy-item:hover { background: var(--bg-hover); }
|
||||||
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.popup-copy-edit-toggle {
|
.popup-copy-edit-toggle {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 6px 14px 8px;
|
padding: 6px 10px 8px;
|
||||||
font-size: 12px; color: var(--text-2);
|
font-size: 12px; color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.popup-copy-edit-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
|
.popup-copy-edit-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||||||
@@ -1557,15 +1727,12 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.topbar-left { gap: 0; }
|
.topbar-left { gap: 0; }
|
||||||
.topbar-right { gap: 0; }
|
.topbar-right { gap: 0; }
|
||||||
|
|
||||||
/* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */
|
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */
|
||||||
.event-popup .icon-btn {
|
.event-popup { width: min(94vw, 360px); max-width: 94vw; }
|
||||||
min-width: 32px !important;
|
.popup-header { padding: 12px 8px 11px 16px; }
|
||||||
min-height: 32px !important;
|
.popup-header h4 { font-size: 14.5px; }
|
||||||
width: 32px;
|
.popup-icon-btn { width: 36px; height: 36px; }
|
||||||
height: 32px;
|
.popup-icon-btn svg { width: 17px; height: 17px; }
|
||||||
}
|
|
||||||
.event-popup .popup-header { gap: 2px; padding: 10px 12px; }
|
|
||||||
.event-popup { width: min(92vw, 340px); max-width: 92vw; }
|
|
||||||
|
|
||||||
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
|
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
|
||||||
.month-event-time { display: none; }
|
.month-event-time { display: none; }
|
||||||
@@ -1627,3 +1794,180 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Collaboration: sharing badges & user picker ───────────── */
|
||||||
|
.cal-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cal-badge-shared {
|
||||||
|
background: rgba(66, 133, 244, 0.15);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.share-user-picker {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.share-user-item {
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.share-user-item:last-child { border-bottom: none; }
|
||||||
|
.share-user-item:hover { background: var(--bg-surface); }
|
||||||
|
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
|
||||||
|
|
||||||
|
/* ── Groups ─────────────────────────────────────────────────── */
|
||||||
|
.group-view-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-surface); /* fallback for browsers without color-mix */
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, var(--bg-app));
|
||||||
|
border-bottom: 1px solid var(--accent);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
/* Group-member rows in the sidebar (colour dot = per-user colour, checkbox =
|
||||||
|
show/hide). Reuse the calendar-list item styling. */
|
||||||
|
.gm-row .gm-dot { cursor: pointer; }
|
||||||
|
|
||||||
|
.group-item-active {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.group-item .cal-item-name { cursor: pointer; flex: 1; }
|
||||||
|
.cal-list-empty {
|
||||||
|
padding: 6px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group member picker rows — checkbox left, name left, one per line. */
|
||||||
|
.group-member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.group-member-item:last-child { border-bottom: none; }
|
||||||
|
.group-member-item:hover { background: var(--bg-surface); }
|
||||||
|
.group-member-item input[type="checkbox"] { flex: 0 0 auto; margin: 0; }
|
||||||
|
.group-member-name { flex: 1 1 auto; color: var(--text-1); }
|
||||||
|
|
||||||
|
/* Calendar radio list (group-visible selection in settings). */
|
||||||
|
.cal-radio-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cal-radio-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cal-radio-item:last-child { border-bottom: none; }
|
||||||
|
.cal-radio-item:hover { background: var(--bg-surface); }
|
||||||
|
.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* Picker rows (group-visible calendar radio + group member checkboxes).
|
||||||
|
Deliberately NOT <label> elements, so the global ".form-group label"
|
||||||
|
uppercase/grey styling never applies. */
|
||||||
|
.cal-radio-list,
|
||||||
|
#group-member-picker {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.pick-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.pick-row:last-child { border-bottom: none; }
|
||||||
|
.pick-row:hover { background: var(--bg-surface); }
|
||||||
|
.pick-row-sel { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.pick-mark {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border: 2px solid var(--text-3);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; line-height: 1; color: #fff;
|
||||||
|
}
|
||||||
|
.pick-check { border-radius: 4px; }
|
||||||
|
.pick-radio { border-radius: 50%; }
|
||||||
|
.pick-mark.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.pick-dot-empty { background: transparent; }
|
||||||
|
.pick-name { flex: 1 1 auto; text-align: left; }
|
||||||
|
|
||||||
|
/* Flat calendar list: inline source label + drag handle. */
|
||||||
|
.cal-source {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 45%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
.cal-drag-handle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.cal-item.cal-dragging { opacity: .5; }
|
||||||
|
|
||||||
|
/* Group emoji + icon picker */
|
||||||
|
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
||||||
|
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
|
||||||
|
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.group-icon-opt {
|
||||||
|
width: 38px; height: 38px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18px; line-height: 1;
|
||||||
|
background: var(--bg-app);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.group-icon-opt { color: var(--text-1); }
|
||||||
|
.group-icon-opt:hover { background: var(--bg-surface); }
|
||||||
|
.group-icon-opt.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; color: var(--accent); }
|
||||||
|
/* SVG group icons render as block so they centre cleanly (vs emoji baseline). */
|
||||||
|
.group-emoji svg, .cal-shared-flag svg, .group-icon-opt svg { display: block; }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<!-- APP_VERSION: update here + version.js on every release -->
|
<!-- Title wird beim Laden aus version.js dynamisch gesetzt -->
|
||||||
<title>Calendarr v11</title>
|
<title>Calendarr</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="theme-color" content="#4285f4" />
|
<meta name="theme-color" content="#4285f4" />
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v18</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||||
@@ -179,13 +179,21 @@
|
|||||||
<div class="mini-cal-days" id="mini-days"></div>
|
<div class="mini-cal-days" id="mini-days"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group members (shown only in the group overlay; hide/show people) -->
|
||||||
|
<div class="cal-list hidden" id="group-members">
|
||||||
|
<div class="cal-list-header">
|
||||||
|
<span data-i18n="group_members">Mitglieder</span>
|
||||||
|
</div>
|
||||||
|
<div id="group-members-items"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Calendar List -->
|
<!-- Calendar List -->
|
||||||
<div class="cal-list" id="cal-list">
|
<div class="cal-list" id="cal-list">
|
||||||
<div class="cal-list-header">
|
<div class="cal-list-header">
|
||||||
<span data-i18n="my_calendars">Meine Kalender</span>
|
<span data-i18n="my_calendars">Meine Kalender</span>
|
||||||
<div class="add-cal-dropdown-wrap">
|
<div class="add-cal-dropdown-wrap">
|
||||||
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||||
<button data-action="local">Lokaler Kalender</button>
|
<button data-action="local">Lokaler Kalender</button>
|
||||||
@@ -198,13 +206,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="cal-list-items"></div>
|
<div id="cal-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups -->
|
||||||
|
<div class="cal-list" id="group-list">
|
||||||
|
<div class="cal-list-header">
|
||||||
|
<span data-i18n="groups_title">Gruppen</span>
|
||||||
|
<button class="icon-btn mini-btn" id="btn-add-group" data-i18n-title="group_create" title="Gruppe erstellen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="group-list-items"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||||
|
|
||||||
<!-- MAIN VIEW -->
|
<!-- MAIN VIEW -->
|
||||||
<main class="main-view" id="main-view">
|
<main class="main-view" id="main-view">
|
||||||
|
<div id="group-view-banner" class="group-view-banner hidden">
|
||||||
|
<span id="group-view-label"></span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="group-view-new-event" data-i18n="group_new_event">+ Gruppentermin</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="group-view-exit" data-i18n="group_exit">Gruppenansicht verlassen</button>
|
||||||
|
</div>
|
||||||
<div id="view-container"></div>
|
<div id="view-container"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -235,7 +260,7 @@
|
|||||||
<input type="hidden" id="ev-start" />
|
<input type="hidden" id="ev-start" />
|
||||||
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 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>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
@@ -243,7 +268,7 @@
|
|||||||
<input type="hidden" id="ev-end" />
|
<input type="hidden" id="ev-end" />
|
||||||
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +278,7 @@
|
|||||||
<input type="hidden" id="ev-start-date" />
|
<input type="hidden" id="ev-start-date" />
|
||||||
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 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>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
@@ -261,7 +286,7 @@
|
|||||||
<input type="hidden" id="ev-end-date" />
|
<input type="hidden" id="ev-end-date" />
|
||||||
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +336,7 @@
|
|||||||
<input type="hidden" id="ev-rec-until" />
|
<input type="hidden" id="ev-rec-until" />
|
||||||
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,6 +344,11 @@
|
|||||||
<label>Kalender</label>
|
<label>Kalender</label>
|
||||||
<select id="ev-calendar"></select>
|
<select id="ev-calendar"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row" id="ev-private-row" style="display:none">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="ev-private" /> <span data-i18n="event_private">Privat</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Ort</label>
|
<label>Ort</label>
|
||||||
<input type="text" id="ev-location" placeholder="Ort hinzufügen" />
|
<input type="text" id="ev-location" placeholder="Ort hinzufügen" />
|
||||||
@@ -344,6 +374,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Group Modal -->
|
||||||
|
<div id="modal-group" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card" style="max-width:480px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="group-modal-title" data-i18n="group_create">Gruppe erstellen</h3>
|
||||||
|
<button class="icon-btn modal-close" data-modal="modal-group">×</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 -->
|
<!-- Delete Confirm Modal -->
|
||||||
<div id="modal-delete-confirm" class="modal-overlay hidden">
|
<div id="modal-delete-confirm" class="modal-overlay hidden">
|
||||||
<div class="modal-card" style="max-width:400px">
|
<div class="modal-card" style="max-width:400px">
|
||||||
@@ -375,22 +467,42 @@
|
|||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
<div class="popup-color-dot" id="popup-color-dot"></div>
|
<div class="popup-color-dot" id="popup-color-dot"></div>
|
||||||
<h4 id="popup-title"></h4>
|
<h4 id="popup-title"></h4>
|
||||||
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
|
<div class="popup-toolbar">
|
||||||
<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 class="popup-icon-btn" id="popup-edit" title="Bearbeiten" aria-label="Bearbeiten">
|
||||||
</button>
|
<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 class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
|
</button>
|
||||||
<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 class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren">
|
||||||
</button>
|
<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 class="icon-btn popup-action" id="popup-delete" title="Löschen">
|
</button>
|
||||||
<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 class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen">
|
||||||
</button>
|
<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 class="icon-btn popup-close" id="popup-close">×</button>
|
</button>
|
||||||
|
<button class="popup-icon-btn popup-icon-btn-close" id="popup-close" title="Schließen" aria-label="Schließen">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="popup-body">
|
<div class="popup-body">
|
||||||
<div class="popup-time" id="popup-time"></div>
|
<div class="popup-row" id="popup-row-time">
|
||||||
<div class="popup-location" id="popup-location"></div>
|
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||||
<div class="popup-description" id="popup-description"></div>
|
<span id="popup-time"></span>
|
||||||
<div class="popup-calendar" id="popup-calendar"></div>
|
</div>
|
||||||
|
<div class="popup-row" id="popup-row-location" style="display:none">
|
||||||
|
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1112 6.5a2.5 2.5 0 010 5z"/></svg>
|
||||||
|
<span id="popup-location"></span>
|
||||||
|
</div>
|
||||||
|
<div class="popup-row popup-row-desc" id="popup-row-description" style="display:none">
|
||||||
|
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h12v2H3v-2z"/></svg>
|
||||||
|
<span id="popup-description"></span>
|
||||||
|
</div>
|
||||||
|
<div class="popup-row" id="popup-row-calendar">
|
||||||
|
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>
|
||||||
|
<span id="popup-calendar"></span>
|
||||||
|
</div>
|
||||||
|
<div class="popup-row" id="popup-row-creator" style="display:none">
|
||||||
|
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 12a5 5 0 100-10 5 5 0 000 10zm0 2c-5.33 0-8 2.67-8 6v2h16v-2c0-3.33-2.67-6-8-6z"/></svg>
|
||||||
|
<span id="popup-creator"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,15 +675,53 @@
|
|||||||
<div class="settings-page-body">
|
<div class="settings-page-body">
|
||||||
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
|
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
|
||||||
<nav class="settings-nav">
|
<nav class="settings-nav">
|
||||||
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
|
||||||
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
|
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
||||||
|
<button class="settings-nav-btn" data-panel="view" data-i18n="settings_nav_view">Ansicht</button>
|
||||||
|
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
|
||||||
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
|
|
||||||
<!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) -->
|
<!-- Profil: Name, Privatsphäre, geteilter Kalender -->
|
||||||
<div class="settings-panel active" id="settings-panel-general">
|
<div class="settings-panel active" id="settings-panel-profile">
|
||||||
|
<h4 class="panel-title" data-i18n="profile_account">Konto</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="profile_display_name">Anzeigename</label>
|
||||||
|
<input type="text" id="cfg-display-name" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="profile_login_name">Login-Name</label>
|
||||||
|
<input type="text" id="cfg-login-name" spellcheck="false" autocapitalize="none" />
|
||||||
|
<p class="panel-desc" data-i18n="profile_login_name_desc">Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
|
||||||
|
|
||||||
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
|
||||||
|
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_private_visibility">Private Termine für Gruppenmitglieder</label>
|
||||||
|
<select id="cfg-private-visibility">
|
||||||
|
<option value="busy" data-i18n="private_visibility_busy">Als „Beschäftigt“ anzeigen</option>
|
||||||
|
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
|
||||||
|
<p class="panel-desc" data-i18n="settings_group_visible_desc">Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_group_visible">Für Gruppen sichtbarer Kalender</label>
|
||||||
|
<div id="cfg-group-visible-list" class="cal-radio-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Darstellung: Sprache, Farben, Stundenhöhe -->
|
||||||
|
<div class="settings-panel" id="settings-panel-general">
|
||||||
|
|
||||||
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
|
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -618,25 +768,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
|
<div class="form-group">
|
||||||
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
|
<label data-i18n="settings_text_color">Schriftfarbe</label>
|
||||||
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast">
|
<div class="ev-color-row">
|
||||||
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button>
|
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||||
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button>
|
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
<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 type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</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>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_line_color">Linienfarbe</label>
|
||||||
|
<div class="ev-color-row">
|
||||||
|
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||||
|
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
|
||||||
|
<div class="ev-color-row">
|
||||||
|
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||||
|
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
|
<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_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
|
<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-line-contrast" data-setting="line_contrast">
|
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
|
||||||
<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="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</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="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</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="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</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>
|
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
<!-- Ansicht: Standardansicht, Wochenstart, vergangene Termine, ausgeblendete Kalender -->
|
||||||
|
<div class="settings-panel" id="settings-panel-view">
|
||||||
|
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settings_default_view">Standardansicht</label>
|
<label data-i18n="settings_default_view">Standardansicht</label>
|
||||||
<select id="cfg-default-view">
|
<select id="cfg-default-view">
|
||||||
@@ -661,22 +830,13 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
||||||
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
|
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
|
||||||
<div class="settings-panel" id="settings-panel-accounts">
|
<div class="settings-panel" id="settings-panel-accounts">
|
||||||
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
|
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
|
||||||
|
|
||||||
<div class="accounts-section">
|
<div class="accounts-section">
|
||||||
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
||||||
@@ -755,16 +915,21 @@
|
|||||||
|
|
||||||
<!-- Account Info -->
|
<!-- Account Info -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>Konto</h4>
|
<h4 data-i18n="profile_account">Konto</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Benutzername</label>
|
<label data-i18n="profile_display_name">Anzeigename</label>
|
||||||
<input type="text" id="profile-username" disabled class="input-disabled" />
|
<input type="text" id="profile-display-name-input" data-i18n-placeholder="profile_display_name_ph" placeholder="Anzeigename" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="profile_login_name">Login-Name</label>
|
||||||
|
<input type="text" id="profile-username" spellcheck="false" autocapitalize="none" />
|
||||||
|
<p class="panel-desc" data-i18n="profile_login_name_desc">Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" />
|
<input type="email" id="profile-email" placeholder="Keine E-Mail hinterlegt" />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="profile-save-info">Speichern</button>
|
<button class="btn btn-primary btn-sm" id="profile-save-info" data-i18n="save">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
@@ -884,7 +1049,7 @@
|
|||||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||||
<span style="font-size:12px;color:var(--text-3)">Calendarr 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>
|
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,19 +50,58 @@ async function uploadRequest(path, formData) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
// to the HTTP status so the message is diagnostic, not "unknown error".
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
const detail = (err && err.detail)
|
||||||
|
? err.detail
|
||||||
|
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
|
||||||
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadRequest(path, fallbackName) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.reload();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
// Derive filename from Content-Disposition if present.
|
||||||
|
let filename = fallbackName || 'calendar.ics';
|
||||||
|
const cd = res.headers.get('Content-Disposition') || '';
|
||||||
|
const m = cd.match(/filename="?([^"]+)"?/);
|
||||||
|
if (m) filename = m[1];
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (path) => request('GET', path),
|
get: (path) => request('GET', path),
|
||||||
post: (path, body) => request('POST', path, body),
|
post: (path, body) => request('POST', path, body),
|
||||||
put: (path, body) => request('PUT', path, body),
|
put: (path, body) => request('PUT', path, body),
|
||||||
delete: (path) => request('DELETE', path),
|
delete: (path) => request('DELETE', path),
|
||||||
upload: (path, form) => uploadRequest(path, form),
|
upload: (path, form) => uploadRequest(path, form),
|
||||||
|
download: (path, name) => downloadRequest(path, name),
|
||||||
|
|
||||||
login: (username, password, totp_code = null, remember_me = false) =>
|
login: (username, password, totp_code = null, remember_me = false) =>
|
||||||
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function launchApp() {
|
|||||||
|
|
||||||
// User dropdown menu
|
// User dropdown menu
|
||||||
const dropdown = document.getElementById('user-dropdown');
|
const dropdown = document.getElementById('user-dropdown');
|
||||||
document.getElementById('dropdown-username').textContent = user.username || 'Benutzer';
|
document.getElementById('dropdown-username').textContent = user.display_name || user.username || 'Benutzer';
|
||||||
|
|
||||||
avatar.addEventListener('click', e => {
|
avatar.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,9 @@ const translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings_title: 'Einstellungen',
|
settings_title: 'Einstellungen',
|
||||||
settings_nav_appearance: 'Darstellung',
|
settings_nav_appearance: 'Darstellung',
|
||||||
|
settings_nav_profile: 'Profil',
|
||||||
|
settings_nav_view: 'Ansicht',
|
||||||
|
settings_nav_calendars: 'Kalender',
|
||||||
settings_nav_google: 'Google Konten',
|
settings_nav_google: 'Google Konten',
|
||||||
settings_nav_users: 'Benutzerverwaltung',
|
settings_nav_users: 'Benutzerverwaltung',
|
||||||
settings_colors: 'Farben',
|
settings_colors: 'Farben',
|
||||||
@@ -67,6 +70,10 @@ const translations = {
|
|||||||
settings_today_color: 'Heutige-Tag-Farbe',
|
settings_today_color: 'Heutige-Tag-Farbe',
|
||||||
settings_month_divider_color: 'Monatswechsel-Linie',
|
settings_month_divider_color: 'Monatswechsel-Linie',
|
||||||
settings_month_label_color: 'Monatskürzel-Farbe',
|
settings_month_label_color: 'Monatskürzel-Farbe',
|
||||||
|
settings_text_color: 'Schriftfarbe',
|
||||||
|
settings_line_color: 'Linienfarbe',
|
||||||
|
settings_bg_color: 'Hintergrundfarbe',
|
||||||
|
reset: 'Reset',
|
||||||
settings_text_contrast: 'Schriftkontrast',
|
settings_text_contrast: 'Schriftkontrast',
|
||||||
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
||||||
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
|
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
|
||||||
@@ -80,6 +87,61 @@ const translations = {
|
|||||||
settings_week_start: 'Erster Wochentag',
|
settings_week_start: 'Erster Wochentag',
|
||||||
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
||||||
settings_dim_past: 'Vergangene Termine ausgrauen',
|
settings_dim_past: 'Vergangene Termine ausgrauen',
|
||||||
|
settings_privacy: 'Privatsphäre',
|
||||||
|
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
||||||
|
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
||||||
|
private_visibility_busy: 'Als „Beschäftigt“ anzeigen',
|
||||||
|
private_visibility_hidden: 'Ausblenden',
|
||||||
|
created_by: 'Erstellt von: {name}',
|
||||||
|
event_private: 'Privat',
|
||||||
|
share: 'Teilen',
|
||||||
|
import: 'Importieren',
|
||||||
|
export: 'Exportieren',
|
||||||
|
importing: 'Importiere…',
|
||||||
|
import_result: '{imported} importiert, {skipped} übersprungen',
|
||||||
|
shared_by: 'geteilt von {name}',
|
||||||
|
share_title: 'Kalender teilen',
|
||||||
|
share_current: 'Aktuelle Freigaben',
|
||||||
|
share_add: 'Benutzer hinzufügen',
|
||||||
|
share_search: 'Benutzer suchen…',
|
||||||
|
share_none: 'Noch nicht geteilt',
|
||||||
|
share_no_users: 'Keine Benutzer gefunden',
|
||||||
|
perm_read: 'Nur lesen',
|
||||||
|
perm_read_write: 'Lesen & schreiben',
|
||||||
|
remove: 'Entfernen',
|
||||||
|
done: 'Fertig',
|
||||||
|
groups_title: 'Gruppen',
|
||||||
|
groups_none: 'Noch keine Gruppen',
|
||||||
|
group_create: 'Gruppe erstellen',
|
||||||
|
group_manage: 'Gruppe verwalten',
|
||||||
|
group_name: 'Name',
|
||||||
|
group_name_ph: 'Gruppenname',
|
||||||
|
group_members: 'Mitglieder',
|
||||||
|
group_delete: 'Gruppe löschen',
|
||||||
|
group_delete_confirm: 'Diese Gruppe und ihren Gruppenkalender wirklich löschen?',
|
||||||
|
group_deleted: 'Gruppe gelöscht',
|
||||||
|
group_saved: 'Gruppe gespeichert',
|
||||||
|
group_created: 'Gruppe erstellt',
|
||||||
|
group_view_label: 'Gruppenansicht: {name}',
|
||||||
|
group_exit: 'Gruppenansicht verlassen',
|
||||||
|
group_calendar: 'Gruppenkalender',
|
||||||
|
group_icon: 'Icon',
|
||||||
|
group_visible_flag: 'Für deine Gruppen sichtbar',
|
||||||
|
group_new_event: '+ Gruppentermin',
|
||||||
|
group_member_colors: 'Farben der Mitglieder',
|
||||||
|
only_owner_color: 'Nur der Besitzer kann die Farbe ändern',
|
||||||
|
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
|
||||||
|
shared_with_me: 'Mit dir geteilt',
|
||||||
|
settings_calendars: 'Kalender',
|
||||||
|
settings_group_visible: 'Für Gruppen sichtbarer Kalender',
|
||||||
|
settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist',
|
||||||
|
group_visible_none: 'Keiner',
|
||||||
|
drag_reorder: 'Zum Sortieren ziehen',
|
||||||
|
profile_account: 'Konto',
|
||||||
|
profile_display_name: 'Anzeigename',
|
||||||
|
profile_display_name_ph: 'Anzeigename',
|
||||||
|
profile_login_name: 'Login-Name',
|
||||||
|
profile_login_name_desc: 'Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.',
|
||||||
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
|
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
|
||||||
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
|
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
|
||||||
hour_compact: 'Kompakt', hour_normal: 'Normal',
|
hour_compact: 'Kompakt', hour_normal: 'Normal',
|
||||||
@@ -154,7 +216,7 @@ const translations = {
|
|||||||
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
|
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
|
||||||
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
|
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
|
||||||
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
|
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
|
||||||
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
|
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
|
||||||
edit_before_copy: 'Vor dem Kopieren bearbeiten',
|
edit_before_copy: 'Vor dem Kopieren bearbeiten',
|
||||||
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
||||||
confirm_delete_event: '"{title}" wirklich löschen?',
|
confirm_delete_event: '"{title}" wirklich löschen?',
|
||||||
@@ -271,6 +333,9 @@ const translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings_title: 'Settings',
|
settings_title: 'Settings',
|
||||||
settings_nav_appearance: 'Appearance',
|
settings_nav_appearance: 'Appearance',
|
||||||
|
settings_nav_profile: 'Profile',
|
||||||
|
settings_nav_view: 'View',
|
||||||
|
settings_nav_calendars: 'Calendars',
|
||||||
settings_nav_google: 'Google Accounts',
|
settings_nav_google: 'Google Accounts',
|
||||||
settings_nav_users: 'User Management',
|
settings_nav_users: 'User Management',
|
||||||
settings_colors: 'Colors',
|
settings_colors: 'Colors',
|
||||||
@@ -278,6 +343,10 @@ const translations = {
|
|||||||
settings_today_color: 'Today highlight color',
|
settings_today_color: 'Today highlight color',
|
||||||
settings_month_divider_color: 'Month divider line',
|
settings_month_divider_color: 'Month divider line',
|
||||||
settings_month_label_color: 'Month label color',
|
settings_month_label_color: 'Month label color',
|
||||||
|
settings_text_color: 'Text color',
|
||||||
|
settings_line_color: 'Line color',
|
||||||
|
settings_bg_color: 'Background color',
|
||||||
|
reset: 'Reset',
|
||||||
settings_text_contrast: 'Text contrast',
|
settings_text_contrast: 'Text contrast',
|
||||||
settings_text_contrast_desc: 'Brightness of labels and text',
|
settings_text_contrast_desc: 'Brightness of labels and text',
|
||||||
contrast_dark: 'Dark', contrast_medium: 'Medium',
|
contrast_dark: 'Dark', contrast_medium: 'Medium',
|
||||||
@@ -291,6 +360,61 @@ const translations = {
|
|||||||
settings_week_start: 'First day of week',
|
settings_week_start: 'First day of week',
|
||||||
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
||||||
settings_dim_past: 'Dim past events',
|
settings_dim_past: 'Dim past events',
|
||||||
|
settings_privacy: 'Privacy',
|
||||||
|
settings_private_visibility: 'Private events for group members',
|
||||||
|
settings_private_visibility_desc: 'How your private events appear to other group members',
|
||||||
|
private_visibility_busy: 'Show as "Busy"',
|
||||||
|
private_visibility_hidden: 'Hide completely',
|
||||||
|
created_by: 'Created by: {name}',
|
||||||
|
event_private: 'Private',
|
||||||
|
share: 'Share',
|
||||||
|
import: 'Import',
|
||||||
|
export: 'Export',
|
||||||
|
importing: 'Importing…',
|
||||||
|
import_result: '{imported} imported, {skipped} skipped',
|
||||||
|
shared_by: 'shared by {name}',
|
||||||
|
share_title: 'Share calendar',
|
||||||
|
share_current: 'Current shares',
|
||||||
|
share_add: 'Add user',
|
||||||
|
share_search: 'Search users…',
|
||||||
|
share_none: 'Not shared yet',
|
||||||
|
share_no_users: 'No users found',
|
||||||
|
perm_read: 'Read only',
|
||||||
|
perm_read_write: 'Read & write',
|
||||||
|
remove: 'Remove',
|
||||||
|
done: 'Done',
|
||||||
|
groups_title: 'Groups',
|
||||||
|
groups_none: 'No groups yet',
|
||||||
|
group_create: 'Create group',
|
||||||
|
group_manage: 'Manage group',
|
||||||
|
group_name: 'Name',
|
||||||
|
group_name_ph: 'Group name',
|
||||||
|
group_members: 'Members',
|
||||||
|
group_delete: 'Delete group',
|
||||||
|
group_delete_confirm: 'Really delete this group and its group calendar?',
|
||||||
|
group_deleted: 'Group deleted',
|
||||||
|
group_saved: 'Group saved',
|
||||||
|
group_created: 'Group created',
|
||||||
|
group_view_label: 'Group view: {name}',
|
||||||
|
group_exit: 'Exit group view',
|
||||||
|
group_calendar: 'Group calendar',
|
||||||
|
group_icon: 'Icon',
|
||||||
|
group_visible_flag: 'Visible to your groups',
|
||||||
|
group_new_event: '+ Group event',
|
||||||
|
group_member_colors: 'Member colours',
|
||||||
|
only_owner_color: 'Only the owner can change the colour',
|
||||||
|
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
|
||||||
|
shared_with_me: 'Shared with me',
|
||||||
|
settings_calendars: 'Calendars',
|
||||||
|
settings_group_visible: 'Calendar visible to groups',
|
||||||
|
settings_group_visible_desc: 'Choose which of your calendars your group members can see',
|
||||||
|
group_visible_none: 'None',
|
||||||
|
drag_reorder: 'Drag to reorder',
|
||||||
|
profile_account: 'Account',
|
||||||
|
profile_display_name: 'Display name',
|
||||||
|
profile_display_name_ph: 'Display name',
|
||||||
|
profile_login_name: 'Login name',
|
||||||
|
profile_login_name_desc: 'Lowercase, used to sign in. Case-insensitive.',
|
||||||
settings_hour_height: 'Hour height (week & day view)',
|
settings_hour_height: 'Hour height (week & day view)',
|
||||||
settings_hour_height_desc: 'How much space one hour takes in the time grid',
|
settings_hour_height_desc: 'How much space one hour takes in the time grid',
|
||||||
hour_compact: 'Compact', hour_normal: 'Normal',
|
hour_compact: 'Compact', hour_normal: 'Normal',
|
||||||
@@ -365,7 +489,7 @@ const translations = {
|
|||||||
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
|
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
|
||||||
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
|
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
|
||||||
rec_on_date: 'On date', rec_occurrences: 'occurrences',
|
rec_on_date: 'On date', rec_occurrences: 'occurrences',
|
||||||
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
|
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
|
||||||
edit_before_copy: 'Edit before copying',
|
edit_before_copy: 'Edit before copying',
|
||||||
event_updated: 'Event updated', event_created: 'Event created',
|
event_updated: 'Event updated', event_created: 'Event created',
|
||||||
confirm_delete_event: 'Really delete "{title}"?',
|
confirm_delete_event: 'Really delete "{title}"?',
|
||||||
@@ -442,15 +566,26 @@ export function t(key, vars = {}) {
|
|||||||
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
|
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up a translation but return null if the key is undefined in both
|
||||||
|
// the current language and German. Lets callers fall back to the existing
|
||||||
|
// HTML default rather than displaying the raw key.
|
||||||
|
function tOrNull(key) {
|
||||||
|
const dict = translations[currentLang] ?? translations.de;
|
||||||
|
const val = dict[key] ?? translations.de[key];
|
||||||
|
return typeof val === 'string' ? val : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyLang() {
|
export function applyLang() {
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
const v = t(el.dataset.i18n);
|
const v = tOrNull(el.dataset.i18n);
|
||||||
if (typeof v === 'string') el.textContent = v;
|
if (v != null) el.textContent = v;
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||||||
el.placeholder = t(el.dataset.i18nPh);
|
const v = tOrNull(el.dataset.i18nPh);
|
||||||
|
if (v != null) el.placeholder = v;
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
el.title = t(el.dataset.i18nTitle);
|
const v = tOrNull(el.dataset.i18nTitle);
|
||||||
|
if (v != null) el.title = v;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ const LINE_CONTRAST = {
|
|||||||
4: { border: '#5a5a78', light: '#484860' },
|
4: { border: '#5a5a78', light: '#484860' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Defaults wenn kein Custom-Override gesetzt ist.
|
||||||
|
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
|
||||||
|
export const DEFAULT_TEXT_COLOR = '#FFFFFF';
|
||||||
|
export const DEFAULT_LINE_COLOR = '#3A3A52';
|
||||||
|
export const DEFAULT_BG_COLOR = '#000000';
|
||||||
|
|
||||||
export function applyTheme(settings) {
|
export function applyTheme(settings) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
||||||
@@ -83,14 +89,33 @@ export function applyTheme(settings) {
|
|||||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
||||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
||||||
|
|
||||||
const tc = TEXT_CONTRAST[settings.text_contrast || 3];
|
// Effektive Farben bestimmen (Override > Default).
|
||||||
root.style.setProperty('--text-1', tc.t1);
|
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
||||||
root.style.setProperty('--text-2', tc.t2);
|
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
||||||
root.style.setProperty('--text-3', tc.t3);
|
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
||||||
|
|
||||||
const lc = LINE_CONTRAST[settings.line_contrast || 3];
|
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||||
root.style.setProperty('--border', lc.border);
|
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||||
root.style.setProperty('--border-light', lc.light);
|
// 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));
|
||||||
|
|
||||||
|
root.style.setProperty('--bg-app', bgColor);
|
||||||
|
root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10));
|
||||||
|
root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
|
||||||
|
root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
|
||||||
|
root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
|
||||||
|
root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
|
||||||
|
|
||||||
const hh = settings.hour_height || 44;
|
const hh = settings.hour_height || 44;
|
||||||
root.style.setProperty('--hour-h', hh + 'px');
|
root.style.setProperty('--hour-h', hh + 'px');
|
||||||
@@ -99,9 +124,48 @@ export function applyTheme(settings) {
|
|||||||
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function luminance(hex) {
|
||||||
|
const c = (n) => {
|
||||||
|
const v = n / 255;
|
||||||
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
|
};
|
||||||
|
const r = c(parseInt(hex.slice(1, 3), 16));
|
||||||
|
const g = c(parseInt(hex.slice(3, 5), 16));
|
||||||
|
const b = c(parseInt(hex.slice(5, 7), 16));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
function contrastRatio(c1, c2) {
|
||||||
|
try {
|
||||||
|
const l1 = luminance(c1);
|
||||||
|
const l2 = luminance(c2);
|
||||||
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||||
|
} catch { return 21; }
|
||||||
|
}
|
||||||
|
|
||||||
function hexToRgba(hex, alpha) {
|
function hexToRgba(hex, alpha) {
|
||||||
const r = parseInt(hex.slice(1,3), 16);
|
const r = parseInt(hex.slice(1,3), 16);
|
||||||
const g = parseInt(hex.slice(3,5), 16);
|
const g = parseInt(hex.slice(3,5), 16);
|
||||||
const b = parseInt(hex.slice(5,7), 16);
|
const b = parseInt(hex.slice(5,7), 16);
|
||||||
return `rgba(${r},${g},${b},${alpha})`;
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brighten (positive amount) or darken (negative) a hex colour.
|
||||||
|
// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
|
||||||
|
// from a single user-picked colour so the whole UI stays in the same family.
|
||||||
|
function shadeHex(hex, amount) {
|
||||||
|
let r = parseInt(hex.slice(1,3), 16);
|
||||||
|
let g = parseInt(hex.slice(3,5), 16);
|
||||||
|
let b = parseInt(hex.slice(5,7), 16);
|
||||||
|
if (amount >= 0) {
|
||||||
|
r = Math.round(r + (255 - r) * amount);
|
||||||
|
g = Math.round(g + (255 - g) * amount);
|
||||||
|
b = Math.round(b + (255 - b) * amount);
|
||||||
|
} else {
|
||||||
|
const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
|
||||||
|
r = Math.round(r * a);
|
||||||
|
g = Math.round(g * a);
|
||||||
|
b = Math.round(b * a);
|
||||||
|
}
|
||||||
|
const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
|
||||||
|
return '#' + h(r) + h(g) + h(b);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v11';
|
export const APP_VERSION = 'v44';
|
||||||
|
|||||||
@@ -149,11 +149,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
|
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
|
||||||
const dividerCls = dividerClasses.join(' ');
|
const dividerCls = dividerClasses.join(' ');
|
||||||
const monthLabel = isFirstOfMonth
|
const monthLabel = isFirstOfMonth
|
||||||
? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>`
|
? `<span class="month-marker">${monthsShort[cell.getMonth()]}</span>`
|
||||||
: '';
|
: '';
|
||||||
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
|
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
|
||||||
${monthLabel}
|
|
||||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||||
|
${monthLabel}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,19 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
const pastCls = isPast(ev) ? 'past' : '';
|
const pastCls = isPast(ev) ? 'past' : '';
|
||||||
const multiCls = isMultiTimed ? 'multiday-timed' : '';
|
const multiCls = isMultiTimed ? 'multiday-timed' : '';
|
||||||
const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
|
// continues-left/right: compute on date-only basis for all-day events
|
||||||
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
|
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])
|
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
|
||||||
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
||||||
: ev.title;
|
: ev.title;
|
||||||
@@ -236,11 +247,28 @@ function renderNowLine(container, days, hourH = 60) {
|
|||||||
function layoutWeekAllDay(evs, days) {
|
function layoutWeekAllDay(evs, days) {
|
||||||
const items = [];
|
const items = [];
|
||||||
evs.forEach(ev => {
|
evs.forEach(ev => {
|
||||||
|
// For all-day events, normalize to date-only with inclusive end-day
|
||||||
|
// (iCal stores exclusive end → subtract 1). For timed events, keep
|
||||||
|
// the original strict-overlap logic so events ending exactly at
|
||||||
|
// midnight don't bleed into the next day.
|
||||||
|
let ns, ne;
|
||||||
|
if (ev.allDay) {
|
||||||
|
ns = new Date(ev.start); ns.setHours(0, 0, 0, 0);
|
||||||
|
ne = new Date(ev.end); ne.setHours(0, 0, 0, 0);
|
||||||
|
if (ne > ns) ne.setDate(ne.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
let colStart = -1, colEnd = -1;
|
let colStart = -1, colEnd = -1;
|
||||||
days.forEach((day, i) => {
|
days.forEach((day, i) => {
|
||||||
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
|
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
|
||||||
const de = new Date(day); de.setHours(24, 0, 0, 0);
|
let matches;
|
||||||
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
|
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;
|
if (colStart === -1) colStart = i;
|
||||||
colEnd = i;
|
colEnd = i;
|
||||||
}
|
}
|
||||||
|
|||||||
107
frontend/sw.js
107
frontend/sw.js
@@ -1,39 +1,21 @@
|
|||||||
// Calendarr Service Worker
|
// Calendarr Service Worker — minimal-cache strategy
|
||||||
// Cache-first for static assets, network-first for /api/* (graceful offline)
|
//
|
||||||
|
// Strategy: network-first for everything. The cache is only used as a
|
||||||
|
// last-resort fallback when offline (so the app shell still opens). This
|
||||||
|
// means every online request hits the network and respects the
|
||||||
|
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
|
||||||
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v11';
|
const CACHE_VERSION = 'calendarr-v23';
|
||||||
const STATIC_ASSETS = [
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
'/',
|
|
||||||
'/index.html',
|
|
||||||
'/manifest.json',
|
|
||||||
'/static/css/app.css',
|
|
||||||
'/static/favicon.svg',
|
|
||||||
'/static/js/app.js',
|
|
||||||
'/static/js/api.js',
|
|
||||||
'/static/js/calendar.js',
|
|
||||||
'/static/js/color-picker.js',
|
|
||||||
'/static/js/date-picker.js',
|
|
||||||
'/static/js/i18n.js',
|
|
||||||
'/static/js/utils.js',
|
|
||||||
'/static/js/version.js',
|
|
||||||
'/static/js/views/agenda.js',
|
|
||||||
'/static/js/views/month.js',
|
|
||||||
'/static/js/views/quarter.js',
|
|
||||||
'/static/js/views/week.js',
|
|
||||||
'/icons/icon-192.png',
|
|
||||||
'/icons/icon-512.png',
|
|
||||||
'/icons/icon.svg',
|
|
||||||
];
|
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_VERSION).then(cache =>
|
caches.open(CACHE_VERSION).then(cache =>
|
||||||
// Use addAll with a fallback so a single missing file doesn't abort install
|
Promise.all(OFFLINE_SHELL.map(url =>
|
||||||
Promise.all(
|
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||||
STATIC_ASSETS.map(url =>
|
))
|
||||||
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).then(() => self.skipWaiting())
|
).then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -52,7 +34,8 @@ self.addEventListener('fetch', event => {
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
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/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(req).catch(() =>
|
fetch(req).catch(() =>
|
||||||
@@ -65,45 +48,29 @@ self.addEventListener('fetch', event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-first for navigation (HTML) and the version-defining files —
|
// Everything else: network-first. The browser's HTTP cache (driven by
|
||||||
// ensures users always get the freshest entry point so new releases
|
// the server's Cache-Control headers) already throttles re-fetches —
|
||||||
// take effect on the next reload without a manual SW unregister.
|
// the SW just makes sure offline still works for the entry HTML.
|
||||||
const isHtml = req.mode === 'navigate'
|
|
||||||
|| url.pathname === '/'
|
|
||||||
|| url.pathname === '/index.html';
|
|
||||||
const isVersionFile = url.pathname === '/static/js/version.js';
|
|
||||||
|
|
||||||
if (isHtml || isVersionFile) {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(req).then(resp => {
|
|
||||||
if (resp && resp.status === 200) {
|
|
||||||
const clone = resp.clone();
|
|
||||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
}).catch(() =>
|
|
||||||
caches.match(req).then(c => c || caches.match('/index.html'))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache-first for everything else (static)
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then(cached => {
|
fetch(req).then(resp => {
|
||||||
if (cached) return cached;
|
// Keep a fresh copy of navigation requests / index.html for offline
|
||||||
return fetch(req).then(resp => {
|
const isNavigation = req.mode === 'navigate'
|
||||||
// Only cache successful, basic-origin responses
|
|| url.pathname === '/'
|
||||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
|| url.pathname === '/index.html';
|
||||||
const clone = resp.clone();
|
if (isNavigation && resp && resp.status === 200) {
|
||||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
const clone = resp.clone();
|
||||||
}
|
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||||
return resp;
|
}
|
||||||
}).catch(() => {
|
return resp;
|
||||||
// Offline fallback for navigation requests
|
}).catch(() => {
|
||||||
if (req.mode === 'navigate') return caches.match('/index.html');
|
// Offline fallback: only the HTML shell is served from cache, so the
|
||||||
return new Response('', { status: 503 });
|
// 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
3
requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
|
httpx>=0.27
|
||||||
Reference in New Issue
Block a user