feat(caldav): username/password (Basic Auth) access with discovery + https URLs

- dav_router: add Basic-Auth principal-discovery tree at /caldav/ (and
  /.well-known/caldav) so clients can add a CalDAV account with server URL +
  username + password; lists all published calendars. Token URL /dav/{token}/
  still works without login. Handlers generalised over a base href.
- dav_util: derive the public origin from X-Forwarded-Proto/-Host (or
  PUBLIC_BASE_URL) so published URLs are https, not internal http:8080.
- local_router: expose caldav_login_url alongside caldav_url.
- frontend/i18n: show both the no-login token URL and the login URL + hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-01 12:44:16 +02:00
parent 34e701fa78
commit fb32f0424f
6 changed files with 258 additions and 90 deletions

View File

@@ -9,6 +9,7 @@ token revokes existing subscriptions.
from __future__ import annotations from __future__ import annotations
import os
import secrets import secrets
import uuid import uuid
@@ -35,7 +36,28 @@ def bump_dav(cal, event=None) -> None:
event.etag = new_tag() event.etag = new_tag()
def public_base(request) -> str:
"""Public origin (scheme://host) as clients actually reach us.
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
ones.
"""
env = os.environ.get("PUBLIC_BASE_URL")
if env:
return env.rstrip("/")
h = request.headers
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
return f"{proto}://{host}"
def caldav_url(request, token: str) -> str: def caldav_url(request, token: str) -> str:
"""Absolute CalDAV collection URL for a token, based on the request origin.""" """Absolute per-calendar CalDAV collection URL (secret token, no login)."""
base = str(request.base_url).rstrip("/") return f"{public_base(request)}/dav/{token}/"
return f"{base}/dav/{token}/"
def caldav_login_url(request) -> str:
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
return f"{public_base(request)}/caldav/"

View File

