feat(calendar): en læst Google-kalender, med sit eget endpoint og sin egen sweep

Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google
udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr.
konto, hver med sine egne scopes — så en forbundet kalender rører ikke en
Gmail, der virker, og at afbryde den ene tager ikke den anden med.

Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde,
hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket
token-lager der skrives* bag et opslag — og går det galt, går det galt ved at
overskrive en virkende Gmail-forbindelse med kalender-tokens.

Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et
månedsgitter bladrer, og et kald til Google pr. piletast er en kalender,
der er langsommere end den kalender, den viser. Vinduet har en kant, og
endpointet siger hvor den er: et tomt svar uden forklaring er en kalender,
der lydløst påstår, at en dag er fri.
This commit is contained in:
Kristian Wind
2026-08-20 07:01:14 +02:00
parent 7828c1e6ed
commit 987d4edfce
16 changed files with 1476 additions and 22 deletions
+17
View File
@@ -61,6 +61,10 @@ const MESSAGES = {
// Same shape for a mailbox read over IMAP: quoted when the host said something,
// this when it did not.
mailbox_failed: 'error.mailboxRefused',
// And for a calendar read from Google. Its own code, because the sentence this
// is wrapped in names where it came from — and "Gmail said no" about a calendar
// sends somebody to the wrong panel.
calendar_failed: 'error.calendarRefused',
last_admin: 'error.lastAdmin'
};
@@ -84,6 +88,9 @@ export function humanMessage(err) {
if (err.code === 'gmail_failed' && err.message) {
return t('error.gmailSaid', { what: err.message });
}
if (err.code === 'calendar_failed' && err.message) {
return t('error.calendarSaid', { what: err.message });
}
const key = MESSAGES[err.code];
if (key) return t(key);
@@ -361,6 +368,16 @@ export const api = {
authorizeGmail: () => post('/gmail/authorize'),
syncGmail: () => post('/gmail/sync'),
// The Google Calendar connection. Read-only: there is nothing here that writes
// an event, only which calendars to look at.
getCalendar: () => get('/calendar'),
setCalendars: (shown) => put('/calendar/calendars', { shown }),
disconnectCalendar: () => del('/calendar'),
authorizeCalendar: () => post('/calendar/authorize'),
syncCalendar: () => post('/calendar/sync'),
calendarEvents: (from, to) =>
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
// Notes. Listing and searching are one endpoint because they answer the same
// question with different words.
notes: (params = {}) => {