feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s

Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden
vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget
om begivenheder ændrer sig.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler —
det er en konvention, der får et skrivebord til at give adressen til
kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links
sådan. At afvise den ville være at afvise folk på en teknikalitet.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 21:26:13 +02:00
co-authored by Claude Opus 5
parent 968a29ca67
commit dcb71f4c14
14 changed files with 996 additions and 12 deletions
+4
View File
@@ -383,6 +383,10 @@ export const api = {
// an event, only which calendars to look at.
getCalendar: () => get('/calendar'),
setCalendars: (shown) => put('/calendar/calendars', { shown }),
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
// én: adressen er identiteten, som tokenet er det for OAuth.
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
disconnectCalendar: () => del('/calendar'),
authorizeCalendar: () => post('/calendar/authorize'),
syncCalendar: () => post('/calendar/sync'),
+7
View File
@@ -671,6 +671,13 @@ export const da = {
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
'int.whichCalendars': 'Hvilke kalendere vises',
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
'int.subscriptions': 'Abonnementer',
'int.subscriptionsHint': 'En kalender, du kun har en adresse til — helligdage, en klub, en fælleskalender fra arbejdet. Den hentes hvert kvarter og er skrivebeskyttet. webcal://-adresser virker; de er https med et andet skema foran.',
'int.subscriptionURL': 'Kalenderens adresse',
'int.subscribe': 'Abonnér',
'int.subscribing': 'Henter …',
'int.unsubscribe': 'Fjern',
'int.unsubscribeQuestion': 'Fjern abonnementet på "{name}"? Begivenhederne forsvinder fra kalenderen.',
'int.calendarLastSync': 'Sidst hentet {when}.',
'int.calendarNeverSynced': 'Ikke hentet endnu.',
'int.calendarFetchedOne': '{n} begivenhed hentet.',
+7
View File
@@ -667,6 +667,13 @@ export const en = {
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
'int.whichCalendars': 'Which calendars are shown',
'int.noCalendars': 'The account has no calendars to show.',
'int.subscriptions': 'Subscriptions',
'int.subscriptionsHint': 'A calendar you only have an address to — holidays, a club, a shared calendar from work. Fetched every fifteen minutes, and read-only. webcal:// addresses work; they are https with a different scheme in front.',
'int.subscriptionURL': "The calendar's address",
'int.subscribe': 'Subscribe',
'int.subscribing': 'Fetching …',
'int.unsubscribe': 'Remove',
'int.unsubscribeQuestion': 'Stop subscribing to "{name}"? Its events go from the calendar.',
'int.calendarLastSync': 'Last fetched {when}.',
'int.calendarNeverSynced': 'Not fetched yet.',
'int.calendarFetchedOne': '{n} event fetched.',
@@ -191,6 +191,41 @@
}
}
// --- abonnementer ---------------------------------------------------------
let subscriptionURL = $state('');
let subscribing = $state(false);
let subscriptionError = $state('');
async function subscribe(event) {
event.preventDefault();
const url = subscriptionURL.trim();
if (!url) return;
subscribing = true;
subscriptionError = '';
try {
await api.subscribeCalendar(url, '');
subscriptionURL = '';
calendar = await api.getCalendar();
} catch (e) {
// Feltets egen fejl frem for en toast: den hører til det felt, der lige
// blev skrevet i, og en toast, der forsvinder, er en fejlmeddelelse, man
// skal huske.
subscriptionError = e.fields?.url ?? humanMessage(e);
} finally {
subscribing = false;
}
}
async function unsubscribe(sub) {
if (!confirm(t('int.unsubscribeQuestion', { name: sub.account || sub.url }))) return;
try {
await api.unsubscribeCalendar(sub.id);
calendar = await api.getCalendar();
} catch (e) {
app.toast(humanMessage(e));
}
}
async function saveCalendars(event) {
event.preventDefault();
savingCalendars = true;
@@ -548,6 +583,48 @@
</div>
</form>
{/if}
<!-- Abonnementer står uden for Google-forgreningen ovenfor, og det er hele
pointen: en konto, der ikke kan bruge instansens OAuth-klient — en privat
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
{#if calendar !== null}
<div class="subs">
<h3>{t('int.subscriptions')}</h3>
<p class="hint">{t('int.subscriptionsHint')}</p>
{#if calendar.subscriptions?.length}
<ul class="subs-list">
{#each calendar.subscriptions as sub (sub.id)}
<li>
<span class="name">{sub.account || sub.url}</span>
<span class="url mono">{sub.url}</span>
<button class="danger" onclick={() => unsubscribe(sub)}>
{t('int.unsubscribe')}
</button>
</li>
{/each}
</ul>
{/if}
<form onsubmit={subscribe}>
<div class="field">
<label for="calendar-url">{t('int.subscriptionURL')}</label>
<input
id="calendar-url"
class="mono"
bind:value={subscriptionURL}
placeholder="https://… eller webcal://…"
/>
{#if subscriptionError}<p class="error">{subscriptionError}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={subscribing}>
{subscribing ? t('int.subscribing') : t('int.subscribe')}
</button>
</div>
</form>
</div>
{/if}
</section>
<section class="panel">