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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-01 10:19:01 +02:00
parent a591eb51d3
commit 47944ed952
9 changed files with 530 additions and 6 deletions

41
backend/dav_util.py Normal file
View File

@@ -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}/"

View File

@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import auth_router, caldav_router, google_router, 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) logging.basicConfig(level=logging.INFO)
@@ -225,6 +225,25 @@ def _migrate():
except Exception: except Exception:
pass 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() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) 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(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"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -124,6 +124,12 @@ class LocalCalendar(Base):
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Whether events of this calendar generate reminders/notifications on clients. # Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False) 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") user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -152,6 +158,8 @@ class LocalEvent(Base):
creator_name_external = Column(Text, nullable=True) creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting. # Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False) 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") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User") creator = relationship("User")

View File

@@ -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""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
<D:supported-report-set>
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
</D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _event_propstat(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 <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _multistatus(body: str) -> Response:
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers ───────────────────────────────────────
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)

View File

@@ -2,11 +2,12 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional 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 fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import dav_util
import ical_io import ical_io
import models import models
import permissions import permissions
@@ -31,6 +32,7 @@ class CalendarUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
reminders_enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None
caldav_published: Optional[bool] = None
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -67,16 +69,21 @@ class ShareCreate(BaseModel):
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, 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 = { 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,
"reminders_enabled": bool(cal.reminders_enabled), "reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published),
"type": "local", "type": "local",
"owned": owned, "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: if shared_by is not None:
d["shared_by"] = shared_by d["shared_by"] = shared_by
if permission is not None: if permission is not None:
@@ -92,6 +99,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
@router.get("/calendars") @router.get("/calendars")
def list_calendars( def list_calendars(
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -117,7 +125,7 @@ def list_calendars(
) )
result = [] result = []
for c in own: 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: if c.id in group_cal_map:
d["group"] = True d["group"] = True
d["shared_by"] = group_cal_map[c.id] # group name, for labelling d["shared_by"] = group_cal_map[c.id] # group name, for labelling
@@ -182,6 +190,7 @@ def create_calendar(
def update_calendar( def update_calendar(
calendar_id: int, calendar_id: int,
data: CalendarUpdate, data: CalendarUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -203,8 +212,47 @@ def update_calendar(
cal.enabled = data.enabled cal.enabled = data.enabled
if data.reminders_enabled is not None: if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled 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() 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}") @router.delete("/calendars/{calendar_id}")
@@ -257,6 +305,7 @@ def create_event(
creator_id=current_user.id, # server-side, never from the client creator_id=current_user.id, # server-side, never from the client
) )
db.add(ev) db.add(ev)
dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
return _event_dict(ev, cal, db) return _event_dict(ev, cal, db)
@@ -305,6 +354,7 @@ def update_event(
ev.exdate = ",".join(dates) ev.exdate = ",".join(dates)
if data.reminders is not None: if data.reminders is not None:
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else 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() db.commit()
return {"ok": True} return {"ok": True}
@@ -316,6 +366,7 @@ def delete_event(
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
ev = _writable_event(db, current_user, uid) ev = _writable_event(db, current_user, uid)
dav_util.bump_dav(ev.calendar)
db.delete(ev) db.delete(ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -441,9 +492,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
rrule=item.get("rrule"), rrule=item.get("rrule"),
exdate=item.get("exdate"), exdate=item.get("exdate"),
creator_name_external=item.get("organizer"), creator_name_external=item.get("organizer"),
etag=dav_util.new_tag(),
) )
db.add(ev) db.add(ev)
imported += 1 imported += 1
if imported:
dav_util.bump_dav(cal)
try: try:
db.commit() db.commit()
except Exception as exc: except Exception as exc:

View File

@@ -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-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-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
.ct-toggle { cursor: pointer; } .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, .ct-bell { opacity: .45; transition: opacity .15s; }
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
.ct-eye:hover, .ct-bell:hover { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; }

View File

@@ -3447,6 +3447,7 @@ function renderCalendarTable() {
if (cal.group) continue; if (cal.group) continue;
const owned = cal.owned !== false; const owned = cal.owned !== false;
const canWrite = owned || cal.permission === 'read_write'; const canWrite = owned || cal.permission === 'read_write';
const pub = !!cal.caldav_published;
rows += `<tr> rows += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td> <td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
<td class="ct-src">Lokal</td> <td class="ct-src">Lokal</td>
@@ -3455,11 +3456,23 @@ function renderCalendarTable() {
<td> <td>
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button> <button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''} ${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
${owned ? `<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>` : ''}
</td> </td>
<td>—</td> <td>—</td>
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td> <td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
</tr>`; </tr>`;
rowCount++; rowCount++;
if (owned && pub) {
rows += `<tr class="ct-dav-row"><td colspan="7">
<div class="ct-dav-box">
<span class="ct-dav-label">${t('caldav_published_url')}</span>
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_url || '')}">
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
<button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button>
</div>
<div class="ct-dav-hint">${t('caldav_hint')}</div>
</td></tr>`;
}
} }
// iCal subscriptions // iCal subscriptions
@@ -3656,6 +3669,42 @@ function renderCalendarTable() {
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId))); 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 // Delete
container.querySelectorAll('[data-ct-del]').forEach(btn => { container.querySelectorAll('[data-ct-del]').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {

View File

@@ -114,6 +114,15 @@ const translations = {
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.', reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.',
calendar_reminders_on: 'Benachrichtigungen aktiviert', calendar_reminders_on: 'Benachrichtigungen aktiviert',
calendar_reminders_off: 'Benachrichtigungen deaktiviert', 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', share: 'Teilen',
import: 'Importieren', import: 'Importieren',
export: 'Exportieren', export: 'Exportieren',
@@ -407,6 +416,15 @@ const translations = {
reminders_calendar_off: 'Reminders are disabled for this calendar they will not fire.', reminders_calendar_off: 'Reminders are disabled for this calendar they will not fire.',
calendar_reminders_on: 'Reminders enabled', calendar_reminders_on: 'Reminders enabled',
calendar_reminders_off: 'Reminders disabled', 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', share: 'Share',
import: 'Import', import: 'Import',
export: 'Export', export: 'Export',

View File

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