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:
@@ -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 += `<tr>
|
||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||
<td class="ct-src">Lokal</td>
|
||||
@@ -3455,11 +3456,23 @@ function renderCalendarTable() {
|
||||
<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>
|
||||
${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>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
|
||||
</tr>`;
|
||||
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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v63';
|
||||
export const APP_VERSION = 'v64';
|
||||
|
||||
Reference in New Issue
Block a user