From 47944ed952c0840005e6c07c6a0e8f525119c61c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 1 Jul 2026 10:19:01 +0200 Subject: [PATCH] feat: publish local calendars via two-way CalDAV (opt-in, secret token URL) Backend: - LocalCalendar gains caldav_published + dav_token + dav_ctag; LocalEvent gains etag (migrations in main.py). bump_dav() refreshes ctag/etag on every local event write (create/update/delete/import). - local_router: PUT /calendars/{id} accepts caldav_published (mints/revokes token), new POST /calendars/{id}/dav-token/rotate, _cal_dict exposes caldav_published + caldav_url. - New dav_router mounted at root (/dav/{token}/...): a minimal two-way CalDAV server (OPTIONS/PROPFIND/REPORT/GET/PUT/DELETE) reusing ical_io build/parse, ctag-based change detection. Secret token = auth, no login. Frontend: - Settings calendar table: per-local-calendar publish toggle + subscribe URL with copy and token-rotate; i18n (de/en) and styling. Note: reverse proxy must allow WebDAV methods (PROPFIND/REPORT/PUT/DELETE). VALARM/reminders are not round-tripped via CalDAV. Co-Authored-By: Claude Opus 4.8 --- backend/dav_util.py | 41 ++++ backend/main.py | 24 ++- backend/models.py | 8 + backend/routers/dav_router.py | 322 ++++++++++++++++++++++++++++++++ backend/routers/local_router.py | 62 +++++- frontend/css/app.css | 10 + frontend/js/calendar.js | 49 +++++ frontend/js/i18n.js | 18 ++ frontend/js/version.js | 2 +- 9 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 backend/dav_util.py create mode 100644 backend/routers/dav_router.py diff --git a/backend/dav_util.py b/backend/dav_util.py new file mode 100644 index 0000000..e9a7e82 --- /dev/null +++ b/backend/dav_util.py @@ -0,0 +1,41 @@ +"""Shared helpers for CalDAV publishing of local calendars. + +Publishing is opt-in per calendar: a published calendar gets a secret +``dav_token`` and is reachable as a two-way CalDAV collection at +``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect +changes; each event carries an ``etag`` that changes on write. Rotating the +token revokes existing subscriptions. +""" + +from __future__ import annotations + +import secrets +import uuid + + +def new_token() -> str: + """A URL-safe, unguessable token used as the CalDAV collection path.""" + return secrets.token_urlsafe(24) + + +def new_tag() -> str: + """A fresh ctag/etag value.""" + return uuid.uuid4().hex + + +def bump_dav(cal, event=None) -> None: + """Mark a calendar (and optionally an event) as changed for CalDAV clients. + + Safe to call unconditionally on every local-event write — it only refreshes + opaque change tags, so unpublished calendars are unaffected. + """ + if cal is not None: + cal.dav_ctag = new_tag() + if event is not None: + event.etag = new_tag() + + +def caldav_url(request, token: str) -> str: + """Absolute CalDAV collection URL for a token, based on the request origin.""" + base = str(request.base_url).rstrip("/") + return f"{base}/dav/{token}/" diff --git a/backend/main.py b/backend/main.py index 17cce1a..7ea0e53 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -225,6 +225,25 @@ def _migrate(): except Exception: pass + # CalDAV publishing of local calendars (opt-in, secret token URL). + for col, ddl in ( + ("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"), + ("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"), + ("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"), + ): + try: + conn.execute(text(ddl)) + conn.commit() + logging.info("Migration: added %s to local_calendars", col) + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)")) + conn.commit() + logging.info("Migration: added etag to local_events") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) @@ -272,6 +291,9 @@ app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) +# CalDAV publishing lives at root scope (no /api prefix) and must be registered +# before the SPA catch-all so /dav/... isn't swallowed by the index fallback. +app.include_router(dav_router.router, tags=["dav"]) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") diff --git a/backend/models.py b/backend/models.py index f4cdac3..26f3d7b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -124,6 +124,12 @@ class LocalCalendar(Base): enabled = Column(Boolean, default=True) # Whether events of this calendar generate reminders/notifications on clients. reminders_enabled = Column(Boolean, default=True, nullable=False) + # CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV + # collection reachable via a secret token URL. dav_ctag changes on every + # event write so clients detect changes; rotating the token revokes access. + caldav_published = Column(Boolean, default=False, nullable=False) + dav_token = Column(String(64), nullable=True, unique=True) + dav_ctag = Column(String(32), nullable=True) user = relationship("User", back_populates="local_calendars") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") @@ -152,6 +158,8 @@ class LocalEvent(Base): creator_name_external = Column(Text, nullable=True) # Private events are filtered for other group members per their visibility setting. is_private = Column(Boolean, default=False) + # CalDAV entity tag — changes on every write so CalDAV clients detect updates. + etag = Column(String(32), nullable=True) calendar = relationship("LocalCalendar", back_populates="events") creator = relationship("User") diff --git a/backend/routers/dav_router.py b/backend/routers/dav_router.py new file mode 100644 index 0000000..6e4285b --- /dev/null +++ b/backend/routers/dav_router.py @@ -0,0 +1,322 @@ +"""Minimal two-way CalDAV server for published local calendars. + +Each published local calendar is reachable as a CalDAV collection at +``/dav/{token}/`` (secret token = auth). Supports the subset real clients +(Thunderbird, DAVx5, Apple Calendar) need: OPTIONS, PROPFIND, REPORT +(calendar-query / calendar-multiget), GET, PUT and DELETE. Change detection is +ctag-based (CS:getctag on the collection + getetag per event), which avoids +maintaining deletion tombstones. + +Reuses ``ical_io.build_ics`` / ``parse_ics`` for (de)serialisation. Note: +VALARM/reminders are not round-tripped (parse_ics ignores them). +""" + +from __future__ import annotations + +import uuid +import xml.etree.ElementTree as ET +from urllib.parse import quote, unquote +from xml.sax.saxutils import escape as xml_escape + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import Response +from sqlalchemy.orm import Session + +import dav_util +import ical_io +import models +from database import get_db + +router = APIRouter() + +# XML namespaces used across WebDAV / CalDAV. +NS_DAV = "DAV:" +NS_CAL = "urn:ietf:params:xml:ns:caldav" +NS_CS = "http://calendarserver.org/ns/" +NS_ICAL = "http://apple.com/ns/ical/" + +_NS_DECL = ( + 'xmlns:D="DAV:" ' + 'xmlns:C="urn:ietf:params:xml:ns:caldav" ' + 'xmlns:CS="http://calendarserver.org/ns/" ' + 'xmlns:ICAL="http://apple.com/ns/ical/"' +) + +_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT" +_MULTISTATUS_CT = "application/xml; charset=utf-8" + + +# ── Helpers ─────────────────────────────────────────────── + +def _resolve(token: str, db: Session) -> models.LocalCalendar | None: + if not token: + return None + return ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.dav_token == token, + models.LocalCalendar.caldav_published == True, # noqa: E712 + ) + .first() + ) + + +def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]: + return ( + db.query(models.LocalEvent) + .filter(models.LocalEvent.calendar_id == cal.id) + .all() + ) + + +def _etag(ev: models.LocalEvent) -> str: + return ev.etag or "0" + + +def _resource_name(ev: models.LocalEvent) -> str: + return f"{quote(ev.uid, safe='')}.ics" + + +def _collection_href(token: str) -> str: + return f"/dav/{token}/" + + +def _event_href(token: str, ev: models.LocalEvent) -> str: + return f"/dav/{token}/{_resource_name(ev)}" + + +def _name_cache(cal: models.LocalCalendar, db: Session) -> dict: + owner = db.query(models.User).filter(models.User.id == cal.user_id).first() + if owner: + return {owner.id: (owner.display_name or owner.username)} + return {} + + +def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str: + return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db)) + + +# ── XML builders ────────────────────────────────────────── + +def _collection_propstat(cal: models.LocalCalendar, token: str) -> str: + href = _collection_href(token) + return f""" + {href} + + + + {xml_escape(cal.name or "")} + {xml_escape(cal.dav_ctag or "0")} + + + + + + {xml_escape(cal.color or "#34a853")} + {href} + {href} + {href} + + HTTP/1.1 200 OK + + """ + + +def _event_propstat(token: str, ev: models.LocalEvent, *, with_data: bool = False, + ics: str | None = None) -> str: + href = _event_href(token, ev) + data = "" + if with_data and ics is not None: + data = f"\n {xml_escape(ics)}" + return f""" + {href} + + + + "{xml_escape(_etag(ev))}" + text/calendar; charset=utf-8; component=VEVENT{data} + + HTTP/1.1 200 OK + + """ + + +def _multistatus(body: str) -> Response: + xml = f'\n\n{body}\n' + return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT) + + +# ── Method handlers ─────────────────────────────────────── + +def _handle_options() -> Response: + return Response(status_code=200, headers={ + "DAV": "1, 2, 3, calendar-access", + "Allow": _ALLOW, + }) + + +def _handle_propfind(cal: models.LocalCalendar, token: str, resource: str, + depth: str, db: Session) -> Response: + # PROPFIND on a single event resource. + if resource: + ev = _find_event(cal, resource, db) + if not ev: + return Response(status_code=404) + return _multistatus(_event_propstat(token, ev)) + + # Collection: always include collection props; Depth:1 adds each event. + parts = [_collection_propstat(cal, token)] + if depth != "0": + for ev in _events(cal, db): + parts.append(_event_propstat(token, ev)) + return _multistatus("\n".join(parts)) + + +def _handle_report(cal: models.LocalCalendar, token: str, body: bytes, db: Session) -> Response: + report_type = None + hrefs: list[str] = [] + if body: + try: + root = ET.fromstring(body) + report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget + hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text] + except ET.ParseError: + pass + + if report_type == "calendar-multiget" and hrefs: + # Decode each requested href to a bare "{uid}.ics" for encoding-safe match. + wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs} + evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted] + else: + # calendar-query (or unknown) → return the whole calendar. + evs = _events(cal, db) + + parts = [] + for ev in evs: + ics = _build_ics(cal, [ev], db) + parts.append(_event_propstat(token, ev, with_data=True, ics=ics)) + return _multistatus("\n".join(parts) if parts else "") + + +def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None: + name = resource.rsplit("/", 1)[-1] + if name.endswith(".ics"): + name = name[:-4] + uid = unquote(name) + return ( + db.query(models.LocalEvent) + .filter( + models.LocalEvent.calendar_id == cal.id, + models.LocalEvent.uid == uid, + ) + .first() + ) + + +def _handle_get(cal: models.LocalCalendar, resource: str, db: Session, + *, head: bool = False) -> Response: + ev = _find_event(cal, resource, db) + if not ev: + return Response(status_code=404) + ics = _build_ics(cal, [ev], db) + headers = {"ETag": f'"{_etag(ev)}"'} + return Response( + content=b"" if head else ics, + media_type="text/calendar; charset=utf-8", + headers=headers, + ) + + +def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response: + try: + parsed = ical_io.parse_ics(body) + except ValueError: + return Response(status_code=400) + items = parsed.get("events") or [] + if not items: + return Response(status_code=400) + item = items[0] + + # Key by the VEVENT UID; fall back to the resource name. + uid = item.get("uid") + if not uid: + name = resource.rsplit("/", 1)[-1] + uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4()) + + ev = ( + db.query(models.LocalEvent) + .filter(models.LocalEvent.uid == uid) + .first() + ) + created = ev is None + if created: + ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id) + db.add(ev) + ev.title = item.get("title") or "(ohne Titel)" + ev.start = item["start"] + ev.end = item["end"] + ev.all_day = item.get("all_day", False) + ev.location = item.get("location") + ev.description = item.get("description") + ev.rrule = item.get("rrule") + ev.exdate = item.get("exdate") + dav_util.bump_dav(cal, ev) + db.commit() + db.refresh(ev) + return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'}) + + +def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response: + ev = _find_event(cal, resource, db) + if not ev: + return Response(status_code=404) + dav_util.bump_dav(cal) + db.delete(ev) + db.commit() + return Response(status_code=204) + + +# ── Dispatch ────────────────────────────────────────────── + +async def _dispatch(request: Request, token: str, resource: str, db: Session) -> Response: + method = request.method.upper() + + # OPTIONS advertises capabilities without needing a valid token. + if method == "OPTIONS": + return _handle_options() + + cal = _resolve(token, db) + if not cal: + return Response(status_code=404) + + if method == "PROPFIND": + depth = request.headers.get("Depth", "0") + return _handle_propfind(cal, token, resource, depth, db) + if method == "REPORT": + return _handle_report(cal, token, await request.body(), db) + if method in ("GET", "HEAD"): + if not resource: + # A bare collection GET: hand back the whole calendar as one .ics. + ics = _build_ics(cal, _events(cal, db), db) + return Response(content=ics, media_type="text/calendar; charset=utf-8") + return _handle_get(cal, resource, db, head=(method == "HEAD")) + if method == "PUT": + return _handle_put(cal, resource, await request.body(), db) + if method == "DELETE": + if not resource: + return Response(status_code=403) # don't delete the collection itself + return _handle_delete(cal, resource, db) + return Response(status_code=405, headers={"Allow": _ALLOW}) + + +_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"] + + +@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False) +async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)): + return await _dispatch(request, token, "", db) + + +@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False) +async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)): + return await _dispatch(request, token, resource, db) diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index e514566..3f3be28 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -2,11 +2,12 @@ import uuid from datetime import datetime, timezone from typing import List, Optional -from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File +from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session +import dav_util import ical_io import models import permissions @@ -31,6 +32,7 @@ class CalendarUpdate(BaseModel): color: Optional[str] = None enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None + caldav_published: Optional[bool] = None class EventCreate(BaseModel): @@ -67,16 +69,21 @@ class ShareCreate(BaseModel): def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, - shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict: + shared_by: Optional[str] = None, permission: Optional[str] = None, + request: Optional[Request] = None) -> dict: d = { "id": cal.id, "name": cal.name, "color": cal.color, "enabled": cal.enabled, "reminders_enabled": bool(cal.reminders_enabled), + "caldav_published": bool(cal.caldav_published), "type": "local", "owned": owned, } + # Only the owner may publish; expose the subscribe URL only when active. + if owned and cal.caldav_published and cal.dav_token: + d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None if shared_by is not None: d["shared_by"] = shared_by if permission is not None: @@ -92,6 +99,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) - @router.get("/calendars") def list_calendars( + request: Request, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): @@ -117,7 +125,7 @@ def list_calendars( ) result = [] for c in own: - d = _cal_dict(c, owned=True) + d = _cal_dict(c, owned=True, request=request) if c.id in group_cal_map: d["group"] = True d["shared_by"] = group_cal_map[c.id] # group name, for labelling @@ -182,6 +190,7 @@ def create_calendar( def update_calendar( calendar_id: int, data: CalendarUpdate, + request: Request, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): @@ -203,8 +212,47 @@ def update_calendar( cal.enabled = data.enabled if data.reminders_enabled is not None: cal.reminders_enabled = data.reminders_enabled + if data.caldav_published is not None: + cal.caldav_published = data.caldav_published + if data.caldav_published: + # First publish: mint a token + initial ctag so clients can sync. + if not cal.dav_token: + cal.dav_token = dav_util.new_token() + if not cal.dav_ctag: + cal.dav_ctag = dav_util.new_tag() + else: + # Unpublishing revokes access: drop the token so the URL 404s. + cal.dav_token = None db.commit() - return {"ok": True} + db.refresh(cal) + return _cal_dict(cal, owned=True, request=request) + + +@router.post("/calendars/{calendar_id}/dav-token/rotate") +def rotate_dav_token( + calendar_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Issue a fresh CalDAV token — the old subscribe URL stops working.""" + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == calendar_id, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + if not cal.caldav_published: + raise HTTPException(422, "Calendar is not published") + cal.dav_token = dav_util.new_token() + cal.dav_ctag = dav_util.new_tag() + db.commit() + db.refresh(cal) + return _cal_dict(cal, owned=True, request=request) @router.delete("/calendars/{calendar_id}") @@ -257,6 +305,7 @@ def create_event( creator_id=current_user.id, # server-side, never from the client ) db.add(ev) + dav_util.bump_dav(cal, ev) db.commit() db.refresh(ev) return _event_dict(ev, cal, db) @@ -305,6 +354,7 @@ def update_event( 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 + dav_util.bump_dav(ev.calendar, ev) db.commit() return {"ok": True} @@ -316,6 +366,7 @@ def delete_event( current_user: models.User = Depends(get_current_user), ): ev = _writable_event(db, current_user, uid) + dav_util.bump_dav(ev.calendar) db.delete(ev) db.commit() return {"ok": True} @@ -441,9 +492,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict rrule=item.get("rrule"), exdate=item.get("exdate"), creator_name_external=item.get("organizer"), + etag=dav_util.new_tag(), ) db.add(ev) imported += 1 + if imported: + dav_util.bump_dav(cal) try: db.commit() except Exception as exc: diff --git a/frontend/css/app.css b/frontend/css/app.css index c51fda2..e7b108f 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1462,6 +1462,16 @@ a { color: var(--primary); text-decoration: none; } .ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; } .ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; } .ct-toggle { cursor: pointer; } +.ct-dav-toggle.on { color: var(--primary); } +.ct-dav-row td { padding-top: 4px; padding-bottom: 12px; } +.ct-dav-box { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.ct-dav-label { font-size: 12px; color: var(--text-3); } +.ct-dav-url { + flex: 1; min-width: 220px; font-size: 12px; padding: 5px 8px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--surface-2); color: var(--text-1); +} +.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; } .ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; } diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 711774b..dcc6017 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -3447,6 +3447,7 @@ function renderCalendarTable() { if (cal.group) continue; const owned = cal.owned !== false; const canWrite = owned || cal.permission === 'read_write'; + const pub = !!cal.caldav_published; rows += ` ${dot(cal.color, '#34a853')}${escHtml(cal.name)} Lokal @@ -3455,11 +3456,23 @@ function renderCalendarTable() { ${canWrite ? `` : ''} + ${owned ? `` : ''} — ${owned ? `` : ''} `; rowCount++; + if (owned && pub) { + rows += ` +
+ ${t('caldav_published_url')} + + + +
+
${t('caldav_hint')}
+ `; + } } // iCal subscriptions @@ -3656,6 +3669,42 @@ function renderCalendarTable() { btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId))); }); + // CalDAV publishing + container.querySelectorAll('.ct-dav-toggle').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.ctDavId); + const newVal = btn.dataset.ctDavOn !== '1'; + const cal = state.localCalendars.find(c => c.id === id); + if (!cal) return; + try { + const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal }); + cal.caldav_published = updated.caldav_published; + cal.caldav_url = updated.caldav_url; + renderCalendarTable(); + } catch (e) { showToast(e.message, true); } + }); + }); + container.querySelectorAll('.ct-dav-copy').forEach(btn => { + btn.addEventListener('click', () => { + const input = btn.parentElement.querySelector('.ct-dav-url'); + if (!input || !input.value) return; + navigator.clipboard.writeText(input.value).then(() => showToast(t('caldav_url_copied'))); + }); + }); + container.querySelectorAll('.ct-dav-rotate').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.ctDavId); + const cal = state.localCalendars.find(c => c.id === id); + if (!cal || !confirm(t('caldav_rotate_confirm'))) return; + try { + const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`); + cal.caldav_url = updated.caldav_url; + renderCalendarTable(); + showToast(t('caldav_token_rotated')); + } catch (e) { showToast(e.message, true); } + }); + }); + // Delete container.querySelectorAll('[data-ct-del]').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 7d57d28..1d2a46d 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -114,6 +114,15 @@ const translations = { reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.', calendar_reminders_on: 'Benachrichtigungen aktiviert', calendar_reminders_off: 'Benachrichtigungen deaktiviert', + copy: 'Kopieren', + caldav_publish: 'CalDAV veröffentlichen', + caldav_unpublish: 'CalDAV deaktivieren', + caldav_published_url: 'CalDAV-URL:', + caldav_url_copied: 'CalDAV-URL kopiert', + caldav_token_rotate: 'Token neu generieren', + caldav_token_rotated: 'Neuer Token erzeugt – alte Abos müssen neu eingerichtet werden', + caldav_rotate_confirm: 'Neuen Token erzeugen? Die bisherige URL wird ungültig und bestehende Abos müssen mit der neuen URL neu eingerichtet werden.', + caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.', share: 'Teilen', import: 'Importieren', export: 'Exportieren', @@ -407,6 +416,15 @@ const translations = { reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.', calendar_reminders_on: 'Reminders enabled', calendar_reminders_off: 'Reminders disabled', + copy: 'Copy', + caldav_publish: 'Publish via CalDAV', + caldav_unpublish: 'Unpublish CalDAV', + caldav_published_url: 'CalDAV URL:', + caldav_url_copied: 'CalDAV URL copied', + caldav_token_rotate: 'Regenerate token', + caldav_token_rotated: 'New token generated – existing subscriptions must be re-added', + caldav_rotate_confirm: 'Generate a new token? The current URL will stop working and existing subscriptions must be re-added with the new URL.', + caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).', share: 'Share', import: 'Import', export: 'Export', diff --git a/frontend/js/version.js b/frontend/js/version.js index cbcdd0a..44ac00c 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v63'; +export const APP_VERSION = 'v64';