@@ -1,30 +1,37 @@
"""Minimal two-way CalDAV server for published local calendars. """Minimal two-way CalDAV server for published local calendars.
Each published local calendar is reachable as a CalDAV collection at Two ways to reach a published local calendar:
``/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: 1. Secret token URL (no login) — ``/dav/{token}/``.
VALARM/reminders are not round-tripped (parse_ics ignores them). 2. Username + password (HTTP Basic Auth) with principal discovery —
``/caldav/`` advertises the user's calendar-home-set and lists every
published calendar as ``/caldav/{id}/``. This is what account-based clients
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
getetag per event), avoiding deletion tombstones.
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
round-tripped (parse_ics ignores them).
""" """
from __future__ import annotations from __future__ import annotations
import base64
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response from fastapi.responses import RedirectResponse, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import dav_util import dav_util
import ical_io import ical_io
import models import models
from auth import verify_password
from database import get_db from database import get_db
router = APIRouter() router = APIRouter()
@@ -44,6 +51,7 @@ _NS_DECL = (
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT" _ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
_MULTISTATUS_CT = "application/xml; charset=utf-8" _MULTISTATUS_CT = "application/xml; charset=utf-8"
_LOGIN_HREF = "/caldav/"
# ── Helpers ─────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────
@@ -61,6 +69,44 @@ def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
) )
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
"""Validate an HTTP Basic Authorization header against a Calendarr account."""
hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
except Exception:
return None
username, sep, password = raw.partition(":")
if not sep:
return None
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
return None
try:
if not verify_password(password, user.password_hash):
return None
except Exception:
return None
return user
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.all()
)
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]: def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
return ( return (
db.query(models.LocalEvent) db.query(models.LocalEvent)
@@ -77,12 +123,8 @@ def _resource_name(ev: models.LocalEvent) -> str:
return f"{quote(ev.uid, safe='')}.ics" return f"{quote(ev.uid, safe='')}.ics"
def _collection_href(token: str) -> str: def _event_href(base: str, ev: models.LocalEvent) -> str:
return f"/dav/{token}/" return f"{base}{_resource_name(ev)}"
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: def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
@@ -98,10 +140,13 @@ def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Sess
# ── XML builders ────────────────────────────────────────── # ── XML builders ──────────────────────────────────────────
def _collection_propstat(cal: models.LocalCalendar, token: str) -> str: def _collection_propstat(cal: models.LocalCalendar, base: str, *,
href = _collection_href(token) principal_href: str | None = None,
home_href: str | None = None) -> str:
principal_href = principal_href or base
home_href = home_href or base
return f""" <D:response> return f""" <D:response>
<D:href>{href}</D:href> <D:href>{base}</D:href>
<D:propstat> <D:propstat>
<D:prop> <D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype> <D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
@@ -113,6 +158,24 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
</D:supported-report-set> </D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-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> <ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _principal_propstat(user: models.User) -> str:
href = _LOGIN_HREF
name = user.display_name or user.username
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
<D:displayname>{xml_escape(name)}</D:displayname>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal> <D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL> <D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set> <C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
@@ -122,9 +185,9 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
</D:response>""" </D:response>"""
def _event_propstat(token: str, ev: models.LocalEvent, *, with_data: bool = False, def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
ics: str | None = None) -> str: ics: str | None = None) -> str:
href = _event_href(token, ev) href = _event_href(base, ev)
data = "" data = ""
if with_data and ics is not None: if with_data and ics is not None:
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>" data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
@@ -146,7 +209,7 @@ def _multistatus(body: str) -> Response:
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT) return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers ─────────────────────────────────────── # ── Method handlers (shared by token and Basic-Auth paths) ──
def _handle_options() -> Response: def _handle_options() -> Response:
return Response(status_code=200, headers={ return Response(status_code=200, headers={
@@ -155,49 +218,6 @@ def _handle_options() -> Response:
}) })
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: def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
name = resource.rsplit("/", 1)[-1] name = resource.rsplit("/", 1)[-1]
if name.endswith(".ics"): if name.endswith(".ics"):
@@ -213,6 +233,49 @@ def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models
) )
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
db: Session, *, principal_href: str | None = None,
home_href: str | None = None) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(base, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(base, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session, def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response: *, head: bool = False) -> Response:
ev = _find_event(cal, resource, db) ev = _find_event(cal, resource, db)
@@ -276,27 +339,20 @@ def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Res
return Response(status_code=204) return Response(status_code=204)
# ── Dispatch ────────────────────────────────────────────── async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
resource: str, db: Session, *,
async def _dispatch(request: Request, token: str, resource: str, db: Session) -> Response: principal_href: str | None = None,
home_href: str | None = None) -> Response:
"""Serve a single calendar collection; auth/ownership already checked."""
method = request.method.upper() 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": if method == "PROPFIND":
depth = request.headers.get("Depth", "0") depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, token, resource, depth, db) return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
if method == "REPORT": if method == "REPORT":
return _handle_report(cal, token, await request.body(), db) return _handle_report(cal, base, await request.body(), db)
if method in ("GET", "HEAD"): if method in ("GET", "HEAD"):
if not resource: if not resource:
# A bare collection GET: hand back the whole calendar as one .ics.
ics = _build_ics(cal, _events(cal, db), db) ics = _build_ics(cal, _events(cal, db), db)
return Response(content=ics, media_type="text/calendar; charset=utf-8") return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD")) return _handle_get(cal, resource, db, head=(method == "HEAD"))
@@ -309,14 +365,93 @@ async def _dispatch(request: Request, token: str, resource: str, db: Session) ->
return Response(status_code=405, headers={"Allow": _ALLOW}) return Response(status_code=405, headers={"Allow": _ALLOW})
# ── Token path (no login): /dav/{token}/… ─────────────────
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
base = f"/dav/{token}/"
return await _dispatch_collection(request, cal, base, resource, db)
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"] _METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False) @router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)): async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch(request, token, "", db) return await _dispatch_token(request, token, "", db)
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False) @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)): async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch(request, token, resource, db) return await _dispatch_token(request, token, resource, db)
# ── Basic-Auth path (username/password): /caldav/… ────────
async def _dispatch_home(request: Request, db: Session) -> Response:
"""Principal + calendar-home-set: lists the user's published calendars."""
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
if request.method.upper() != "PROPFIND":
return Response(status_code=405, headers={"Allow": _ALLOW})
depth = request.headers.get("Depth", "0")
parts = [_principal_propstat(user)]
if depth != "0":
for cal in _published_calendars(user, db):
parts.append(_collection_propstat(
cal, f"/caldav/{cal.id}/",
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
return _multistatus("\n".join(parts))
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == cal_id,
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
if not cal:
return Response(status_code=404)
base = f"/caldav/{cal.id}/"
return await _dispatch_collection(request, cal, base, resource, db,
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
async def wellknown_caldav(request: Request):
if request.method.upper() == "OPTIONS":
return _handle_options()
# Point discovery at the principal/home collection.
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
async def caldav_home(request: Request, db: Session = Depends(get_db)):
return await _dispatch_home(request, db)
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, "", db)
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, resource, db)

View File

@@ -81,9 +81,10 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
"type": "local", "type": "local",
"owned": owned, "owned": owned,
} }
# Only the owner may publish; expose the subscribe URL only when active. # Only the owner may publish; expose the subscribe URLs only when active.
if owned and cal.caldav_published and cal.dav_token: if owned and cal.caldav_published and cal.dav_token:
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
if shared_by is not None: 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:

View File

@@ -3471,6 +3471,12 @@ function renderCalendarTable() {
<button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button> <button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button>
</div> </div>
<div class="ct-dav-hint">${t('caldav_hint')}</div> <div class="ct-dav-hint">${t('caldav_hint')}</div>
<div class="ct-dav-box">
<span class="ct-dav-label">${t('caldav_login_url')}</span>
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_login_url || '')}">
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
</div>
<div class="ct-dav-hint">${t('caldav_login_hint')}</div>
</td></tr>`; </td></tr>`;
} }
} }

View File

@@ -117,12 +117,14 @@ const translations = {
copy: 'Kopieren', copy: 'Kopieren',
caldav_publish: 'CalDAV veröffentlichen', caldav_publish: 'CalDAV veröffentlichen',
caldav_unpublish: 'CalDAV deaktivieren', caldav_unpublish: 'CalDAV deaktivieren',
caldav_published_url: 'CalDAV-URL:', caldav_published_url: 'CalDAV-URL (ohne Login):',
caldav_url_copied: 'CalDAV-URL kopiert', caldav_url_copied: 'CalDAV-URL kopiert',
caldav_token_rotate: 'Token neu generieren', caldav_token_rotate: 'Token neu generieren',
caldav_token_rotated: 'Neuer Token erzeugt alte Abos müssen neu eingerichtet werden', 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_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.', caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten kein Login nötig. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.',
caldav_login_url: 'CalDAV-URL (mit Login):',
caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und Passwort hinzufügen dann werden alle deine veröffentlichten Kalender gefunden.',
share: 'Teilen', share: 'Teilen',
import: 'Importieren', import: 'Importieren',
export: 'Exportieren', export: 'Exportieren',
@@ -419,12 +421,14 @@ const translations = {
copy: 'Copy', copy: 'Copy',
caldav_publish: 'Publish via CalDAV', caldav_publish: 'Publish via CalDAV',
caldav_unpublish: 'Unpublish CalDAV', caldav_unpublish: 'Unpublish CalDAV',
caldav_published_url: 'CalDAV URL:', caldav_published_url: 'CalDAV URL (no login):',
caldav_url_copied: 'CalDAV URL copied', caldav_url_copied: 'CalDAV URL copied',
caldav_token_rotate: 'Regenerate token', caldav_token_rotate: 'Regenerate token',
caldav_token_rotated: 'New token generated existing subscriptions must be re-added', 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_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).', caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar — no login required. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).',
caldav_login_url: 'CalDAV URL (with login):',
caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and password — it will discover all your published calendars.',
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 = 'v66'; export const APP_VERSION = 'v67';