Compare commits

...
41 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 bdde9cc6a5 merge: Google Kalender som menupunkt, med Verdandes egen kalender lagt over
Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel:
kalenderen læses, og der skrives ingenting tilbage.

**OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som
*Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun
Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er
klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord,
man kan handle på, frem for at vise Googles egen skærm, der ikke gør.

Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen
OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det
næste stykke, og det eneste, der låser den private konto op.

`internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme
flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails:
én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved
at overskrive en Gmail-forbindelse, der virkede.

Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer
alle på "hvad gør en besked til en opgave", og ingenting her bliver til en
opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en
en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan
ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021
skulle igennem.

Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et
sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit,
og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret
ikke stille viser noget forkert.

`CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en
opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er
læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre
færdigt ved en andens møde.

To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den
må stå i en style, og linket bliver kun til et href, hvis det begynder med https.

Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:39:12 +02:00
Kristian WindandClaude Opus 5 6b2cd0f259 merge: engelske skærmbilleder, og otte steder hvor fladen blev ved med at være dansk
Release / Build and publish image (push) Failing after 9m54s
Skærmbillederne er engelske, fjorten i alt, og der er to mennesker i dem. Det
sidste er ikke pynt: halvdelen af det, programmet er til, findes ikke med én
konto — en ansvarlig, et navn i historikken, en medlemsliste, og "Venter på
andre" som andet end en tom side.

Sproget kommer fra kontoens locale og ikke fra strenge skrevet i hånden, hvilket
er dét, der fandt fejlene: otte steder, hvor grænsefladen blev ved med at være
dansk, uanset hvad kontoen sagde. "Forsinket" og "I dag" skrevet ind i markup,
"Uden sektion" som boardets første kolonne, gentagelsesreglerne halvt oversat,
alle nitten navne på hændelser i historikken, og et `da-DK` hårdkodet i Kommende.
Gentagelsesreglerne er flyttet til `when.js`, så menuen og mærket ikke kan blive
uenige igen — det er præcis, hvad den fil er til.

README's billeder havde været brudte siden `37605ba`, som lavede PNG'erne om til
WebP og slettede dem, uden at teksten fulgte med: forsidebilledet og alle seks i
tabellen var 404 på GitHub. Og noterne — hele funktionen — stod ikke nævnt hverken
i README, på forsiden eller i dokumentationen.

Nu skrives PNG til docs og WebP til site, altså præcis dét, de to steder læser.
Før efterlod hver kørsel tolv usporede PNG'er i site, som intet pegede på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:34:16 +02:00
Kristian Wind dcd66b415e docs(web): registreringsteksten fortæller nu, at klienten dækker begge dele
Hjælpeteksten sagde "slå Gmail API til", og det er kun det halve nu. Den samme
klient bærer både Gmail og Google Kalender og skal have begge redirect-URI'er —
og kalenderpanelet siger, hvor dets egen kommer fra, så ingen leder efter et
felt til en klienthemmelighed, der allerede står ovenfor.
2026-08-20 07:33:24 +02:00
Kristian WindandClaude Opus 5 3991ec9445 feat(notes): et billede kan indsættes og trækkes ind i en note
Rapporteret fra brug: man kunne ikke indsætte billeder eller trække filer ind i
noter. Det passede — importen kunne tage billeder med, og intet andet kunne.
Indsætningen tog kun `text/plain` og smed resten væk, og der var slet ingen
håndtering af et træk. At indsætte et skærmbillede er det mest almindelige, der
findes i et noteprogram, og det gjorde ingenting overhovedet.

Der manglede også et sted at lægge filen. Vedhæftninger på en note fandtes i
databasen, siden migration 0021, men kun importen kunne skrive dem; der var ingen
rute. Nu er der en, med editor-rettigheder på noten og samme svar for "findes
ikke" og "ikke din", så et id ikke kan afprøves.

Skrevet ind som `![](…)` og ikke som en data-URL: en note er Markdown i en
database, og et fotografi fra en telefon som base64 midt i teksten ville gøre
noten til fire megabyte tekst, der skal læses hver gang listen tegnes.

`execCommand` frem for at bygge noden selv, fordi det er dét, der lægger
handlingen i browserens egen fortryd-historik — ⌘Z tager billedet ud igen,
ligesom det tager et ord ud.

`preventDefault` på både dragover og drop. Uden den første kommer der intet drop;
uden den anden åbner browseren filen i stedet, og man mister det, man var i gang
med at skrive, til en fane med et billede i. Kun filer: et træk inde fra appen
selv bærer vores egen nyttelast og skal falde igennem til den, der lyttede.

Prøven går begge veje ind, ser at billederne bliver hentet og tegnet, og læser
filen bagefter for at sikre, at der står to bilagsadresser og ingen data-URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:32:03 +02:00
Kristian Wind a5f2f769c4 fix(web): begivenhederne i en celle sorteres, hvor de tegnes
Serveren sorterer allerede, men en celle, der viser 14:00 over 09:15, ligner
ikke en liste i forkert rækkefølge — den ligner et forkert klokkeslæt på
chippen, og det er langt værre at skulle udelukke. Heldags først: en heldags
begivenhed er et bånd hen over dagen, ikke noget der sker på et punkt i den.
2026-08-20 07:30:55 +02:00
Kristian WindandClaude Opus 5 9d743a5d46 fix(tools): shots skrev PNG'er ned i mappen med WebP
Standardstien var site/screenshots, som siden landingssiden gik over til WebP
kun indeholder WebP. En kørsel lagde tolv PNG'er ved siden af dem, som ingen
tekst peger på. Den skriver nu til docs/, hvor de andre PNG'er ligger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:48 +02:00
Kristian Wind 031bb18067 fix(calendar): en tom kalender skal sige hvorfor, ikke bare være tom
At vælge en kalender er nogen, der siger "vis mig den her". At gemme valget og
lade gitteret stå tomt, til fejeren kommer forbi et kvarter senere, er en
kalender, der påstår, at fjorten dage er fri — og personen står på siden med
knappen under hånden. Gemningen henter nu selv.

Og er der forbundet en konto uden at der er valgt nogen kalendere, siger
visningen dét. "Der er ikke valgt en kalender" og "der er ikke booket noget"
ser ens ud, og kun den ene er noget at gøre noget ved.
2026-08-20 07:27:42 +02:00
Kristian WindandClaude Opus 5 71a9fe63d6 docs: noterne findes også i teksten, og hvert billede har et sted at høre til
README'ets billeder havde været brudte siden landingssiden gik over til WebP:
site/screenshots/*.png blev slettet dengang, og markup'en pegede stadig på
dem. Den peger nu på PNG'erne i docs/, som er dem, der findes.

Noter er en af de største ting i appen og stod ikke ét sted i teksten —
hverken i README'et, på landingssiden eller i dokumentationen. Nu gør de,
med de to nye billeder.

Fem billeder pegede ingen tekst på: upcoming, sections, delegated, settings
og admin. De har fået et afsnit i docs/index.md, hvor hvert enkelt beviser
noget, siden siger. Landingssidens gitter viste den lyse udgave to gange —
den ene af dem er blevet til noterne.

Og to billedtekster passede ikke længere på billedet: temaerne viser nu også
de fire udseender, og opgaven i skuffen viser ikke sin samtale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:25:43 +02:00
Kristian WindandClaude Opus 5 eeaaf7506b docs: skærmbillederne er engelske, og der er to mennesker i dem
Landingssiden og dokumentationen læses på engelsk, og billederne var danske
hele vejen — både grænsefladen og indholdet. Kontoen bliver nu oprettet med
engelsk sprog frem for at oversætte noget her: det er sådan appen selv gør
det, og det er også dét, der får en glemt oversættelse til at stå i et
billede i stedet for at vente på en bruger.

Demodataene er skrevet om til engelsk arbejde og husholdning. Og der er en
person mere: en invitation, en konto, tre opgaver hun sidder på og en
kommentar. Halvdelen af det, appen er til for, findes først, når der er to i
den — en ansvarlig på en opgave, et navn i historikken, og "Venter på andre"
som andet end en tom side.

Tre billeder viste ikke det, teksten sagde. `detail` var en tom formular, så
opgaven har fået beskrivelse, undertasks, en fil, en påmindelse og en samtale.
`themes` viste profilfeltet over temaerne, så den ruller nu ned til dem.
`delegated` var en tom tilstand.

Noterne er kommet til — listen med en note åben, og den samme note som kilde.
Fjorten billeder i alt.

WebP'en skrives af den browser, der i forvejen står åben, frem for af cwebp:
et program mere at installere hører ikke til i et script, hvis hele pointe er,
at der ikke er noget at installere. PNG'erne ligger nu kun i docs/ og WebP'en
kun i site/, hvilket er præcis dét, de to steder læser — før lagde et kørsel
tolv usporede PNG'er i site/, som ingen brugte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:24:12 +02:00
Kristian Wind 1b1e57df29 docs: kalenderen den anden vej, og én registrering til begge Google-ting
`caldav.md` beskrev to måder at sende opgaver *ud*. Den tredje er den modsatte
retning og hører til samme sted, fordi det er dér, man ville lede.

`configuration.md` hed "Gmail" og handler nu om Google: samme klient, to
redirect-URI'er, to API'er, to scopes. Bemærkningen om en Internal-app står
begge steder, fordi den gælder begge veje — og for én privat konto er
kalenderens egen hemmelige ICS-adresse den kortere vej. Intet i verdande
abonnerer på sådan en endnu.
2026-08-20 07:24:01 +02:00
Kristian WindandClaude Opus 5 dce8800f93 fix(web): otte steder hvor grænsefladen blev ved med at være dansk
Skærmbillederne skulle tages på engelsk, og det var dét, der fandt dem:
en konto sat til engelsk fik stadig "Forsinket" over de forsinkede opgaver,
"Uden sektion" over den første kolonne på tavlen, "Aldrig" i gentagelses-
menuen, "hver dag" på rækken, "til instansen · inviteret af" på invitationen,
"3 projekter" på kontoen, og hele historikken — nitten hændelser, alle på
dansk. En funktion, der kun virker på det ene sprog, er en funktion, der er i
stykker.

Gentagelsen er den eneste, der ikke bare manglede en nøgle. Serveren skriver
`recurrence_text` på dansk og skal blive ved med det: én besked går over
WebSocket til alle i projektet på én gang, og de læser ikke det samme sprog.
Så de regler, der har et navn, får det i when.js — på den side, der ved, hvis
skærm det er — og alt mere specifikt falder tilbage til serverens ord.

Mellemrummet i historikken er skrevet ud. Svelte fjerner det, der åbner en
blok, så "inviterede" og adressen løb sammen til ét ord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:23:42 +02:00
Kristian Wind c3f2181efe feat(web): Kalender som menupunkt, med Googles begivenheder lagt over egne opgaver
Samme gitter som Kommende, givet en liste mere at tegne. Ikke et gitter nummer
to, og grunden er den del, der er nem at få næsten rigtig: et mål skal afgøre
ud fra `dataTransfer.types` alene, om det tager imod, og to kopier af det er to
steder at være næsten rigtig. En opgave trukket her flyttes præcis som alle
andre steder, fordi det er den samme kode, der gør det.

En begivenhed er ikke en opgave, og cellen siger det før et eneste ord er
læst: en flade af farve, hvor en opgave er et kort med en farvet kant. Den kan
ikke trækkes — forbindelsen er læsende, og en chip, der letter fra siden og så
ikke vil lande, læses som en fejl frem for som en regel — og den er ikke en
knap, for der er ikke noget at lukke ved en andens møde. Farven er Googles egen
for den kalender, så to kalendere i ét gitter kendes fra hinanden på den farve,
personen allerede kender dem på.

Farven tjekkes frem for at blive troet: den ender i en inline `style`, og en
værdi, der ikke er en farve, kan lukke erklæringen og åbne en anden. Linket
tegnes kun, hvis det begynder med `https://` — et href er dér, hvor en
`javascript:`-adresse bliver til et klik, der kører.

Prøven bruger uge-visningen med vilje: en månedscelle skæres af ved tre chips,
og resten af suiten lægger opgaver på andre dage. Datoen læses ud af siden, ikke
ud af testprocessen, som de øvrige har lært at gøre.
2026-08-20 07:22:25 +02:00
Kristian WindandClaude Opus 5 241c5ed37d fix(mail): den samme mail blev en opgave igen og igen
Release / Build and publish image (push) Failing after 9m58s
Rapporteret fra drift med et skærmbillede af den samme mail seks gange i træk.

**IMAP: flaget kommer af, når mailen er blevet en opgave.** Markøren var et uid —
husk det højeste, begynd over det næste gang — og den holdt kun, hvis uids var,
hvad serveren sagde. Den brød på et fetch-svar uden uid, på en UIDVALIDITY, der
skiftede under en genopbygget postkasse, og på to postkasse-rækker, der pegede
samme sted og hver især førte deres eget regnskab. Alle tre gav det samme
symptom.

Så regnskabet flytter derhen, hvor spørgsmålet bliver stillet. "Flaget" betyder
"lav det her om til en opgave"; når den er en opgave, har flaget gjort sit
arbejde og er ikke sandt længere. Så er der ingenting at huske, og dermed
ingenting, der kan komme ud af trit — en anden synkronisering, en anden
postkasse-række eller en database gendannet fra en kopi finder alle det samme
svar, fordi svaret ligger i postkassen.

Flaget tages af *efter* opgaven er skrevet. Et flag fjernet fra en mail, hvis
opgave aldrig blev gemt, er en mail, ingen ser igen.

**Gmail havde den samme fejl af en anden grund.** Listen over allerede behandlede
beskeder blev bygget om ved hver kørsel af *kun de id'er, Gmail svarede med den
gang* — for at den ikke skulle vokse uendeligt. Men forespørgslen er begrænset,
så på en konto med mere stjernemarkeret post, end der er plads til i ét svar,
glemte hver kørsel det, den forrige havde husket. De kom tilbage som ubehandlede
og blev opgaver igen. Det var ikke en grænse, det var en måde at glemme på.
Listen har nu et loft på antal i stedet, nyeste først.

Gmail kan ikke få taget stjernen af: scopet er `gmail.readonly`, med vilje, fordi
det er dét, der gør samtykkeskærmen til noget, et menneske kan sige ja til. Derfor
skal regnskabet blive på vores side dér — men det skal så være et regnskab, der
kan holde.

Prøven kører hele vejen mod en rigtig IMAP-server: find de flagede, tag flaget af,
og spørg så forfra med markøren sat til nul — altså det værst tænkelige tilfælde,
hvor alt, vi selv havde husket, er væk. Der kommer ingenting tilbage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:04:40 +02:00
Kristian Wind 987d4edfce 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.
2026-08-20 07:01:14 +02:00
Kristian WindandClaude Opus 5 78891bae6f feat(beacon): tæl installationer, sig præcis hvad der sendes, og lad folk sige nej
Spørgsmålet er ét: hvor mange bruger det her. Svaret er to værdier én gang i
døgnet — et tilfældigt id, installationen selv fandt på, og versionen. Ikke andet.
Beskeden er lille nok til at stå i fuld længde på indstillingssiden, og den gør
det, fordi et løfte om telemetri er præcis så meget værd som læserens mulighed
for at efterprøve det.

**Ikke obligatorisk, og grunden er praktisk frem for principiel:** det kan ikke
håndhæves. Domænet kan blokeres, binæren kan patches, og en fork fjerner den i
første commit — så obligatorisk køber intet over slået-til-som-udgangspunkt og
koster den tillid, resten af programmet arbejder for. Hemmeligheder forsegles
her, nøglen holdes uden for databasen, og sikkerhedskopisiden advarer om, hvad en
kopi indeholder. Telemetri, man ikke må sige nej til, er den ene sætning i den
historie, der ikke passer.

Uenigheden med `config.UpdateCheck`, som er slået fra med den modsatte
begrundelse, står skrevet i pakkens egen dokumentation frem for at blive glattet
ud. Den er reel, og læseren fortjener at se begge halvdele.

Id'et findes på og gemmes ved første brug frem for at blive udledt af værtsnavn
eller adresse. En hash af noget om maskinen ville være stabil, bekvem og
samtidig en værdi, modtageren kunne holde op mod et gæt — og det er forskellen
mellem anonym og pseudonym.

Modtagerens side er den samme binary med en kontakt: `POST /api/v1/beacon` uden
login, fordi den, der ringer, er en fremmed installation. Den svarer 404, når
instansen ikke er tælleren, så en almindelig installation ikke er en spamspand,
der svarer 200 på alt. Ratebegrænset som login-ruterne, og tabellen har et loft:
endepunktet indsætter en række på et id, den, der ringer, har fundet på, og uden
et loft kunne enhver fylde disken.

Tallet er "set inden for tredive dage" og ikke et løbende total. Et total stiger
kun og holder op med at betyde "hvor mange bruger det" og begynder at betyde
"hvor mange har prøvet det".

`internal/safedial` kom til undervejs: modtagerens adresse kan skrives i fladen,
og en adresse, en bruger angiver, må ikke kunne pege indad. Kontrollen ligger på
den opslåede adresse og ikke på URL'en, fordi et navn kan svare 127.0.0.1 — og
svare noget andet anden gang, efter en kontrol ved parsning er gået godt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:59:50 +02:00
Kristian Wind 7828c1e6ed feat(gcal): Google Calendar kan læses, og kalenderen får sine egne tabeller
Klienten henter kalenderlisten og begivenhederne i et vindue, og laver Googles
to former om til den ene, et gitter kan spørge om: dage i begge ender, begge
med. Googles heldags-slutdato er eksklusiv, og et møde, der slutter ved midnat,
slutter aftenen før — begge dele rettes ét sted frem for hos hver læser.

Egne tabeller frem for `mailboxes`. De to ligner hinanden på afstand og er
uenige om hver eneste kolonne bagefter: `trigger_kind`, `seen`, `last_uid` og
`project_id` svarer alle på "hvad laver en besked om til en opgave", og intet
her bliver til en opgave. Én konto rummer desuden mange kalendere, og en
en-til-mange er der ikke plads til. Det afgørende er mindre og mere praktisk:
`mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en CHECK — en tredje
værdi koster en tabelombygning med fremmednøgler slået fra. Derfor er der ingen
CHECK på `provider` her: CalDAV skal koste en række, ikke en ombygning.
2026-08-20 06:50:57 +02:00
Kristian Wind a46d9158e1 refactor(google): OAuth-flowet flytter ud af gmail, så to ting kan dele det
Gmail og Google Calendar logger ind gennem den samme registrering med den
samme PKCE og den samme fornyelse. Det eneste, der adskiller dem, er ét ord:
scopet. En kopi mere ville være et sted mere, hvor `access_type=offline` kan
blive glemt — og det er dén fejl, der får en forbindelse til at dø lydløst en
time efter, den blev lavet.

internal/gmail beholder sit API og sit scope; navnene på flowet er aliaser, så
hverken kaldere eller prøver ændrer sig. Prøverne for PKCE, AuthURL og
Configured flytter med koden.
2026-08-20 06:46:10 +02:00
Kristian WindandClaude Opus 5 4b5f3f0864 chore: agenternes arbejdstræer hører ikke i repoet
Release / Build and publish image (push) Failing after 9m56s
Et `git add -A` fangede .claude/worktrees/ som et indlejret repo. Det er en
agents arbejdskopi og skal ingen steder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:39:45 +02:00
Kristian WindandClaude Opus 5 052f8a43fb fix(notes): en note kunne smugle et script ind gennem et billedes alt-tekst
Indført i aften, sammen med billedvisningen, og fundet ved at læse den igen.

`escapeHtml` tager &, < og > — ikke anførselstegn, og det er rigtigt for tekst
mellem to tags, hvor et anførselstegn ikke betyder noget. `alt` er ikke tekst
mellem to tags. Det står inde i en attribut, og der lukker det første
anførselstegn den, hvorefter resten står som markup:

    !["  onerror="alert(1)](/api/v1/attachments/aaaa)

blev til et <img> med en onerror på. Adressen var i forvejen bundet til vores
eget bilagsmønster, så den var ikke vejen ind — teksten ved siden af var.

Det er ikke en teoretisk vej. En note deles ved at ligge i et projekt, så den
læses af alle, der kan læse projektet, og scriptet ville køre på vores eget
domæne med sessionscookien, hos den der åbnede noten.

En værdi i en attribut har brug for sin egen undslipning, og har nu en. Prøven
skriver noten, åbner den og ser efter, at hændelsen står som tekst i alt og ikke
som en attribut på elementet — og at billedets 404, som er selve vejen en
onerror ville blive kaldt ad, ikke kalder noget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:39:19 +02:00
Kristian WindandClaude Opus 5 dee2d5e30b fix(faerdige): en opgave lukket efter midnat lå under "I går"
Release / Build and publish image (push) Failing after 9m56s
Fundet af prøverne, som for første gang kørte forbi midnat.

Dagen blev læst som `completed_at.slice(0, 10)` — de ti første tegn af et
tidsstempel, der er i UTC. Overskriften sammenlignede med *lokal* dato. I dansk
sommertid er de to ikke den samme dag i de første to timer efter midnat, så en
opgave lukket 00.30 blev sat under "I går", indtil klokken blev to. Tyve timer i
døgnet er de ens, hvilket er derfor prøven har været grøn hver eneste gang før.
Dagen udregnes nu lokalt, som den overskrift den skal svare til.

Og prøven for invitationslinket kunne kun bestå seks dage om ugen. Den ledte
efter "males inden fredag" som én tekst, men hurtig tilføjelse læser "fredag" som
forfaldsdatoen og tager ordet ud af titlen — tilbage står "males inden" plus et
mærke, hvis ord skifter med ugedagen. Kørt på en torsdag hedder mærket "I
morgen", og så fandt den ingenting. Prøven handler om invitationer, ikke om
datoer, så den ser nu på titlen alene.

Uddraget i notelisten viste `![](/api/v1/attachments/01a0…` for hver note med et
billede i — den eneste tekst i listen, ingen havde skrevet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:17:50 +02:00
Kristian WindandClaude Opus 5 2bd8e48aba fix(notes): en note med tre prikker i titlen blev smidt væk ved importen
Release / Build and publish image (push) Failing after 9m54s
`skipFile` afviste ethvert navn, der indeholdt "..", for at stoppe en zip i at
skrive uden for sin egen mappe. Det er den rigtige idé rettet mod det forkerte:
det beskriver ikke en sti, der klatrer ud af arkivet, men ethvert filnavn med to
prikker ved siden af hinanden. En ellipse i en overskrift er almindelig, og noten
"Så blev det endelig jul! Stay tuned... ☕️" blev derfor aldrig oprettet.

Lydløst, oven i købet: filen blev sprunget over, før den blev talt, så svaret
sagde det samme, som hvis den var kommet med. Én note ud af tolv hundrede, og den
blev kun fundet ved at tælle efter, hvor mange der kom ud i den anden ende — 1140
hvor der skulle stå 1141.

Nu læses ".." som et stiled, ikke som to tegn, og en Markdown-fil, filteret
afviser, tælles som sprunget over. En import, der taber noget, skal sige det.

Og move-ikonet i sidebjælken er væk. ⠿ sad i den to pixels brede rende til
venstre for teksten og lå oven i rækkens egen kant — idéen var rigtig, pladsen
var det ikke. Elementet bliver: det er dét, der ejer trækket, og gjorde man i
stedet hele rækken draggable, ville alt indeni også blive det, og en opgave
trukket hen på I dag holdt op med at lande. Nu er håndtaget usynligt og lidt
bredere, og hånden ved hover er hele forklaringen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:04:08 +02:00
Kristian WindandClaude Opus 5 9b4e7686f9 feat(notes): datoerne følger med, billederne bliver tegnet, og listen kan sorteres
Release / Build and publish image (push) Failing after 9m54s
Fire ting, som en rigtig import af tolv hundrede noter fandt på en time.

**Billederne blev vist som ord.** `markdownToHtml` havde ingen regel for
`![](…)` overhovedet, så en note fra Apple Noter viste sin egen billedadresse som
tekst. Filen var hentet, gemt og hæftet på noten; det eneste, der manglede, var
at nogen tegnede den. Kun vores egen adresse bliver til et billede — et
`![](http://…)` fra en indsat tekst ville ellers hente fra en fremmed vært, når
noten åbnes, og en note er det sidste sted, man vil have en sporingspixel. Og
vejen tilbage skulle med: uden `IMG` i inlineToMarkdown ville den første gemning
have taget hvert billede ud af noten igen.

**Titlen blev revet i stykker.** Noter deler en linje op i så mange stumper, den
har brug for — "Aalborg Kommune" står som
`<h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>`, fordi der er en
attributkørsel bag hver stump — og eksporten læste et linjeskift ved hver af dem.
Noten kom ud som fire overskrifter oven på hinanden, altså ødelagt på den mest
synlige måde der findes: i sin egen titel. Samme rod gav `**M****øde**` for fed
og `*M**øde*` for kursiv, hvor de to stjerner i midten læses som fed. Nu brydes
der kun, når blokken skifter, og et mærke, der lukkes og straks åbnes igen, er
ingenting.

**Datoerne fandtes ikke.** Eksporten læste dem aldrig, og Markdown havde ingen
plads til dem — så en flytning af tolv hundrede noter blev en bunke, der alle var
skrevet samme aften. Rækkefølgen er halvdelen af, hvad et arkiv er. Nu står de i
et YAML-hoved, som Obsidian og Bear allerede skriver, og importen læser det.
`SetNoteTimes` frem for gennem SaveNote: `updated_at` betyder "hvornår skrev
*dette* program noten sidst", og lod man enhver kalder sætte den, ville kolonnen
betyde det, den sidste skriver havde lyst til.

**Og listen kan sorteres** — senest rørt, oprettet, navn. Favoritter står øverst
uanset hvad; det er hele meningen med at gøre en note til favorit.

Eksporten henter nu i klumper. Ét kald pr. note tog halvanden time for tolv
hundrede, ikke fordi Noter er langsom, men fordi hvert kald er en proces, der
skal startes — og det gjorde det umuligt at rette en fejl i oversættelsen og se
resultatet, for hver runde var en frokostpause. Antallet af bilag kom med i
klumpen af samme grund: ni ud af ti kald var en proces startet for at få svaret
nul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:58:19 +02:00
Kristian WindandClaude Opus 5 2ffbd9533b fix(notes): billederne i en importeret note pegede på ingenting
Release / Build and publish image (push) Failing after 9m54s
Fundet ved at importere rigtige noter frem for ti prøvenoter.

Linket blev skrevet halvt om. attachToNote kendte to måder, en note kan nævne en
fil på — den bare filnavn og stien i forhold til noten — men ikke den, en Apple
Noter-eksport faktisk skriver: `vedhaeftninger/751-2-IMG_0615.heic`, med noten i
roden ved siden af mappen. Kun det bare navn matchede, så kun det blev erstattet,
og hver eneste billede i hver eneste importerede note endte som
`vedhaeftninger//api/v1/attachments/…`, som er ingenting. Filerne var gemt og
hæftet rigtigt på; det var linket, der var forkert. Længste match først, ellers
bliver mappen stående foran adressen.

Og billederne kunne alligevel ikke vises. Alt blev serveret som
application/octet-stream med Content-Disposition: attachment, hvilket er en god
regel med en god grund — en uploadet SVG eller HTML vist inline ville køre sit
eget script på dette domæne med sessionscookien på. Men en note bærer sine
billeder i teksten som `![](…)`, og under den regel er hvert eneste af dem et
brudt billede. Hele importen findes for at tage dem med.

En kort liste af rasterformater vises nu inline med deres egen type. Det er ikke
en opblødning: listen er navngivet ét format ad gangen frem for matchet på
"image/", fordi præfikset ville lukke image/svg+xml ind — et dokument, der kan
køre script — og en liste, der vokser ved et uheld, er præcis sådan det sker.
nosniff står, så browseren skal læse filen som netop det billede eller slet ikke.

Titlen stod to gange i fjorten noter. Noter kalder noten det, der står på de
første linjer sat sammen med mellemrum, så en note, hvis to første linjer er
"CPU" og "CAN", hedder "CPU CAN" — og sammenligningen med kun den første linje
gik fri. Overskrifterne øverst tages nu med, så længe de tilsammen stadig er
begyndelsen på titlen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:02:20 +02:00
Kristian WindandClaude Opus 5 f6c6bdccbb fix(notes): projektmenuen står ved det # der bliver skrevet
Den var lagt fast øverst i ruden, med den begrundelse at en menu, der følger
markøren, rykker sig et par pixels for hvert bogstav. Det er rigtigt, og det var
alligevel forkert: i en note på en skærmfuld skriver man `#` nederst og får en
menu i toppen — fire hundrede pixels fra det, øjet ser på, og langt nok væk til
at man ikke opdager, at den er der.

Begge dele kan lade sig gøre. Positionen måles, når menuen åbner, og ikke igen
mens listen snævres ind: den står ved tegnet, og den står stille.

Målt på `#term` frem for på markøren, fordi et sammenfaldet område ikke har nogen
kasse på en tom linje. Klemt inden for ruden, og vendt op over linjen, når der
ikke er plads under — ellers ville en note, der er skrevet helt ned i bunden,
lægge menuen uden for skærmen.

Og pak-noter-i-portioner.py, fordi en Apple Noter-eksport er hundrede og
enoghalvfems megabyte, hvoraf teksten er fire: importen tager 64 MB ad gangen, og
tunnelen foran den tager mindre. Delt efter hvad en note vejer med sine billeder,
og altid med billederne i samme portion som den note, der peger på dem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:44:03 +02:00
Kristian WindandClaude Opus 5 9e687185fb feat(notes): vis noten som kode, og giv teksten en margen at ånde i
Release / Build and publish image (push) Failing after 9m59s
Kildevisningen, fordi den rige flade ikke kan tage imod Markdown. Indsat i den
er "## Overskrift" fem tegn og ikke en overskrift, og der var ingen vej ind for
tekst skrevet et andet sted — hvilket er en mærkelig mangel i et program, hvis
hele lager er Markdown. Knappen </> viser filen, som den ligger i databasen, og
det, man skriver der, går tilbage gennem den samme oversætter som altid.

Fyldt fra kilden og ikke fra note.body, når man går tilbage: gemningen venter på
sin pause, så den vej ville kaste det nyeste væk. Og kildevisningen hører til den
note, den blev slået til på — skifter man note, er det den rige flade, man møder.

Margenerne. Teksten lå helt ude ved kanten, og på en bred skærm blev linjerne
hundrede og fyrre tegn — dér mister øjet hvilken linje det var på, når det
springer tilbage. Arket har nu marginer, der vokser med ruden, og teksten en
grænse på 46rem. Kun teksten: værktøjslinjen og foden hører til ruden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:28:16 +02:00
Kristian WindandClaude Opus 5 8229800767 fix(notes): lister overlevede ikke en gemning, og tallene blev klippet af
Seks ting fundet ved at bruge appen, i den rækkefølge de gjorde ondt.

En liste inde i en blok blev læst som tekst. htmlToMarkdown havde én gren for
ul og ol og lod alt andet falde ned i inlineToMarkdown, som samler efterkommere
til én streng — så et blokcitat med en liste i kom tilbage som "> PunktPunkt 2",
og en note fra Apple Noter, der er fuld af citater, mistede hvert punktmærke ved
den første gemning. Ingen havde rørt de linjer. Nu læses en blok, der rummer
blokke, igennem frem for flad, og et citat sætter "> " foran det, der kommer
indefra.

Indlejrede lister fandtes ikke. Den flade `list`-variabel kunne kun beskrive ét
niveau, så et indrykket punkt blev lagt ved siden af sit ophav. Nu en stak, og
underlisten lægges inde i sit <li> — det er den forskel, der kan læses tilbage.

En liste, der begyndte ved ti, begyndte ved ét bagefter. `start` skrives nu med
og læses med, så en gemning uden ændringer ikke gør noten om.

Tallet blev klippet af. Mærket sættes uden for tekstkanten, og 16px rakte til et
punkttegn og ikke til "10." — resten hang ud over kanten, hvor .page beskærer.
Renden er nu 2.2em og deles af begge slags, så punkter og tal flugter.

# viste seks vilkårlige projekter ud af tyve og så færdig ud. Loftet er væk,
listen kan rulle, og det, der begynder med det tastede, står øverst.

Og to ting om, hvordan det ser ud. Noteruden har fået sin egen flade, fordi et
ark, der holder op et sted, skiller det, man skriver, fra det program, man
skriver det i — `--surface` frem for `#fff`, så de fem temaer hver får deres.
Noter stod for sig selv over en streg, hvilket sagde det rigtige og kostede
halvfjerds pixels; den står nu i rækken med de faste visninger og bærer et ark i
stedet for en prik, hvilket siger det samme gratis.

Prøven skriver de fem listeformer, gemmer gennem den rigtige editor og læser
filen bagefter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:24:43 +02:00
Kristian WindandClaude Opus 5 a4b12f3601 feat(notes): listen som Apple Noters, ⌘K finder noter, og de kan trækkes
Release / Build and publish image (push) Failing after 9m52s
Rækken har nu titel i samme størrelse som en opgavetitel — en note og en opgave er
to ting af samme slags, og hver sin størrelse får den ene til at se mindre vigtig
ud. Under den står dato og begyndelse på samme linje, som Apple Noter gør det: to
linjer pr. note frem for tre, og datoen først, fordi det er den, man skanner efter.
Ligger noten i et projekt, står det nedenunder.

⌘K søgte kun i opgaver og projekter. Feltet ser ud til at kunne finde alt, så det
er svært at gætte, at halvdelen af programmet ikke var med. Og mens jeg var der:
"Projekt" og "Opgave" stod hårdkodet på dansk i paletten.

En note kan trækkes ind på et projekt i sidebjælken — samme mål som en opgave,
fordi det er samme gestus: læg denne her.

To fejl i søgefeltet, begge Safaris egen type="search":
Fokusringen tvang sin egen hjørneradius og tegnede en firkantet bue uden om en
rund kant — to streger, hvor der skulle være én. Den følger nu elementets form,
overalt i programmet. Og feltet skiftede udseende ved fokus: forstørrelsesglasset
forsvandt og pilleformen blev firkantet. Det tegnes nu af os.

Trækket og palettens noter er ikke fastholdt i en prøve. ⌘K, tastning og en
debounce oven på hinanden gjorde den upålidelig, og en flakkende prøve siger
mindre end ingen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:46:05 +02:00
Kristian WindandClaude Opus 5 43ad60a84a feat(notes): formateringen overlever, og monospace bliver til en terminal
Konverteringen læser nu Apple Noters HTML frem for at flade den ud med textutil.
Overskrifter, fed, kursiv, understreget, lister og monospace kommer med. En note er
ikke kun sine ord — et referat med overskrifter er ikke det samme referat som en
klump — og ti prøvenoter viste hvad det kostede.

Skrevet om til Python, fordi HTML skal læses og ikke gættes med regulære udtryk.

Den alvorligste fejl, prøvekørslen fandt: navn og krop blev hentet i hvert sit
kald, og Noter sorterer listen efter ændringsdato. Flyttede noget sig imellem de
to kald, fik en note én titel og en andens indhold. På ti noter er det synligt; på
tolv hundrede ville det være ubemærket rod. Der hentes nu ved id, og navn og krop
kommer i samme kald.

Sammenhængende monospace-linjer bliver til én blok. Hver linje i Noter er pakket
ind i sin egen div, hvis starttag tømmer den forrige — så mellem to tt-linjer kom
der altid en tom flush forbi, og hver linje i en terminaludskrift blev sin egen
blok.

Og i editoren ser en kodeblok ud som en terminal: mørk flade, monospace, sproget i
hjørnet, farver på strenge, tal, kommentarer, nøgleord og prompten. Monospace ER
kode i praksis, og en mørk flade siger det på afstand.

Farverne lægges på de blokke, markøren IKKE står i. At farve mens nogen skriver
betyder at bygge indholdet om ved hvert tastetryk og sætte markøren tilbage
bagefter — det virker ni gange ud af ti og flytter markøren midt i en sætning den
tiende.

Et API-token er ét ord, der er bredere end skærmen. Uden min-width: 0 skubbede en
sådan note hele listen bredere og klemte editoren sammen — på en note, man ikke
engang havde åben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:31:10 +02:00
Kristian WindandClaude Opus 5 08fc355bf9 fix(tools): tre fejl, en prøvekørsel på ti noter fandt med det samme
Kodningen. textutil gætter på inddataets kodning og gætter forkert, så
"Ansøgning" kom ud som "Ansøgning". Det ser ud som én underlig note, indtil man
opdager, at det er hver eneste note med et dansk bogstav i.

Titlen stod to gange. Kroppen af en Apple-note begynder med notens egen titel, så
en overskrift ovenover gav den dobbelt overalt.

Og listerne var ikke lister. Apple skriver dem som tabulator, tegn, tabulator —
en liste for et menneske og ingenting for en Markdown-læser, så en huskeliste
ankom som en klump linjer. Punkttegnet er flerbyte, og BSD sed hakkede det midt
over, så hver linje begyndte med to halve tegn; perl med -CSD gør det rigtigt.

Scriptet tager nu et antal som andet argument, så en prøvekørsel er til at
gentage. Det er billigere at opdage den slags på ti noter end på tolv hundrede.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:37:52 +02:00
Kristian WindandClaude Opus 5 414a35eaba feat(notes): billeder og bilag følger med ind — og ud
Release / Build and publish image (push) Failing after 9m58s
Du spurgte, hvad der sker med bilag og indsatte billeder. Svaret var: de forsvandt
stille. Scriptet konverterede til ren tekst, og en note fra Apple Noter, der kommer
over som ord alene, er ikke den note — den er et referat af den.

En note kan nu bære filer, i samme tabel som opgaver og grupper allerede bruger.
Filerne er indholdsadresserede og deduplikeres på disken, upload-vejen er skrevet
og afprøvet, og oprydningen af forældreløse filer går allerede den tabel igennem.
En tabel mere ville være et alt-sammen mere.

Importen læser arkivet i to omgange. Alt, der ikke er Markdown, gemmes først, så
en note, der bliver læst, allerede har adresser at få sine links rettet til —
noter og billeder ligger i den rækkefølge, arkivet nu har dem, og ét gennemløb
ville efterlade halvdelen af henvisningerne pegende på ingenting.

Kun de filer, noten faktisk nævner. Et arkiv er én mappe til mange noter, og at
hænge hvert billede på hver note ville gøre hundrede noter til ti tusind bilag.

Hentningen af en note-fil gav 404, fordi adgangskontrollen ikke kendte noter som
forælder. Et billede midt i den tekst, der navngav det, svarede altså "findes
ikke". Den følger nu noten, som følger sit projekt — samme kæde som alt andet.

AppleScriptet gemmer nu hver vedhæftning og skriver en henvisning til den i noten.
Vedhæftninger uden fil bag sig — links til websider, kort — springes over frem for
at vælte kørslen.

Migration 0021.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:12:54 +02:00
Kristian WindandClaude Opus 5 11fdbefe1b feat(notes): hver note som en Markdown-fil, i et zip
Det er løftet, hele designet blev lagt til rette omkring: noten på disken ER den
fil, man ville eksportere. Der konverteres ingenting på vejen ud, så der er
ingenting, konverteringen kan tage fejl af — filerne åbner i Obsidian, i en
teksteditor, i hvad som helst.

To ting, en eksport kan tabe stille, og som prøven holder fast:

Et skråstreg i en titel ville lave en mappe på Mac og Linux og blive afvist på
Windows. De tegn, ingen af de tre vil have, bliver til bindestreger.

Og to noter med samme titel er to noter — et arkiv med den samme post to gange er
et arkiv, der har mistet den ene. Den anden får et nummer.

Fejler skrivningen midtvejs, stopper den frem for at rydde op. Svaret er allerede
på vej ud, så der er ingen statuskode tilbage at sende: et afkortet zip siger
selv, at det er afkortet, hvilket er bedre end en komplet fil med en note færre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:03:25 +02:00
Kristian WindandClaude Opus 5 6e83291444 feat(notes): opgaven viser de noter, der nævner den — og har fået en adresse
Den anden vej af samme forbindelse. En note, der nævner en opgave, viste den; nu
viser opgaven noten. Uden det virker forbindelsen kun, hvis man tilfældigvis
sidder og læser noten, og det er den halvdel, ingen har brug for hjælp til.

Undervejs viste det sig, at en opgave slet ikke havde en adresse. Skuffen åbner
over den liste, man står på, og ændrer ingenting i URL'en — hvilket er rigtigt for
et klik og ubrugeligt for et link: man kan ikke sende nogen en opgave, og en note,
der nævner en, har ingen steder at sende dem hen. Min egen henvisningsform pegede
altså på noget, der ikke fandtes.

/opgave/{id} findes nu. Den åbner skuffen og træder til side — erstatter sig selv
med opgavens eget projekt, så man lander hvor opgaven bor, med den åben, og
tilbage-knappen ikke går gennem en side, der kun nogensinde viderestillede.

Prøven henter opgavens id fra API'et frem for fra adresselinjen, netop fordi den
ikke står der.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:59:34 +02:00
Kristian WindandClaude Opus 5 f6c60bdaf9 fix(notes): #garageristeriet og #GarageRisteriet er samme projekt
Release / Build and publish image (push) Failing after 9m58s
De var det ikke. Nøglen blev gemt præcis som skrevet og slået op på samme måde,
så en note, der nævnte et projekt med lille begyndelsesbogstav, dukkede simpelthen
ikke op på det — stille, og uden noget der antydede hvorfor.

Foldet på vej ind og foldet i opslaget, så sammenligningen bliver ved med at være
en nøjagtig lighed og bruger sit indeks. Havde man i stedet sænket kolonnen i
forespørgslen, skulle hele tabellen læses.

Kun projekter. En [[notetitel]] er en titel, ikke en nøgle: to noter, der hedder
"Møde" og "møde", er to noter, og at folde dem sammen ville slå noget sammen,
nogen havde holdt adskilt. Der er en prøve for begge dele.

Migrationen samler de rækker, der allerede er skrevet. Primærnøglen er
(note_id, kind, target_id), så en note, der skrev begge stavemåder, ville kollidere
med sig selv — derfor bygges sættet forfra med INSERT OR IGNORE frem for en
opdatering, der ville fejle på parret.

Fladen viser projektets egen stavemåde og ikke den foldede nøgle. En etiket, ingen
har skrevet, og som står hverken i noten eller på projektet, ville være værre end
ingen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:48:00 +02:00
Kristian WindandClaude Opus 5 5e670119db feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik
nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som
"fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser
tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af
teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra
både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte,
den ikke kan holde.

Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren
står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det
under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens
den er åben, som enhver anden fuldførelse.

Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B,
⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i
programmet, hvor det, man skriver, bliver læst for betydning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:36:31 +02:00
Kristian WindandClaude Opus 5 3223989a9d feat(notes): første linje er titlen, resten er brødtekst
Release / Build and publish image (push) Failing after 9m57s
En ny note åbner på sin titel, og linjen efter er brødtekst — uden at man vælger
noget. Det er det, Apple Notes gør, og det er også hvad listen har brug for:
første linje bliver notens navn, så en note, der begynder i brødtekst, hedder sin
egen første sætning.

To ting undervejs. Reserven for en tom note blev aldrig brugt, fordi en tom tekst
ikke gengives som ingenting men som ét tomt afsnit — den prøves nu på teksten og
ikke på resultatet. Og Chromium svarer på retur med et bart <div>, som læses som
brødtekst men ikke er formateret som det: afstanden mellem afsnit forsvandt
stille i det øjeblik, man trykkede retur frem for at vælge Brødtekst i menuen.

Listen viste desuden den rå Markdown i forhåndsvisningen — "# Møde med Anders" og
"**fed**" — hvilket er præcis det, editoren blev bygget for at holde op med at
vise. Den viser nu sætningen frem for notationen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:23:42 +02:00
Kristian WindandClaude Opus 5 f557d319ca feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have
dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om
skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken
rækkefølge man vil have dem i, er en kendsgerning om personen.

En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og
manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at
strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige
forlod menuen.

To ting, prøverne fandt undervejs, og begge var mine:

Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede
modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi
rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det.
Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet
var en menuflytning.

Migration 0019.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:19:46 +02:00
Kristian WindandClaude Opus 5 90bdf4b725 feat(look): en akse mere — hvordan det læses, ikke hvor lyst det er
Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft),
Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud
til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver.

To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal
ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at
vælge en skrift må ikke trække temaet med sig.

Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op
i én skrift og derefter i en anden, og det er et synligt spring.

Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst
genkendelig — det var det, der blev bedt om — men et program med ét muligt
udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en
indstilling blandt flere frem for det eneste, det kan være.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:07:39 +02:00
Kristian WindandClaude Opus 5 1bbae698d5 feat(notes): en note deles ved at ligge i et projekt
Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og
invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i
forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert,
og de to ville være uenige den dag, den ene blev ændret.

To ting, prøven fandt, som begge var rigtige fejl.

Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så
en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags.

Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i
det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser
nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:03:28 +02:00
Kristian WindandClaude Opus 5 1b02eecdab feat(notes): rich text, med Apple Notes' egne stilarter
Man skriver nu i formateret tekst frem for i Markdown-kildekode. Titel,
Overskrift, Underrubrik, Brødtekst, Monotype, Blokcitat, punkt- og talliste, plus
fed, kursiv, understreget og gennemstreget — samme ordforråd som Apple Notes, for
det er det, en, der kommer derfra, allerede kan.

Filen under er stadig Markdown. Det er ikke et kompromis, nogen skal leve med i
stilhed: det er grunden til, at eksporten er til at stole på — filen, en note
bliver til, er den fil, den allerede var. Understregning er den ene undtagelse;
den findes ikke i noget Markdown-dialekt og gemmes som <u>, som enhver læser
sender uændret igennem. Det er bedre end at fjerne en stilart, kilden har.

contenteditable med execCommand. Den er forældet og også det eneste, alle browsere
er enige om; at skrive en markeringsmodel fra bunden er ugers arbejde og en ny
slags fejl netop dér, hvor tabt tekst er utilgiveligt.

Tre ting, prøverne fandt, som alle ville have kostet tekst:
Værktøjslinjen stjal markeringen, når man klikkede — knapperne tager ikke fokus
længere. Browseren skrev <span style="font-weight: normal"> for en ophævet fed,
hvilket en oversætter, der læser tags, taber; der bedes nu om tags, og styles
læses alligevel, fordi det ikke honoreres alle steder. Og markeringen med
Shift+Home tog hele dokumentet, ikke linjen.

Favoritter er stjernen på hver note. Noteoversigten har fået luft mod venstre
kant, og Gem og Slet er knapper frem for bare ord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:58:53 +02:00
Kristian WindandClaude Opus 5 5757f06e7a feat(notes): man kan se hvad man skriver
Release / Build and publish image (push) Failing after 10m0s
Formatering vises, mens man skriver. Overskrifter, fed, kursiv, kode, citater,
punkter, #tags og [[links]].

Et spejl bag et gennemsigtigt tekstfelt — samme greb som hurtig tilføjelse bruger
til at vise, hvad parseren forstod. En contenteditable ville lade teksten style
direkte og ville også ødelægge fortryd, autokorrektur og IME. Det er en dårlig
byttehandel i det ene sted i programmet, hvor nogen skriver en halv time i træk.

Mærkerne bliver stående, dæmpede. Obsidian skjuler dem uden for den linje,
markøren står i; at dæmpe dem altid er en mindre idé, der ikke flytter tekst under
markøren mens den bevæger sig — og den overlever mødet med et tekstfelt.

Overskrifter skifter vægt og farve, aldrig størrelse: en større skrift ville fylde
flere linjer end tekstfeltet gør, og så skred alt nedenunder.

Hele risikoen ved grebet er, at de to lag skal flugte tegn for tegn. Prøven måler
det frem for at stole på det: skrifttype, linjehøjde, ombrydning, polstring og den
faktiske højde af de to lag med samme tekst i.

Undervejs: kursiv-passet slugte " og " efter et fed-udtryk, fordi den løse stjerne
stadig var synlig for det. Hvert pas læser nu en kopi, hvor de tidligere pas'
tegn er blændet ud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:38:18 +02:00
Kristian WindandClaude Opus 5 5f04fe56bc feat(notes): note-værktøjer i MCP, og sidebjælken ryddet op
Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple
Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på
maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note
ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til.

Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet
forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den
række, der blev skrevet.

Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de
samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af
programmet, og i den række læste de som endnu et filter.

Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af
papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken
visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved
siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse
af hvad den er til.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:33:54 +02:00
113 changed files with 11639 additions and 655 deletions
+3
View File
@@ -66,3 +66,6 @@ node_modules.nosync/
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
.claude/worktrees/
+23 -12
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
</p>
---
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project;
`#project` and `[[another note]]` are links, and a task shows the notes that
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -90,21 +95,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| Comments, attachments, notifications, Web Push | Built |
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
| Notes in and out: a zip of Markdown, both directions | Done, tested |
| Todoist CSV import and export, account export | Done, tested |
| MCP server for Claude | Done, tested |
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
| Update notice for administrators | Done, tested |
| Personal API tokens | Done, tested |
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
**Not finished:** Web Push, the AI providers and the Gmail API calls are
implemented against their specifications and have only ever been exercised there,
not against live services. Everything else has an interface: opening a task shows
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
API calls are implemented against their specifications and have only ever been
exercised there, not against live services. Everything else has an interface: opening a task shows
its description, sub-tasks, comments, files and reminders, and the settings pages
cover the account, notifications, integrations, AI, API tokens, import, export and
templates.
@@ -116,17 +124,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
| | |
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| ![Board view](docs/screenshots/board.png) | ![Light theme](docs/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| Five themes, all measured for contrast. | What has been done on the server. |
| ![A week on Upcoming](docs/screenshots/week.png) | ![A task, opened](docs/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
| ![Notes](docs/screenshots/notes.png) | ![The same note as Markdown](docs/screenshots/notes-source.png) |
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
| ![Themes and looks](docs/screenshots/themes.png) | ![Instance history](docs/screenshots/history.png) |
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
still works, and is the way to photograph an instance that is already running.)
the real binary, creates its own account and a second one to share with, fills them
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
remember. (`go run ./tools/shots` still works, and is the way to photograph an
instance that is already running.)
## Running it
+2
View File
@@ -75,7 +75,9 @@ func run() error {
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.SyncMailbox = api.SyncMailbox
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.Start(jobCtx)
srv := &http.Server{
+44 -7
View File
@@ -1,13 +1,14 @@
# Calendar and CalDAV
Two ways to get your tasks into a calendar, and they are not the same thing.
Two ways to get your tasks into a calendar, one way to get a calendar into
verdande, and none of the three is the same thing.
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|---|---|---|
| Direction | Read only | Two-way |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
| You can tick things off | No | Yes |
| Setup | Paste a URL | Username and token |
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|---|---|---|---|
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
| You can tick things off | No | Yes | No — an event is not a task |
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
## Subscribing to a feed
@@ -39,6 +40,42 @@ most people actually keep.
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
immediately.
## Showing a Google calendar in verdande
The two sections above send your tasks *out*. This is the other direction: your
Google calendar drawn underneath them, so one screen answers "what does this day
already have in it".
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
own colour for each one, beside your own tasks with due dates.
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
second redirect URI registered — see [Configuration](configuration.md#google).
!!! warning "Read only"
verdande never writes to your Google calendar. An event cannot be dragged,
edited or ticked off here; clicking one opens it in Google, which is where it
can be changed. Your own tasks in the same grid still drag to another day as
they always have.
Writing would need the `calendar.events` scope and an answer to what should
happen when both sides moved the same meeting — which is a synchronisation
model rather than a scope, and it is deliberately not built yet.
!!! note "The Internal client applies here too"
An OAuth client registered as *Internal* in a Google Workspace can only be
used by accounts in that workspace, calendars included. A personal
`@gmail.com` address is refused with `org_internal`. For one personal account
the shorter road is the calendar's own secret address: in Google Calendar,
**Settings → the calendar → Integrate calendar → Secret address in iCal
format**. Nothing in verdande subscribes to one yet — it is the next piece of
this feature.
Events are kept for ninety days back and a year forward, refreshed every fifteen
minutes and whenever you press **Fetch now**. Paging the grid past that window
says so rather than showing an empty month.
## Connecting a CalDAV client
This is the one where ticking something off in Apple Reminders ticks it off here.
+26 -7
View File
@@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`.
yourself, password resets are written to the log, and reminders arrive in the
app and as Web Push rather than by email.
## Gmail
## Google
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
and add the `gmail.readonly` scope.
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
registration covers both: register an OAuth client in Google Cloud
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
**both** redirect URIs:
```
https://your-address/oauth/gmail/callback
https://your-address/oauth/calendar/callback
```
Then enable the APIs and scopes for whichever you want:
| For | Enable | Scope |
|---|---|---|
| Mailboxes | Gmail API | `gmail.readonly` |
| Calendar | Google Calendar API | `calendar.readonly` |
| Variable | What it does |
|---|---|
@@ -52,8 +64,15 @@ and add the `gmail.readonly` scope.
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
One registration serves every user on the instance; each person authorises their
own mailbox from **Settings → Gmail**. The redirect URI is derived from
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
!!! note "Two connections, two grants"
Connecting a calendar does not disturb a working Gmail connection, and
disconnecting one does not take the other with it. Google issues a refresh
token per authorisation and keeps up to a hundred of them live per account,
each carrying only the scopes it was granted with.
## Secrets
+30
View File
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
shared by filing it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
## What it looks like
![Upcoming as a month](screenshots/upcoming.png)
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
dragged from one day onto another.*
![A project as a list](screenshots/sections.png)
*The same project as a list, where sections read as bands. As a board they are
columns.*
![Waiting on others](screenshots/delegated.png)
*What you have handed to somebody else, grouped by whoever has it.*
![Settings, integrations](screenshots/settings.png)
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
[mail-to-task address](mail.md) are connected.*
![Settings, people](screenshots/admin.png)
*Inviting somebody to the instance. There is no open registration — everybody
arrives through a link, and picks their own password. See
[signing in](signing-in.md).*
## Getting it running
verdande is built to be deployed as a **Rune** in
+1 -1
View File
@@ -16,7 +16,7 @@ administrator who can read yours.
**Settings → Integrations → Gmail → Connect.**
Gmail needs an OAuth client, which is the instance's registration with Google
rather than anybody's account — see [Configuration](configuration.md#gmail).
rather than anybody's account — see [Configuration](configuration.md#google).
Once it is set up, each person connects their own mailbox through it.
Choose what makes a task:
+456 -3
View File
@@ -263,6 +263,36 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/nav-order:
put:
tags: [auth]
summary: The order of the fixed views in the sidebar.
description: |
I dag, Kommende, Venter and the inbox, in whatever order this person wants
them. On the account rather than in the browser: which order you want them
in is a fact about you and should be the same on the phone as on the laptop.
A list of keys rather than a number per view. The set of views changes with
the program, and a key that no longer exists costs nothing — the sidebar
draws what it recognises and appends anything new at the end, which is what
makes adding a view later not a migration. An empty list means the order the
program ships with.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
order:
type: array
maxItems: 20
items: { type: string }
example: ["inbox", "today", "upcoming", "delegated"]
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/me:
get:
tags: [auth]
@@ -1366,6 +1396,153 @@ paths:
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/{noteID}/attachments:
post:
tags: [notes]
summary: Put a file into a note.
description: |
A picture pasted or dropped into a note. Until this existed the import
could bring files in and nothing else could, so the most ordinary act in
a notes app — paste a screenshot — did nothing at all.
Editor rights on the note. The same answer for "no such note" and "not
yours", so an id cannot be probed.
The response carries the attachment's URL; the editor writes that into the
note's text as a Markdown image. The file is never inlined as a data URL —
a photograph as base64 would make the note itself megabytes of text.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"201":
description: Stored, and hung on the note.
"404":
description: No such note, or not yours to write in.
"413":
description: Larger than 25 MB.
/api/v1/beacon:
post:
tags: [auth]
summary: Report that an installation exists. Unauthenticated.
description: |
The receiving end of the beacon. Open on purpose: the caller is a
stranger's installation, which is the whole feature.
Answers 404 unless this instance has been told it is the collector, so an
ordinary installation is not a spam sink that answers 200 to anything.
Rate limited like the login routes, and the table behind it has a ceiling:
the endpoint inserts a row keyed on an id the caller invented, so without
one anybody could grow it until the volume is full.
Nothing about the request is recorded but the two values in the body. No
source address is read or written here.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [instance_id]
properties:
instance_id:
type: string
description: A random id the installation generated for itself.
version:
type: string
responses:
"204":
description: Recorded.
"400":
description: The id or the version is not the expected shape.
"404":
description: This instance is not a collector.
/api/v1/beacon/count:
get:
tags: [auth]
summary: How many installations reported in the last thirty days.
description: |
Unauthenticated and cached for a minute, and only answered when the
collector has been told to publish it.
A window rather than a running total: a total only climbs, so it stops
meaning "how many people use this" and starts meaning "how many ever
tried it".
responses:
"200":
description: The count.
content:
application/json:
schema:
type: object
properties:
installs: { type: integer }
window_days: { type: integer }
"404":
description: Not a collector, or not published.
/api/v1/beacon/settings:
get:
tags: [auth]
summary: What this instance reports, and to whom. Administrators only.
description: |
Includes the exact payload that will be sent, so the settings page can
print it in full rather than describe it.
responses:
"200":
description: The current configuration, and the counts if this is the collector.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
collector_url: { type: string }
instance_id: { type: string }
version: { type: string }
is_collector: { type: boolean }
publish_count: { type: boolean }
last_ping_at: { type: string, format: date-time }
put:
tags: [auth]
summary: Turn the beacon off, or point it somewhere else. Administrators only.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
collector_url:
type: string
description: |
Where reports go. Fetched through a dialer that refuses
loopback and private address space, so this cannot be used to
make the server reach inside its own network.
is_collector: { type: boolean }
publish_count: { type: boolean }
responses:
"200":
description: The configuration as it now stands.
"422":
description: The collector address is not an http or https URL.
/api/v1/panel/restart:
post:
tags: [auth]
@@ -2477,6 +2654,142 @@ paths:
created: { type: integer }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/calendar:
get:
tags: [integrations]
summary: The Google Calendar connection and the calendars in it.
description: |
Read-only. verdande shows a Google calendar beside its own dated tasks and
never writes one back — writing needs the `calendar.events` scope and an
answer to what happens when both sides moved the same meeting, which is a
synchronisation model rather than a scope.
The list of calendars is fetched from Google when it is empty, so an
account that connected during a hiccup corrects itself on the next look
rather than showing an empty panel for ever.
responses:
"200": { $ref: "#/components/responses/Calendar" }
delete:
tags: [integrations]
summary: Disconnect the calendar and forget its tokens.
description: >
The cached events go too. Unlike the tasks a mailbox made, none of this is
the person's own work — it is a copy of somebody else's calendar, and it
means nothing without the account.
responses:
"204": { description: Disconnected. }
/api/v1/calendar/calendars:
put:
tags: [integrations]
summary: Choose which calendars are drawn.
description: >
The whole set in one write rather than one flip at a time, for the reason
the projects' order is written the same way: the question is "these ones",
and a list built out of several requests can land half applied. A calendar
turned off has its cached events removed; they come back if it is turned
on again.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
shown:
type: array
items: { type: string }
responses:
"204": { description: Saved. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/calendar/authorize:
post:
tags: [integrations]
summary: Begin the OAuth flow for a calendar.
description: |
The same registration Gmail signs in through, asking for
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
issues a refresh token per authorisation, so connecting a calendar does not
disturb a working Gmail connection.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
url: { type: string }
"409":
$ref: "#/components/responses/Conflict"
/api/v1/calendar/sync:
post:
tags: [integrations]
summary: Refresh the chosen calendars now.
description: >
Refreshes the list of calendars and replaces the cached window of events
for the ones that are shown. `partial` means the budget ran out before the
last calendar; what was read is kept, and the rest arrive on the next run.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
events: { type: integer }
partial: { type: boolean }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/calendar/events:
get:
tags: [integrations]
summary: The events of the chosen calendars, between two days.
description: |
Both ends inclusive, and an event that merely overlaps the range comes
back — a grid asks which cells an event covers, not where it begins.
`from` and `to` in the answer are the window verdande holds a copy of,
which is not the window that was asked for. A grid paged past it gets no
events, and an empty answer with no explanation is a calendar quietly
claiming a day is clear.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
events:
type: array
items: { $ref: "#/components/schemas/CalendarEvent" }
from: { type: string, format: date }
to: { type: string, format: date }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/export/notes.zip:
get:
tags: [notes]
summary: Every note as a Markdown file, in a zip.
description: >
One file per note, named after its title, containing exactly what is
stored. Nothing is converted on the way out — the note on disk is already
the file you would export — so it opens in Obsidian, in a text editor, or
in anything else that reads Markdown.
responses:
"200":
description: A zip archive.
content:
application/zip:
schema: { type: string, format: binary }
/api/v1/notes:
get:
tags: [notes]
@@ -2520,6 +2833,37 @@ paths:
schema: { $ref: "#/components/schemas/Note" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/import:
post:
tags: [notes]
summary: A zip of Markdown files becomes notes.
description: >
The mirror of the export, and the way in from anywhere else: a folder of
Markdown is what Obsidian, Bear, iA Writer and a shell script all produce.
Filenames are ignored — the title is the first line of the body, here as
everywhere, and taking it from the filename would be a second rule for the
same thing. Directories, macOS resource forks and anything that is not .md
are passed over.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file: { type: string, format: binary }
responses:
"200":
description: Read
content:
application/json:
schema:
type: object
properties:
created: { type: integer }
skipped: { type: integer, description: Files that were not usable as a note. }
"413": { description: The archive is too large. }
/api/v1/notes/{noteID}:
parameters:
- name: noteID
@@ -3073,6 +3417,26 @@ paths:
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/oauth/calendar/callback:
get:
tags: [integrations]
summary: Where Google sends the browser after consent to a calendar.
description: |
A URI of its own rather than a reuse of Gmail's. One callback deciding from
stored state which of the two flows had come back would put the choice of
which token store to write behind a lookup — and when that goes wrong it
goes wrong by overwriting a working Gmail connection with calendar tokens.
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
An `org_internal` error here means the OAuth app is registered as Internal
and the account is outside the organisation.
parameters:
- { name: code, in: query, schema: { type: string } }
- { name: state, in: query, schema: { type: string } }
- { name: error, in: query, schema: { type: string } }
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/mcp:
post:
tags: [integrations]
@@ -3255,6 +3619,40 @@ components:
email: { type: string }
trigger: { type: string, enum: ["", starred, label, both] }
label: { type: string }
Calendar:
description: OK
content:
application/json:
schema:
type: object
properties:
connected: { type: boolean }
account:
type: string
description: >
The address of the connected account, taken from the id of its
primary calendar — which is where Google puts it, so no profile
scope has to be asked for to learn it.
read_only:
type: boolean
description: >
Always true today. Stated rather than assumed: an interface that
quietly looks editable is one somebody will try to edit.
has_client:
type: boolean
description: >
Whether an OAuth client is registered on this instance at all, so
the page can explain rather than offer a button that answers 409.
redirect_uri:
type: string
description: >
What has to be registered with Google, spelled out. Derived from
VERDANDE_BASE_URL and matched exactly — the single most likely
thing to be wrong, and the error Google gives names neither value.
last_sync_at: { type: string, format: date-time }
calendars:
type: array
items: { $ref: "#/components/schemas/Calendar" }
ImportResult:
description: Created
content:
@@ -3272,13 +3670,69 @@ components:
items: { type: string }
schemas:
Calendar:
type: object
description: >
One calendar inside a connected account. Kept whether or not it is shown,
so turning one off and on again gives back the same row.
properties:
id: { type: string }
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
name: { type: string }
colour:
type: string
description: >
Google's own hex value, not one of verdande's ten named colours. Those
are names so the interface can restyle them per theme and they are
verdande's palette; a Google calendar is told apart by the colour the
person already knows it by.
time_zone: { type: string }
primary: { type: boolean }
writable:
type: boolean
description: >
Whether the connected account could write to it. Nothing does — the
scope is read-only — and it is stored so the day something can, the
interface knows which calendars could even be offered.
shown: { type: boolean }
CalendarEvent:
type: object
description: >
One occurrence, in the shape a grid asks about. A recurrence is expanded by
Google into its occurrences, so what arrives here is a day, not a rule.
properties:
id: { type: string }
calendar_id: { type: string }
summary: { type: string }
starts_at:
type: string
description: >
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
event, which has a day rather than a moment — inventing midnight for it
is how an event moves a day when a container moves time zone.
ends_at: { type: string }
start_day: { type: string, format: date }
end_day:
type: string
format: date
description: >
The last day the event covers, inclusive. Google's all-day end date is
exclusive and is corrected on the way in, once, rather than by every
reader.
all_day: { type: boolean }
location: { type: string }
url: { type: string, description: "Opens the event in Google's own interface." }
calendar_name: { type: string }
colour: { type: string }
Note:
type: object
description: A page of Markdown. The body is the stored form and the exported form.
properties:
id: { type: string }
project_id: { type: string }
title: { type: string, description: "Taken from the first line when not given." }
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
body: { type: string, description: Markdown }
pinned: { type: boolean }
created_at: { type: string, format: date-time }
@@ -3296,8 +3750,7 @@ components:
type: object
properties:
project_id: { type: string, description: "Empty files it under no project." }
title: { type: string }
body: { type: string }
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Mailbox:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 180 KiB

+139
View File
@@ -0,0 +1,139 @@
// Package beacon answers one question for the project, and refuses to answer any
// other: how many installations are out there.
//
// It sends two values once a day — an anonymous id the installation made up for
// itself, and the version it is running — and nothing else. No IP is stored by
// the collector, no hostname, no domain, no account, no usage. The payload is
// small enough to be printed in full on the settings page, and it is, because a
// promise about telemetry is worth exactly as much as the reader's ability to
// check it.
//
// # On by default, and off in one click
//
// The alternative that was asked for first was mandatory, and the argument
// against it is practical rather than principled: it cannot be enforced. The
// domain can be blocked, the binary can be patched, and a fork removes it in its
// first commit — so mandatory buys nothing over on-by-default, while costing the
// trust the rest of this program works for. Secrets are sealed here, the key is
// kept out of the database, and the backup page warns you what a copy contains.
// Telemetry you are not allowed to refuse is the one sentence in that story that
// does not fit.
//
// So: on by default, said plainly, one switch to turn it off. That collects
// essentially the same number and leaves nobody feeling tricked.
//
// # Where this disagrees with the rest of the program
//
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
// "a self-hosted app that reaches out without being told to has broken the deal
// its operator made by self-hosting." That is the same situation as this one, and
// the two defaults do not agree.
//
// The disagreement is written down here rather than smoothed over, because it is
// a real one and the reader deserves to see both halves. The case for treating
// them differently is that the update check reveals an instance to a third party
// (GitHub) and returns something the operator can act on, while this returns
// nothing and goes to the project itself — and that a count nobody sends is a
// count that does not exist, whereas an update nobody checks for is one you can
// still go and look up. Whether that is a distinction or an excuse is a judgement
// call, and it was made deliberately by the person whose instance this is.
//
// If it is ever decided the other way, the change is one word: the default in
// `beaconSettings`. Nothing else in this package assumes which way it points.
//
// # What the collector can still see
//
// A request has a source address whether or not anybody writes it down. The
// collector does not store or log it, and the code is arranged so that it never
// reaches a function that could — but it passes through a reverse proxy and a
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
// be a half-truth; what is true is that nothing here records one.
package beacon
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
// DefaultCollector is where a fresh installation reports, unless it is told
// otherwise. Somebody running their own fleet points it at their own instance;
// the field is on the settings page for exactly that.
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
// Payload is the entire message. Two fields, and adding a third is a change to
// what this program promises, not a change to a struct.
type Payload struct {
InstanceID string `json:"instance_id"`
Version string `json:"version"`
}
// InstanceID and Version are checked on the way in as well as written on the way
// out, because the collector's endpoint is unauthenticated: anything that reaches
// it is a stranger's bytes, and it should be able to store only the two shapes it
// expects.
var (
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
)
// ValidID reports whether an id is the shape this program writes.
func ValidID(s string) bool { return idShape.MatchString(s) }
// ValidVersion allows a version number and the word this binary uses when it was
// built outside a release.
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
// queue, and a failure that is returned rather than made anybody's problem.
//
// No retry, because the next pass is tomorrow and a missed day changes a count of
// installations by nothing. A telemetry ping that retries is a telemetry ping
// that can hammer somebody's server on the day it is down.
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
if !ValidID(p.InstanceID) {
return fmt.Errorf("beacon: refusing to send a malformed id")
}
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
// en fremmed collector skal filtrere fra.
if !ValidVersion(p.Version) {
return fmt.Errorf("beacon: refusing to send a malformed version")
}
body, err := json.Marshal(p)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// A plain user agent with the version already in the payload. Nothing is
// learned from it that the body does not already say, and it makes the request
// legible in a log rather than anonymous-looking.
req.Header.Set("User-Agent", "verdande-beacon/1")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Read and discard, so the connection can be reused rather than dropped.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode >= 400 {
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package beacon
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// Hvad der bliver sendt, tegn for tegn.
//
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
func TestSendsExactlyTwoFields(t *testing.T) {
var got map[string]any
var contentType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("kroppen er ikke JSON: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
Version: "v0.26.3",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
if contentType != "application/json" {
t.Errorf("Content-Type = %q", contentType)
}
if len(got) != 2 {
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
}
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
t.Errorf("instance_id = %v", got["instance_id"])
}
if got["version"] != "v0.26.3" {
t.Errorf("version = %v", got["version"])
}
}
// En collector, der er nede, er ikke denne installations problem.
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
})
if err == nil {
t.Fatal("en 500 skal give en fejl")
}
if calls != 1 {
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
}
}
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
// noget, en fremmed collector skal filtrere fra.
func TestSendRefusesAMalformedID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("der blev sendt noget, som ikke skulle sendes")
}))
defer srv.Close()
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
}); err == nil {
t.Fatal("et misformet id skal afvises før det forlader maskinen")
}
}
func TestShapes(t *testing.T) {
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
for _, s := range ok {
if !ValidID(s) {
t.Errorf("ValidID(%q) = false", s)
}
}
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
"<script>alert(1)</script>"}
for _, s := range bad {
if ValidID(s) {
t.Errorf("ValidID(%q) = true", s)
}
}
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
if !ValidVersion(s) {
t.Errorf("ValidVersion(%q) = false", s)
}
}
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
if ValidVersion(s) {
t.Errorf("ValidVersion(%q) = true", s)
}
}
}
+31 -12
View File
@@ -59,10 +59,19 @@ type Config struct {
// without spending the real one on every run.
GmailSyncBudget time.Duration
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
// in production, where it means Google.
GmailTokenURL string
GmailAPIURL string
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
// the same reason: a slow Google held the request open past what Cloudflare is
// willing to wait, and the browser got Cloudflare's HTML instead of an error
// this server could explain.
CalendarSyncBudget time.Duration
// Where Google is. Empty in production; a test points them at a server it
// controls, which is the only way to exercise a Google that is slow or refuses.
// The token endpoint is shared — Gmail and Calendar sign in through the same
// registration — and the two APIs are not.
GoogleTokenURL string
GmailAPIURL string
CalendarAPIURL string
PanelURL string
PanelToken string
@@ -98,14 +107,15 @@ func Load() (*Config, error) {
TrashRetention: 30 * 24 * time.Hour,
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
CalendarSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
var err error
@@ -152,6 +162,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
// configured separately and left to drift out of step with it.
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
//
// One callback that decided from server-side state which of the two flows had come
// back would save the operator one line in Google Cloud, and would put the choice
// of *which token store to write* behind a lookup. When that goes wrong it goes
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
// nobody would think to look for. Two paths cannot do that.
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
+339
View File
@@ -0,0 +1,339 @@
// Package gcal reads a Google Calendar. It never writes one.
//
// The scope is calendar.readonly, and that is the whole of the decision: verdande
// already is a CalDAV server, so a Google event shown here is a second calendar
// laid over the first, not a copy Google and verdande now both have to agree
// about. Writing needs `calendar.events`, and it needs an answer to what happens
// when both sides moved the same meeting — which is a synchronisation model, not a
// scope. Read first; write later and to one calendar at a time.
//
// What the API gives back and what this returns are deliberately different shapes.
// Google answers with RFC 3339 instants for a timed event and bare dates for an
// all-day one, and with an *exclusive* end date for the second. A calendar grid
// asks one question — "which cells does this event cover" — so that is what an
// Event answers, in days, worked out once here rather than in every caller.
package gcal
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
// Scope is read-only, and only the calendar.
//
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
// which together are strictly less than this — they leave out access rules and
// account settings, which verdande has no use for. They are the tighter pair and
// worth moving to; they are also newer, and this one has been documented and
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
// is not a place to be adventurous on somebody else's Google account.
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
// The OAuth flow, which is Google's rather than the calendar's — the same one
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
// one package for one feature.
type (
Config = google.Config
PKCE = google.PKCE
Token = google.Token
)
var NewPKCE = google.NewPKCE
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
var ErrUnauthorized = google.ErrUnauthorized
type Client struct {
accessToken string
// base is where the Calendar API is. A field rather than a constant so a test
// can point it at a server it controls; empty is Google. There is no other way
// to exercise a calendar that is slow, empty, or refuses.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Calendar is one entry in the person's calendar list.
type Calendar struct {
ID string
// Name is the summary Google holds. A calendar somebody renamed for themselves
// carries summaryOverride instead, and that is the name they will look for.
Name string
// Colour is Google's own hex value for the calendar, which is what makes two
// calendars in one grid tellable apart. Their own colour rather than one picked
// here: it is the colour they already know the calendar by.
Colour string
TimeZone string
Primary bool
// Owner means the connected account can write to it. Nothing writes today —
// it is stored so the day something does, the interface knows which calendars
// could even be offered.
Owner bool
}
// Calendars lists what the connected account can see.
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
var out []Calendar
page := ""
// Bounded rather than "until Google stops": a page token that never empties is
// a loop that never ends, and the difference between a bug at Google and a
// hung sync should not be this server hanging.
for range 10 {
v := url.Values{}
v.Set("minAccessRole", "reader")
v.Set("maxResults", "250")
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
SummaryOverride string `json:"summaryOverride"`
BackgroundColor string `json:"backgroundColor"`
TimeZone string `json:"timeZone"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
Deleted bool `json:"deleted"`
} `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
if item.Deleted {
continue
}
name := item.SummaryOverride
if name == "" {
name = item.Summary
}
out = append(out, Calendar{
ID: item.ID, Name: name, Colour: item.BackgroundColor,
TimeZone: item.TimeZone, Primary: item.Primary,
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
})
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
// Event is one occurrence, in the shape a calendar grid asks about.
type Event struct {
ID string
Summary string
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
// one — an all-day event has no instant, only a day, and inventing midnight for
// it is how an event moves a day every time a server changes time zone.
StartsAt string
EndsAt string
// StartDay and EndDay are the first and last day the event covers, inclusive at
// both ends. Google's all-day end date is exclusive; that is corrected here,
// once, rather than in every reader.
StartDay string
EndDay string
AllDay bool
Location string
// URL opens the event in Google's own interface. Same reasoning as the Gmail
// link on a task made from a mail: what verdande shows is a pointer to the
// event, not a second copy of it that somebody could try to edit.
URL string
}
// Events lists what falls inside a window, one calendar at a time.
//
// `singleEvents` expands a recurrence into its occurrences, which is the only form
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
// it produced. It also means verdande does not have to grow a second recurrence
// engine that agrees with Google's about Danish summer time.
//
// from and to are dates, inclusive. The window is the caller's business; this
// turns it into the half-open interval Google wants.
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
start, err := time.Parse(dateLayout, from)
if err != nil {
return nil, fmt.Errorf("gcal: from: %w", err)
}
end, err := time.Parse(dateLayout, to)
if err != nil {
return nil, fmt.Errorf("gcal: to: %w", err)
}
var out []Event
page := ""
for range 20 {
v := url.Values{}
v.Set("singleEvents", "true")
v.Set("orderBy", "startTime")
v.Set("maxResults", "2500")
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
// 22:30 UTC the day *before* — so a window cut exactly at the requested
// dates loses the events at both edges. The extra day is thrown away again
// by the day fields below, which are computed from the event's own offset.
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []rawEvent `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
if err := c.get(ctx, url, &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
// A cancelled occurrence of a recurring event still comes back, so that
// a client keeping its own copy knows to drop it. verdande replaces the
// window wholesale, so it only has to not add it.
if item.Status == "cancelled" {
continue
}
event, ok := item.event()
if !ok {
continue
}
if event.EndDay < from || event.StartDay > to {
continue // the slack above, thrown away
}
out = append(out, event)
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
const dateLayout = "2006-01-02"
type rawEvent struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Location string `json:"location"`
HTMLLink string `json:"htmlLink"`
Start when `json:"start"`
End when `json:"end"`
}
type when struct {
Date string `json:"date"`
DateTime string `json:"dateTime"`
}
// event turns Google's two shapes into the one a grid can read.
//
// The second return is false for an event with neither a date nor a dateTime —
// which Google does return, for an event somebody is still being asked about. It
// belongs on no day, so there is no cell to draw it in.
func (r rawEvent) event() (Event, bool) {
e := Event{
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
Location: r.Location, URL: r.HTMLLink,
}
if e.Summary == "" {
// Google's own interface says this for an event with no title, and a blank
// chip in a grid reads as a rendering fault rather than as an empty title.
e.Summary = "(uden titel)"
}
switch {
case r.Start.Date != "":
e.AllDay = true
e.StartDay = r.Start.Date
// Google's all-day end is the day *after* the last one, so a one-day event
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
// comparison rather than a special case at every call site.
e.EndDay = r.Start.Date
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
e.EndDay = last.Format(dateLayout)
}
}
case r.Start.DateTime != "":
e.StartsAt = r.Start.DateTime
e.EndsAt = r.End.DateTime
// The day is the date part of the timestamp Google returned, which carries
// the event's own offset. Parsing it into a time.Time and formatting it
// back would ask *this process* what day it is — and a container running in
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
e.StartDay = dayOf(r.Start.DateTime)
e.EndDay = dayOf(r.End.DateTime)
if e.EndDay == "" {
e.EndDay = e.StartDay
}
// An event ending at midnight ends the evening before, as far as a grid is
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
// the following day as well.
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
}
}
default:
return Event{}, false
}
if e.EndDay < e.StartDay {
e.EndDay = e.StartDay
}
return e, true
}
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
func dayOf(stamp string) string {
if len(stamp) < len(dateLayout) {
return ""
}
day := stamp[:len(dateLayout)]
if _, err := time.Parse(dateLayout, day); err != nil {
return ""
}
return day
}
func mustDay(s string) time.Time {
day, _ := time.Parse(dateLayout, s)
return day
}
func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
+255
View File
@@ -0,0 +1,255 @@
package gcal
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeGoogle answers the two calls this package makes, out of whatever the test
// hands it. There is no other way to exercise this: the real Calendar needs an
// account, a registered client and somebody's consent, and none of the three can be
// had inside a test — so the shapes Google returns are written down here and the
// parsing is checked against them.
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
_ = json.NewEncoder(w).Encode(calendarList)
case strings.HasPrefix(r.URL.Path, "/calendars/"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
body, ok := events[id]
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(body)
default:
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return NewClient("token").At(srv.URL)
}
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
client := fakeGoogle(t, map[string]any{
"items": []map[string]any{
{"id": "primary", "summary": "kw@nolimit.dk", "backgroundColor": "#3f51b5",
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
"backgroundColor": "#0b8043", "accessRole": "reader"},
// A calendar removed from the list still arrives, flagged.
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
},
}, nil)
got, err := client.Calendars(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
}
if got[1].Name != "Familien" {
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
}
if !got[0].Primary || !got[0].Owner {
t.Errorf("the primary calendar came back as %+v", got[0])
}
if got[1].Owner {
t.Error("a reader was reported as an owner; nothing may offer to write to it")
}
if got[0].Colour != "#3f51b5" {
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
}
}
// The three shapes an event arrives in, and the one shape a grid can read.
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"primary": map[string]any{
"items": []map[string]any{
{
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
},
{
// All-day, one day. Google's end is the day after.
"id": "allday", "summary": "Fridag",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"},
},
{
// All-day, three days.
"id": "trip", "summary": "Aarhus",
"start": map[string]string{"date": "2026-08-24"},
"end": map[string]string{"date": "2026-08-27"},
},
{
// Runs to midnight, which is the evening before as far as a
// grid is concerned.
"id": "late", "summary": "Nattevagt",
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
},
{
// A cancelled occurrence of a recurring event.
"id": "off", "status": "cancelled", "summary": "Aflyst",
"start": map[string]string{"date": "2026-08-25"},
"end": map[string]string{"date": "2026-08-26"},
},
{
// Neither a date nor a dateTime: belongs on no day at all.
"id": "nowhere", "summary": "Uden tid",
},
},
},
})
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
byID := map[string]Event{}
for _, e := range got {
byID[e.ID] = e
}
if len(got) != 4 {
t.Fatalf("got %d events, want 4: %+v", len(got), got)
}
if _, ok := byID["off"]; ok {
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
}
if _, ok := byID["nowhere"]; ok {
t.Error("an event with no time was kept; there is no cell to draw it in")
}
timed := byID["timed"]
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
t.Errorf("timed = %+v", timed)
}
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
}
allday := byID["allday"]
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
allday)
}
if allday.StartsAt != "" {
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
allday.StartsAt)
}
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
}
if late := byID["late"]; late.EndDay != "2026-08-22" {
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
"or every one of them puts a chip in the following day", late.EndDay)
}
}
// An event with no title has to say so. A blank chip in a grid reads as a
// rendering fault rather than as an empty title.
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{{
"id": "x",
"start": map[string]string{"date": "2026-08-20"},
"end": map[string]string{"date": "2026-08-21"},
}}},
})
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Summary == "" {
t.Fatalf("got %+v", got)
}
}
// The window is asked for with a day of slack at each end, because an event late
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
// out: an event on the day before the window is not in the window.
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{
{"id": "before", "summary": "For tidligt",
"start": map[string]string{"date": "2026-08-09"},
"end": map[string]string{"date": "2026-08-10"}},
{"id": "inside", "summary": "Med",
"start": map[string]string{"date": "2026-08-10"},
"end": map[string]string{"date": "2026-08-11"}},
{"id": "after", "summary": "For sent",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"}},
}},
})
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "inside" {
t.Fatalf("got %+v, want only the one inside the window", got)
}
}
// A page token has to be followed, or a busy calendar silently shows its first
// page and nothing else — which is a calendar that lies about a day being clear.
func TestEveryPageIsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"items": []map[string]any{{"id": "a", "summary": "Første",
"start": map[string]string{"date": "2026-08-11"},
"end": map[string]string{"date": "2026-08-12"}}},
"nextPageToken": "more",
}
if r.URL.Query().Get("pageToken") == "more" {
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
"start": map[string]string{"date": "2026-08-12"},
"end": map[string]string{"date": "2026-08-13"}}}}
}
_ = json.NewEncoder(w).Encode(body)
}))
defer srv.Close()
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
}
}
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
// two apart is what stops a revoked account being polled every ten minutes for ever.
func TestARefusedTokenIsItsOwnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
}))
defer srv.Close()
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
if err != ErrUnauthorized {
t.Fatalf("err = %v, want ErrUnauthorized", err)
}
}
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
// and a scope that quietly grew is a promise quietly broken.
func TestTheScopeIsReadOnly(t *testing.T) {
if !strings.HasSuffix(Scope, "calendar.readonly") {
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
}
}
+41 -213
View File
@@ -1,5 +1,5 @@
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
// Google, and the small slice of the Gmail API verdande needs.
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The connection is one-way on purpose: a starred or labelled message becomes a
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
@@ -9,200 +9,51 @@
//
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
// asking for less is what makes the consent screen something a person can agree to.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
// tests — still read as Gmail's, which is what they are: one registration, one
// flow, one scope each.
package gmail
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// Read-only, and only mail. Not profile, not contacts, not send.
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
// Read-only, and only mail. Not profile, not contacts, not send.
Scope = "https://www.googleapis.com/auth/gmail.readonly"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// The OAuth flow, which is Google's rather than Gmail's.
type (
Config = google.Config
PKCE = google.PKCE
Token = google.Token
)
// Endpoints is where Google is.
//
// A field rather than a constant so a test can point it at a server it controls.
// There is no other way to exercise what happens when Gmail is slow or refuses —
// and "the sync comes back before whatever sits in front of this server gives up
// on it" is exactly the kind of promise worth a test and impossible to make
// against the real thing.
//
// The zero value is Google, so nothing outside a test has to know it exists.
type Endpoints struct {
Token string
API string
}
func (e Endpoints) token() string {
if e.Token != "" {
return e.Token
}
return defaultTokenEndpoint
}
func (e Endpoints) api() string {
if e.API != "" {
return e.API
}
return defaultAPIBase
}
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
// the instance rather than to a person: one registration, any number of users.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
// Endpoints is Google unless a test says otherwise.
Endpoints Endpoints
}
func (c Config) Configured() bool {
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
}
// PKCE is the proof-key pair for one authorisation attempt.
//
// PKCE on a server-side client is not strictly required — the client secret already
// authenticates the token exchange — but it costs nothing and closes the window in
// which an authorisation code intercepted from the redirect could be replayed
// before verdande gets to it.
type PKCE struct {
Verifier string
Challenge string
State string
}
func NewPKCE() (PKCE, error) {
verifier, err := randomString(64)
if err != nil {
return PKCE{}, err
}
state, err := randomString(32)
if err != nil {
return PKCE{}, err
}
sum := sha256.Sum256([]byte(verifier))
return PKCE{
Verifier: verifier,
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
State: state,
}, nil
}
// AuthURL is where the browser is sent to ask the person for consent.
func (c Config) AuthURL(p PKCE) string {
v := url.Values{}
v.Set("client_id", c.ClientID)
v.Set("redirect_uri", c.RedirectURL)
v.Set("response_type", "code")
v.Set("scope", Scope)
v.Set("state", p.State)
v.Set("code_challenge", p.Challenge)
v.Set("code_challenge_method", "S256")
// Without both of these Google returns no refresh token on a re-authorisation,
// and the connection silently stops working an hour later.
v.Set("access_type", "offline")
v.Set("prompt", "consent")
return authEndpoint + "?" + v.Encode()
}
type Token struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// Exchange trades the authorisation code for tokens.
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
form := url.Values{}
form.Set("code", code)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("redirect_uri", c.RedirectURL)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", p.Verifier)
return c.token(ctx, form)
}
// Refresh gets a fresh access token. Google does not return a new refresh token
// here, so the caller keeps the one it has.
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
form := url.Values{}
form.Set("refresh_token", refreshToken)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("grant_type", "refresh_token")
token, err := c.token(ctx, form)
if err != nil {
return Token{}, err
}
token.RefreshToken = refreshToken
return token, nil
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
var parsed struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
}
if parsed.Error != "" {
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
}
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
}
return Token{
AccessToken: parsed.AccessToken,
RefreshToken: parsed.RefreshToken,
// A minute of slack, so a token that is about to expire is refreshed
// rather than used and rejected.
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
}, nil
}
var (
NewPKCE = google.NewPKCE
ErrUnauthorized = google.ErrUnauthorized
)
// --- the API ---------------------------------------------------------------------
type Client struct {
accessToken string
endpoints Endpoints
http *http.Client
// base is where the Gmail API is. A field rather than a constant so a test can
// point it at a server it controls — and "the sync comes back before whatever
// sits in front of this server gives up on it" is exactly the kind of promise
// worth a test and impossible to make against the real thing. Empty is Google.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
@@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client {
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(e Endpoints) *Client {
c.endpoints = e
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
@@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
@@ -257,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
@@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
@@ -308,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
}
func (c *Client) get(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gmail: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
// or changed their password. The caller disconnects rather than retrying.
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
// falling back to the address when there is no display name.
func SenderName(from string) string {
@@ -375,11 +211,3 @@ func Query(trigger, label string) string {
terms = append(terms, "newer_than:30d")
return strings.Join(terms, " ")
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
}
+4 -98
View File
@@ -1,113 +1,19 @@
package gmail
import (
"crypto/sha256"
"encoding/base64"
"net/url"
"strings"
"testing"
)
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
// Getting this wrong means Google refuses every exchange with a message that does
// not say which end is at fault.
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte(p.Verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if p.Challenge != want {
t.Errorf("challenge = %q, want %q", p.Challenge, want)
}
// RFC 7636 requires 43–128 characters, and no padding.
if n := len(p.Verifier); n < 43 || n > 128 {
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
}
if strings.ContainsAny(p.Verifier, "=+/") {
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
}
if p.State == "" {
t.Error("no state was generated")
}
// Two attempts must not share anything.
other, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
if other.Verifier == p.Verifier || other.State == p.State {
t.Error("two authorisation attempts produced the same secrets")
}
}
func TestAuthURL(t *testing.T) {
cfg := Config{
ClientID: "client-123",
ClientSecret: "secret",
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
}
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
raw := cfg.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("the URL does not parse: %v", err)
}
q := parsed.Query()
checks := map[string]string{
"client_id": cfg.ClientID,
"redirect_uri": cfg.RedirectURL,
"response_type": "code",
"scope": Scope,
"code_challenge": p.Challenge,
"code_challenge_method": "S256",
"state": p.State,
// Without both of these Google returns no refresh token on a
// re-authorisation, and the connection dies silently an hour later.
"access_type": "offline",
"prompt": "consent",
}
for key, want := range checks {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
// The verifier itself must never leave the server.
if strings.Contains(raw, p.Verifier) {
t.Error("the code verifier was put in the authorisation URL")
}
// Read-only, and only mail.
// The scope is the one thing verdande chooses about the connection, and it is the
// one the person reads on the consent screen. The flow itself is exercised in
// internal/google; what belongs here is that Gmail still asks for read-only mail.
func TestTheScopeIsReadOnlyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.readonly") {
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
}
}
func TestConfigured(t *testing.T) {
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
if !full.Configured() {
t.Error("a complete config is not reported as configured")
}
for _, partial := range []Config{
{},
{ClientID: "a"},
{ClientID: "a", ClientSecret: "b"},
{ClientSecret: "b", RedirectURL: "c"},
} {
if partial.Configured() {
t.Errorf("%+v was reported as configured", partial)
}
}
}
func TestQuery(t *testing.T) {
cases := []struct {
trigger, label string
+224
View File
@@ -0,0 +1,224 @@
// Package google implements the OAuth2 authorisation-code flow with PKCE against
// Google.
//
// It was written inside internal/gmail, because Gmail was the only thing that
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
// same refresh — and the only thing that differs between them is one string: the
// scope. A second copy would be a second place for "access_type=offline" to be
// forgotten, which is the mistake that makes a connection die silently an hour
// after it was made.
//
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
// keep their own clients and share only the way in.
package google
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
)
// Config is the OAuth client the operator registered in Google Cloud, plus the
// scope one feature is asking for.
//
// The client belongs to the instance rather than to a person: one registration,
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
// in through the same registration and ask for different things, and Google issues
// a refresh token per authorisation — so connecting one does not disturb the other.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
Scope string
// TokenURL is where Google is. A field rather than a constant so a test can
// point it at a server it controls: there is no other way to exercise what
// happens when the token endpoint is slow or refuses. Empty is Google, so
// nothing outside a test has to know it exists.
TokenURL string
}
func (c Config) Configured() bool {
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
}
func (c Config) tokenURL() string {
if c.TokenURL != "" {
return c.TokenURL
}
return defaultTokenEndpoint
}
// PKCE is the proof-key pair for one authorisation attempt.
//
// PKCE on a server-side client is not strictly required — the client secret already
// authenticates the token exchange — but it costs nothing and closes the window in
// which an authorisation code intercepted from the redirect could be replayed
// before verdande gets to it.
type PKCE struct {
Verifier string
Challenge string
State string
}
func NewPKCE() (PKCE, error) {
verifier, err := randomString(64)
if err != nil {
return PKCE{}, err
}
state, err := randomString(32)
if err != nil {
return PKCE{}, err
}
sum := sha256.Sum256([]byte(verifier))
return PKCE{
Verifier: verifier,
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
State: state,
}, nil
}
// AuthURL is where the browser is sent to ask the person for consent.
func (c Config) AuthURL(p PKCE) string {
v := url.Values{}
v.Set("client_id", c.ClientID)
v.Set("redirect_uri", c.RedirectURL)
v.Set("response_type", "code")
v.Set("scope", c.Scope)
v.Set("state", p.State)
v.Set("code_challenge", p.Challenge)
v.Set("code_challenge_method", "S256")
// Without both of these Google returns no refresh token on a re-authorisation,
// and the connection silently stops working an hour later.
v.Set("access_type", "offline")
v.Set("prompt", "consent")
return authEndpoint + "?" + v.Encode()
}
type Token struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// Exchange trades the authorisation code for tokens.
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
form := url.Values{}
form.Set("code", code)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("redirect_uri", c.RedirectURL)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", p.Verifier)
return c.token(ctx, form)
}
// Refresh gets a fresh access token. Google does not return a new refresh token
// here, so the caller keeps the one it has.
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
form := url.Values{}
form.Set("refresh_token", refreshToken)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("grant_type", "refresh_token")
token, err := c.token(ctx, form)
if err != nil {
return Token{}, err
}
token.RefreshToken = refreshToken
return token, nil
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
var parsed struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Token{}, fmt.Errorf("google: reading token response: %w", err)
}
if parsed.Error != "" {
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
}
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
}
return Token{
AccessToken: parsed.AccessToken,
RefreshToken: parsed.RefreshToken,
// A minute of slack, so a token that is about to expire is refreshed
// rather than used and rejected.
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
}, nil
}
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
// or changed their password. The caller disconnects rather than retrying.
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
// Get performs an authorised GET and decodes the JSON body into out.
//
// Shared by the two API clients because the interesting part is the same in both:
// a 401 is not a failure to retry, it is a connection that has ended, and telling
// those apart is what stops a revoked account being polled every ten minutes for
// ever.
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("google: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
}
+127
View File
@@ -0,0 +1,127 @@
package google
import (
"crypto/sha256"
"encoding/base64"
"net/url"
"strings"
"testing"
)
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
// Getting this wrong means Google refuses every exchange with a message that does
// not say which end is at fault.
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte(p.Verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if p.Challenge != want {
t.Errorf("challenge = %q, want %q", p.Challenge, want)
}
// RFC 7636 requires 43–128 characters, and no padding.
if n := len(p.Verifier); n < 43 || n > 128 {
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
}
if strings.ContainsAny(p.Verifier, "=+/") {
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
}
if p.State == "" {
t.Error("no state was generated")
}
// Two attempts must not share anything.
other, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
if other.Verifier == p.Verifier || other.State == p.State {
t.Error("two authorisation attempts produced the same secrets")
}
}
func TestAuthURL(t *testing.T) {
cfg := Config{
ClientID: "client-123",
ClientSecret: "secret",
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
Scope: "https://www.googleapis.com/auth/gmail.readonly",
}
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
raw := cfg.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("the URL does not parse: %v", err)
}
q := parsed.Query()
checks := map[string]string{
"client_id": cfg.ClientID,
"redirect_uri": cfg.RedirectURL,
"response_type": "code",
"scope": cfg.Scope,
"code_challenge": p.Challenge,
"code_challenge_method": "S256",
"state": p.State,
// Without both of these Google returns no refresh token on a
// re-authorisation, and the connection dies silently an hour later.
"access_type": "offline",
"prompt": "consent",
}
for key, want := range checks {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
// The verifier itself must never leave the server.
if strings.Contains(raw, p.Verifier) {
t.Error("the code verifier was put in the authorisation URL")
}
}
// The scope is the one thing that differs between the two features signing in
// through this, so it has to come out of the config rather than out of a constant
// somebody could forget to change.
func TestTheScopeComesFromTheConfig(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
calendar := "https://www.googleapis.com/auth/calendar.readonly"
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if got := parsed.Query().Get("scope"); got != calendar {
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
"that grants the wrong thing", got, calendar)
}
}
func TestConfigured(t *testing.T) {
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
if !full.Configured() {
t.Error("a complete config is not reported as configured")
}
for _, partial := range []Config{
{},
{ClientID: "a"},
{ClientID: "a", ClientSecret: "b"},
{ClientSecret: "b", RedirectURL: "c"},
} {
if partial.Configured() {
t.Errorf("%+v was reported as configured", partial)
}
}
}
+30 -1
View File
@@ -32,6 +32,7 @@ type userJSON struct {
// about the work and not about the screen — the sidebar's width is the
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
CreatedAt string `json:"created_at"`
}
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
if collapsed == nil {
collapsed = []string{}
}
order := u.NavOrder
if order == nil {
order = []string{}
}
return userJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// handleSetNavOrder records the order of the fixed views in the sidebar.
//
// Its own route for the same reason as the folds: this writes on every drop, and
// sharing the profile's path would make a reorder send a name and a timezone it
// was never asked to change.
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
Order []string `json:"order"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.Order) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
return
}
user := userFrom(r.Context())
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
s.storeError(w, r, "set nav order", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
+297
View File
@@ -0,0 +1,297 @@
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/beacon"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
// The beacon, from the instance's side.
//
// Two halves that happen to live in the same binary: the part that reports in,
// which every installation runs, and the part that receives, which only the one
// acting as collector turns on. See internal/beacon for what is sent and why it
// is on by default rather than compulsory.
const beaconScope = "beacon"
// beaconConfig is what this instance has been told to do. The zero value is what
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
// not here — a zero struct must never mean "silently on".
type beaconConfig struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt int64 `json:"last_ping_at,omitempty"`
}
// beaconSettings reads the configuration, filling in the defaults and minting the
// anonymous id on first use.
//
// The id is generated here rather than derived from anything about the machine.
// A hash of the hostname or the base URL would be stable and convenient and would
// also be a value the collector could compare against a guess — which is the
// difference between anonymous and pseudonymous, and it is the whole point.
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
return cfg, err
}
if v, ok := stored["enabled"].(bool); ok {
cfg.Enabled = v
}
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
cfg.Collector = strings.TrimSpace(v)
}
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
cfg.InstanceID = v
}
if v, ok := stored["is_collector"].(bool); ok {
cfg.IsCollector = v
}
if v, ok := stored["publish_count"].(bool); ok {
cfg.PublishCount = v
}
if v, ok := stored["last_ping_at"].(float64); ok {
cfg.LastPingAt = int64(v)
}
if cfg.InstanceID == "" {
cfg.InstanceID = store.NewID()
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
return cfg, err
}
}
return cfg, nil
}
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
"enabled": cfg.Enabled,
"collector_url": cfg.Collector,
"instance_id": cfg.InstanceID,
"is_collector": cfg.IsCollector,
"publish_count": cfg.PublishCount,
"last_ping_at": cfg.LastPingAt,
})
}
// --- what the settings page shows ------------------------------------------------
type beaconStatusJSON struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt string `json:"last_ping_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
out := beaconStatusJSON{
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
}
if cfg.LastPingAt > 0 {
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
}
if cfg.IsCollector {
stats, err := s.db.BeaconSummary(r.Context())
if err != nil {
s.internal(w, r, "beacon summary", err)
return
}
out.Stats = &stats
}
writeJSON(w, http.StatusOK, out)
}
type beaconRequest struct {
Enabled *bool `json:"enabled"`
Collector *string `json:"collector_url"`
IsCollector *bool `json:"is_collector"`
PublishCount *bool `json:"publish_count"`
}
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
var req beaconRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.IsCollector != nil {
cfg.IsCollector = *req.IsCollector
}
if req.PublishCount != nil {
cfg.PublishCount = *req.PublishCount
}
if req.Collector != nil {
url := strings.TrimSpace(*req.Collector)
if url == "" {
url = beacon.DefaultCollector
}
// http and https only, and said as a field error rather than a 500: an
// operator pointing this at their own collector will mistype it, and the
// page should say which field is wrong.
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
return
}
cfg.Collector = url
}
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
s.internal(w, r, "save beacon settings", err)
return
}
s.handleBeaconStatus(w, r)
}
// --- the collector's side ---------------------------------------------------------
// handleBeaconPing receives one report. Unauthenticated, because the whole point
// is that a stranger's installation can reach it.
//
// Nothing about the request is recorded except the two values in the body. The
// source address is not read here, and there is no code path from this function
// to anything that could write one down — which is the only version of that
// promise worth making.
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
// An instance that is not a collector does not quietly accept and drop pings —
// it says there is nothing here. Otherwise every installation in the world is a
// spam sink that answers 200 to anything.
if !cfg.IsCollector {
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
var p beacon.Payload
if err := decodeJSON(w, r, &p); err != nil {
return
}
// Checked, not trusted. This is the one endpoint anybody can post to, and the
// two columns behind it should only ever hold the two shapes this program
// writes — an id that is not id-shaped is somebody probing, not an install.
if !beacon.ValidID(p.InstanceID) {
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
return
}
if p.Version != "" && !beacon.ValidVersion(p.Version) {
writeFieldErrors(w, map[string]string{"version": "malformed"})
return
}
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
s.internal(w, r, "record beacon", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleBeaconCount publishes the number, when the collector has been told to.
//
// Thirty days rather than a running total: a total only climbs, so it stops
// meaning "how many people use this" and starts meaning "how many ever tried it".
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
if !cfg.IsCollector || !cfg.PublishCount {
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
return
}
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
if err != nil {
s.internal(w, r, "beacon count", err)
return
}
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
}
// SendBeacon reports this installation, at most once a day.
//
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
// settings, the anonymous id and the version all live on this side, and the
// runner should not have to know about any of them.
//
// The day is counted from the last successful send, not from a wall-clock hour.
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
// counted this way they spread themselves out by when each was started.
func (s *Server) SendBeacon(ctx context.Context) error {
cfg, err := s.beaconSettings(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
return nil
}
// The collector does not report to itself over the network. It would work, and
// it would also mean the one instance whose count matters most depends on its
// own tunnel being up to be counted.
if cfg.IsCollector {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
cfg.LastPingAt = time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
return nil
}
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
// svarede, er i sig selv det, sådan et forsøg leder efter.
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
InstanceID: cfg.InstanceID,
Version: Version,
}); err != nil {
// Logged at info, not error. The collector being unreachable is not this
// installation's problem and must never look like one — it is somebody
// else's server, and the only consequence is a number being one lower.
s.log.Info("beacon not delivered", "err", err)
return nil
}
cfg.LastPingAt = time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
+548
View File
@@ -0,0 +1,548 @@
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"github.com/kristianwind/verdande/internal/gcal"
"github.com/kristianwind/verdande/internal/store"
)
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
// server is the one that answers a slow Google rather than Cloudflare's own HTML
// page — which is how a failure arrives with no code, no message and nothing in
// the error log.
const defaultCalendarSyncBudget = 25 * time.Second
// The window verdande keeps a copy of.
//
// A month grid pages, so asking Google on every arrow press would mean a round trip
// for a question answered ninety seconds ago — a calendar slower than the calendar
// it is showing. A cached window is the alternative, and a window has to end
// somewhere. Ninety days back is roughly "when was that", a year forward is past
// anything anybody has booked; between them they cover every screen the grid can
// reach without a person noticing an edge.
//
// The edge is still real, and the events endpoint says where it is rather than
// answering an empty list for a month outside it. A calendar that quietly shows no
// events is a calendar that lies about a day being clear.
const (
calendarWindowBack = 90
calendarWindowAhead = 365
calendarDateLayout = "2006-01-02"
calendarProviderName = "google"
)
func (s *Server) calendarClient(accessToken string) *gcal.Client {
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
}
// calendarConfig is the same registration Gmail signs in through, asking for a
// different scope and coming back to a different URI.
//
// One OAuth client, two features. Google issues a refresh token per authorisation
// and keeps up to a hundred of them live per account, each carrying the scopes it
// was granted with — so connecting the calendar does not disturb a working Gmail,
// and disconnecting one does not take the other with it.
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
cfg := s.gmailConfig(ctx)
cfg.Scope = gcal.Scope
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
return cfg
}
// --- connecting ------------------------------------------------------------------
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
// reason for keeping the verifier server-side: the callback arrives as a top-level
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
// until a browser tightens its defaults.
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg := s.calendarConfig(r.Context())
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
"no Google OAuth client is registered on this server")
return
}
pkce, err := gcal.NewPKCE()
if err != nil {
s.internal(w, r, "calendar pkce", err)
return
}
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
// person who started both flows had one verifier, and whichever came back
// second would be refused for a reason nothing on the screen could explain.
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
if err != nil {
s.internal(w, r, "calendar settings", err)
return
}
settings["pkce_verifier"] = pkce.Verifier
settings["pkce_state"] = pkce.State
// The attempt expires: a stored verifier that lives forever is a replay window
// that lives forever.
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
s.internal(w, r, "calendar settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
}
// handleCalendarCallback is where Google sends the browser back.
//
// It answers with a redirect into the app rather than with JSON: the person is
// looking at a browser tab, not at a fetch response.
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
if errParam := r.URL.Query().Get("error"); errParam != "" {
// Somebody clicked "cancel" on the consent screen — or, on an Internal
// OAuth app, Google refused an account outside the organisation with
// `org_internal`. Both arrive here, and the page names what it was given.
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
if err != nil {
s.internal(w, r, "calendar settings", err)
return
}
verifier, _ := settings["pkce_verifier"].(string)
expected, _ := settings["pkce_state"].(string)
expires, _ := settings["pkce_expires"].(float64)
// The state has to match, or this endpoint accepts a code from anywhere.
if verifier == "" || expected == "" || state != expected {
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
return
}
if int64(expires) < time.Now().Unix() {
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
return
}
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
gcal.PKCE{Verifier: verifier})
if err != nil {
s.log.Error("calendar exchange", "err", err, "user", user.ID)
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
return
}
if token.RefreshToken == "" {
// Without one the connection dies in an hour. Google only withholds it when
// the consent prompt was skipped, which access_type and prompt are meant to
// prevent — so this means something is wrong with the client registration
// rather than with this request.
s.log.Error("calendar returned no refresh token", "user", user.ID)
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
return
}
// The one-time values are cleared: keeping a spent verifier serves nothing and
// is one more secret at rest.
delete(settings, "pkce_verifier")
delete(settings, "pkce_state")
delete(settings, "pkce_expires")
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
s.internal(w, r, "clear calendar handshake", err)
return
}
account := &store.CalendarAccount{
UserID: user.ID, Provider: calendarProviderName,
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
ExpiresAt: token.ExpiresAt,
}
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
s.internal(w, r, "save calendar tokens", err)
return
}
// The list of calendars, fetched now so the page that opens next has something
// to tick rather than an empty panel and a button. Best effort: an account that
// connects but cannot list itself is still connected, and the status endpoint
// asks again when it finds nothing.
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
s.log.Warn("calendar list", "err", err, "user", user.ID)
}
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
}
// --- what the settings page reads --------------------------------------------------
type calendarStatusJSON struct {
Connected bool `json:"connected"`
Account string `json:"account,omitempty"`
Calendars []store.Calendar `json:"calendars"`
// RedirectURI is what has to be registered with Google, spelled out. Derived
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
// be wrong, and the error Google gives for it names neither value.
RedirectURI string `json:"redirect_uri"`
// HasClient says whether there is an OAuth registration to connect through at
// all, so the page can explain rather than offer a button that answers 409.
HasClient bool `json:"has_client"`
LastSyncAt string `json:"last_sync_at,omitempty"`
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
// an interface that quietly looks editable is one somebody will try to edit.
ReadOnly bool `json:"read_only"`
}
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
out := calendarStatusJSON{
Calendars: []store.Calendar{},
RedirectURI: s.cfg.CalendarRedirectURL(),
HasClient: s.calendarConfig(r.Context()).Configured(),
ReadOnly: true,
}
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account == nil || account.RefreshToken == "" {
writeJSON(w, http.StatusOK, out)
return
}
out.Connected = true
out.Account = account.Account
if !account.LastSyncAt.IsZero() {
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
}
calendars, err := s.db.Calendars(r.Context(), account.ID)
if err != nil {
s.internal(w, r, "calendars", err)
return
}
// An account connected during a Google hiccup has no calendars and no way back:
// the person sees an empty list and nothing to press. Asking again here costs
// one request on a page nobody opens often, and means the wrong answer lasts
// until the next look rather than for ever. Same reasoning as the Gmail address.
if len(calendars) == 0 {
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
calendars = fetched
out.Account = account.Account
} else {
s.log.Warn("calendar list", "err", err, "user", user.ID)
}
}
if calendars != nil {
out.Calendars = calendars
}
writeJSON(w, http.StatusOK, out)
}
type showCalendarsRequest struct {
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
// a person has a handful of calendars, the question is "these ones", and a list
// that can only be built by several requests can land half applied.
Shown []string `json:"shown"`
}
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
var req showCalendarsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account == nil {
// A 404 rather than a 409: everything the caller may not see answers 404,
// and "there is no such connection" is the true answer either way.
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
return
}
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
s.internal(w, r, "show calendars", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account != nil {
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
s.internal(w, r, "disconnect calendar", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// --- what the grid reads -----------------------------------------------------------
type calendarEventsJSON struct {
Events []store.CalendarEvent `json:"events"`
// From and To are the window verdande holds a copy of, not the window that was
// asked for. A grid that has paged past it gets no events, and an empty answer
// with no explanation is a calendar quietly claiming a day is clear.
From string `json:"from"`
To string `json:"to"`
}
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
fields := map[string]string{}
if !validDay(from) {
fields["from"] = "must be a date, as 2026-08-20"
}
if !validDay(to) {
fields["to"] = "must be a date, as 2026-08-20"
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
if err != nil {
s.internal(w, r, "calendar events", err)
return
}
if events == nil {
events = []store.CalendarEvent{}
}
windowFrom, windowTo := calendarWindow(time.Now())
writeJSON(w, http.StatusOK, calendarEventsJSON{
Events: events, From: windowFrom, To: windowTo,
})
}
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
// can see it work rather than wondering whether it did.
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
// A budget of its own, well under what sits in front of this server. An unset
// budget is not a budget of nothing: a zero here would expire the context
// before the first call and make every sync a silent no-op.
budget := s.cfg.CalendarSyncBudget
if budget <= 0 {
budget = defaultCalendarSyncBudget
}
ctx, cancel := context.WithTimeout(r.Context(), budget)
defer cancel()
count, err := s.SyncCalendars(ctx, user)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// Not a failure: Google is slow or busy, some calendars were read, and
// the rest arrive on the next run. Every calendar is committed as it is
// read, so a short budget is a shorter run rather than a lost one.
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
return
}
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"events": count})
}
// SyncCalendars refreshes every calendar this person has chosen to see.
//
// Returns how many events were written. Also used by the background job, which is
// why it takes a context and a user rather than a request.
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
if err != nil {
return 0, err
}
if account == nil || account.RefreshToken == "" {
return 0, nil // not connected; nothing to do and nothing to complain about
}
// The same guard the mailboxes have, for the same reason: a press of "fetch
// now" during the sweep would otherwise have two runs replacing the same window
// at once, and the loser's delete would land after the winner's insert.
unlock := s.lockSync("calendar:" + account.ID)
defer unlock()
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
account = fresh
}
access, err := s.calendarAccessToken(ctx, account)
if err != nil {
return 0, err
}
client := s.calendarClient(access)
// The list first, so a calendar added or renamed since the last run is drawn
// under its new name and one that has gone stops being drawn at all.
calendars, err := s.calendarList(ctx, client, account)
if err != nil {
return 0, s.forgetIfRevoked(ctx, user, account, err)
}
from, to := calendarWindow(time.Now())
written := 0
for _, calendar := range calendars {
if !calendar.Shown {
continue
}
// A spent budget ends the run rather than turning into a failed fetch per
// calendar, logged one at a time.
if ctx.Err() != nil {
return written, ctx.Err()
}
events, err := client.Events(ctx, calendar.RemoteID, from, to)
if err != nil {
if errors.Is(err, gcal.ErrUnauthorized) {
return written, s.forgetIfRevoked(ctx, user, account, err)
}
// One calendar somebody was unshared from must not stop the others.
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
continue
}
rows := make([]store.CalendarEvent, 0, len(events))
for _, e := range events {
rows = append(rows, store.CalendarEvent{
RemoteID: e.ID, Summary: e.Summary,
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
StartDay: e.StartDay, EndDay: e.EndDay,
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
})
}
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
return written, err
}
written += len(rows)
}
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
return written, err
}
return written, nil
}
// calendarAccessToken returns a usable access token, refreshing only when the one
// held has actually expired.
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
return account.AccessToken, nil
}
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
if err != nil {
return "", err
}
account.AccessToken = token.AccessToken
account.ExpiresAt = token.ExpiresAt
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
return "", err
}
return account.AccessToken, nil
}
// refreshCalendarList fetches the calendar list with a token it obtains itself.
// Used by the paths that have an account but no client yet — connecting, and the
// status endpoint correcting an empty list.
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
access, err := s.calendarAccessToken(ctx, account)
if err != nil {
return nil, err
}
return s.calendarList(ctx, s.calendarClient(access), account)
}
// calendarList writes what the provider says and returns what is stored, which is
// not the same list: the provider says what exists, and only verdande knows which
// of them somebody chose to look at.
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
remote, err := client.Calendars(ctx)
if err != nil {
return nil, err
}
list := make([]store.Calendar, 0, len(remote))
for _, c := range remote {
list = append(list, store.Calendar{
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
})
// The primary calendar is the account, and its id is the address. It is the
// only place the connected account names itself without a second scope —
// asking for a profile scope to learn an address already in the answer would
// be one more thing on the consent screen for nothing.
if c.Primary && account.Account == "" {
account.Account = c.ID
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
return nil, err
}
}
}
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
return nil, err
}
return s.db.Calendars(ctx, account.ID)
}
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
// other failure through untouched.
//
// The distinction is the point. A revoked account polled every ten minutes for ever
// is a log line a week long and a connection the person believes they removed; a
// Google that is merely down is one that will answer next time.
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
if errors.Is(err, gcal.ErrUnauthorized) {
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
}
return err
}
// calendarWindow is the span verdande keeps a copy of, measured from today.
//
// Built from the calendar date rather than by truncating the instant: Truncate
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
// two in the morning — so for two hours of every summer day the window would start
// and end a day out.
func calendarWindow(now time.Time) (string, string) {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
}
func validDay(s string) bool {
_, err := time.Parse(calendarDateLayout, s)
return err == nil
}
+471
View File
@@ -0,0 +1,471 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
// token endpoint, the calendar list and one calendar's events.
//
// There is no other way to exercise any of it. The real thing needs a registered
// OAuth client, an account and somebody's consent, and none of the three can be had
// inside a test — so the shapes Google returns are written down and the behaviour
// is checked against them, the way internal/imap is checked against go-imap's own
// in-process server.
type fakeCalendarGoogle struct {
*httptest.Server
// refreshes counts token refreshes, so a test can prove one is not made for a
// token that has not expired.
refreshes int
// refuse makes every API call answer 401, which is what a revoked grant does.
refuse bool
events map[string][]map[string]any
}
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
t.Helper()
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/token":
g.refreshes++
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
"expires_in": 3600,
})
case g.refuse:
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
{"id": "kw@nolimit.dk", "summary": "kw@nolimit.dk", "primary": true,
"accessRole": "owner", "backgroundColor": "#3f51b5",
"timeZone": "Europe/Copenhagen"},
{"id": "fam", "summary": "Familien", "accessRole": "reader",
"backgroundColor": "#0b8043"},
}})
case strings.HasPrefix(r.URL.Path, "/calendars/"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
items := g.events[id]
if items == nil {
items = []map[string]any{}
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
default:
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
}
}))
t.Cleanup(g.Close)
return g
}
// connectedCalendar returns a server with a calendar account already connected —
// the state the OAuth callback leaves behind, without the consent screen nothing
// can automate.
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
t.Helper()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = g.URL + "/token"
cfg.CalendarAPIURL = g.URL
if tune != nil {
tune(cfg)
}
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
account := &store.CalendarAccount{
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
}
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
return ts, user
}
// The scope on the consent screen is what somebody agrees to. It has to be the
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
// would put "which token store do I write" behind a lookup.
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, _ := connectedCalendar(t, g, nil)
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
url, _ := body["url"].(string)
for _, want := range []string{
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
"%2Foauth%2Fcalendar%2Fcallback",
"access_type=offline",
"prompt=consent",
} {
if !strings.Contains(url, want) {
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
}
}
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
t.Errorf("a writing scope was asked for:\n%s", url)
}
}
// Without a registered client there is nothing to connect through, and the answer
// has to say that rather than "that already exists".
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if body["code"] != CodeGmailNotConfigured {
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
}
}
// The whole path, minus the consent screen: sync fetches the list, refreshes the
// events of what was chosen, and the grid endpoint answers with them.
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
g := newFakeCalendarGoogle(t)
g.events["fam"] = []map[string]any{
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
}
g.events["kw@nolimit.dk"] = []map[string]any{
{"id": "e2", "summary": "Skal ikke med",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"}},
}
ts, user := connectedCalendar(t, g, nil)
// The list arrives on the first look at the settings page, which is what
// somebody would be doing at this point.
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["connected"] != true {
t.Fatalf("not reported as connected: %v", body)
}
if body["read_only"] != true {
t.Error("the connection did not say it is read-only; an interface that looks " +
"editable is one somebody will try to edit")
}
// The primary calendar's id is the address, which is how the account names
// itself without asking for a profile scope as well.
if body["account"] != "kw@nolimit.dk" {
t.Errorf("account = %v", body["account"])
}
list, _ := body["calendars"].([]any)
if len(list) != 2 {
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
}
var famID string
for _, raw := range list {
c := raw.(map[string]any)
if c["shown"] == true {
t.Errorf("%v was shown without anybody choosing it", c["name"])
}
if c["remote_id"] == "fam" {
famID = c["id"].(string)
}
}
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
// calendars must not fill somebody's grid the moment it is connected.
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
map[string]any{"shown": []string{famID}})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
}
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if body["events"] != float64(1) {
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
}
resp, body = ts.do(t, "GET",
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
}
events, _ := body["events"].([]any)
if len(events) != 1 {
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
}
event := events[0].(map[string]any)
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
t.Errorf("the event came back as %v", event)
}
// The calendar's colour rides along, or the chip has to ask for it per row.
if event["colour"] != "#0b8043" {
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
}
// The window verdande holds is stated, so a grid paged past it can say so
// rather than showing an empty month it cannot explain.
if body["from"] == "" || body["to"] == "" {
t.Error("the answer does not say which window it covers")
}
// And it is one person's calendar. Somebody else on the same instance sees
// nothing of it.
other := ts.newUser(t, "andreas@example.dk", "Andreas")
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if events, _ := body["events"].([]any); len(events) != 0 {
t.Errorf("somebody else's calendar was visible: %v", body["events"])
}
_ = user
}
// A token that has not expired must not be traded in for another. Refreshing on
// every run burns the quota and makes the failure mode of a bad refresh token
// arrive on every sweep rather than once an hour.
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, user := connectedCalendar(t, g, nil)
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
if err != nil {
t.Fatal(err)
}
account.AccessToken = "ya29.still-good"
account.ExpiresAt = time.Now().Add(time.Hour)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if g.refreshes != 0 {
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
}
// And an expired one is.
account.ExpiresAt = time.Now().Add(-time.Minute)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if g.refreshes != 1 {
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
}
}
// Access revoked at Google's end is a connection that has ended, not a failure to
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
// connection the person believes they removed.
func TestRevokedAccessDisconnects(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, user := connectedCalendar(t, g, nil)
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
account.AccessToken = "ya29.revoked"
account.ExpiresAt = time.Now().Add(time.Hour)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
g.refuse = true
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != StatusUpstreamRefused {
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
resp.StatusCode, StatusUpstreamRefused)
}
if body["code"] != CodeCalendarFailed {
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
}
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
if err != nil {
t.Fatal(err)
}
if gone != nil {
t.Error("the connection is still there after Google refused the token")
}
// And the failure is in the error log, which is the point of having one: a
// sync that has been failing for a week is invisible otherwise.
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := logged["errors"].([]any)
if len(rows) == 0 {
t.Fatal("nothing was recorded")
}
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
}
}
// Disconnecting takes the copy with it. None of it is the person's own work, and
// a calendar left behind after the account is gone is a frozen lie about a day.
func TestDisconnectingRemovesTheEvents(t *testing.T) {
g := newFakeCalendarGoogle(t)
g.events["fam"] = []map[string]any{
{"id": "e1", "summary": "Møde",
"start": map[string]string{"date": "2026-08-20"},
"end": map[string]string{"date": "2026-08-21"}},
}
ts, user := connectedCalendar(t, g, nil)
ts.do(t, "GET", "/api/v1/calendar", nil)
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
var famID string
for _, c := range calendars {
if c.RemoteID == "fam" {
famID = c.ID
}
}
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("disconnect: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if events, _ := body["events"].([]any); len(events) != 0 {
t.Errorf("events survived the disconnect: %v", body["events"])
}
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
if body["connected"] != false {
t.Error("still reported as connected")
}
}
// A range that is not a date has to be refused, not guessed at. An empty `from`
// compared as a string matches everything before it, which is a grid quietly
// answering a question nobody asked.
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, _ := connectedCalendar(t, g, nil)
for _, query := range []string{
"", "?from=2026-08-01", "?from=&to=2026-08-31",
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
} {
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
}
}
}
// A person with no calendar connected still opens the view. It has to answer, and
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
// they must not disappear because Google is not set up.
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
t.Errorf("events = %v, want an empty list rather than null", body["events"])
}
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
if resp.StatusCode != http.StatusOK || body["connected"] != false {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["has_client"] != false {
t.Error("an instance with no OAuth registration claimed to have one; the page " +
"would offer a button that answers 409")
}
// Spelled out even when nothing is connected: it is what has to be registered
// with Google, and it is the single most likely thing to be wrong.
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
t.Errorf("redirect_uri = %q", uri)
}
}
// A slow Google must not hold the request open until something in front of this
// server gives up on it and answers with its own HTML.
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
blocked := make(chan struct{})
defer close(blocked)
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-blocked:
case <-r.Context().Done():
}
}))
defer slow.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = slow.URL + "/token"
cfg.CalendarAPIURL = slow.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
// would put half a minute into the suite to learn the same thing.
cfg.CalendarSyncBudget = time.Second
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
UserID: user.ID, Provider: "google", RefreshToken: "r",
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Its own client, because the shared one gives up after ten seconds — which is
// shorter than the budget being tested and would prove nothing either way.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
"own, and whatever sits in front of this server answers for it",
err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
if took := time.Since(started); took > 10*time.Second {
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
}
if resp.StatusCode >= 500 {
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
}
}
+66 -6
View File
@@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit
return true, nil
}
// A file inside a note follows the note, which follows its project — the same
// chain everything else here follows. Without this a picture imported with a
// note answered 404 while sitting in the middle of the text that named it.
if a.NoteID != "" {
n, err := s.db.Note(r.Context(), a.NoteID)
if err != nil {
return false, err
}
if n == nil {
return false, store.ErrNotFound
}
if n.ProjectID == "" {
if n.CreatedBy != user.ID {
return false, store.ErrNotFound
}
return true, nil
}
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
if err != nil {
return false, err
}
return role.CanEdit(), nil
}
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
return false, err
@@ -370,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
}
defer f.Close()
// Everything is served as a download rather than rendered. An uploaded SVG or
// HTML file displayed inline would run its own script on this origin, with the
// session cookie attached — attachments are the one place a user supplies
// content that another user opens.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
// Almost everything is served as a download rather than rendered. An uploaded
// SVG or HTML file displayed inline would run its own script on this origin,
// with the session cookie attached — attachments are the one place a user
// supplies content that another user opens.
//
// The exception is a short list of raster images, and it is not a softening of
// that rule but what makes a note work: a note carries its pictures in the text
// as `![](…)`, and served as octet-stream every one of them is a broken image.
// The whole Apple Notes import exists to bring those pictures along.
//
// Safe because it is an allowlist of formats that cannot carry script, sent as
// the exact type with `nosniff` — so the browser must treat it as that image or
// not at all, and can never be talked into reading it as HTML. SVG is
// deliberately absent: it is a document, not a picture, and it is the one image
// type that can run script.
disposition := "attachment"
contentType := "application/octet-stream"
if inlineImage[a.MimeType] {
disposition = "inline"
contentType = a.MimeType
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
@@ -384,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
// inlineImage is what may be shown rather than downloaded.
//
// Raster formats only, and named one by one rather than matched on an "image/"
// prefix: the prefix would let image/svg+xml through, which is a document that
// can run script, and a list that grows by accident is how that happens.
var inlineImage = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/avif": true,
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
"image/heic": true,
"image/heif": true,
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
+5
View File
@@ -63,6 +63,11 @@ const (
// diagnosis — a wrong app password, a host that is not listening, a folder that
// is not there — and it says it in its own words.
CodeMailboxFailed = "mailbox_failed"
// And for a calendar read from Google. Its own code rather than the Gmail one,
// because the sentence the interface wraps it in names where it came from — and
// "Gmail said no" about a calendar sends somebody to the wrong panel.
CodeCalendarFailed = "calendar_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
+40 -10
View File
@@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second
// to redeploy a container to paste a client id.
// gmailClient is the API client, pointed wherever the config says Google is.
func (s *Server) gmailClient(accessToken string) *gmail.Client {
return gmail.NewClient(accessToken).At(gmail.Endpoints{
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
})
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
}
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
Endpoints: endpoints,
Scope: gmail.Scope,
TokenURL: s.cfg.GoogleTokenURL,
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
@@ -251,6 +249,17 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
//
// Returns how many were made. Also used by the background job, which is why it
// takes a context and a user rather than a request.
// maxGmailSeen bounds the list of message ids already turned into tasks.
//
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
// asking for less is what makes the consent screen something a person can agree
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
// record has to live on this side, and a record on this side has to be bounded.
//
// A count rather than the previous rule of "whatever Gmail returned this run",
// which was not a bound but a way of forgetting.
const maxGmailSeen = 2000
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
if err != nil {
@@ -260,7 +269,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// The same guard as the mailboxes read over IMAP, for the same reason: a
// press of "Fetch now" during the sweep would otherwise make two runs from
// one marker.
unlock := s.lockMailbox(box.ID)
unlock := s.lockSync(box.ID)
defer unlock()
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
box = fresh
@@ -362,14 +371,35 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
}
if created > 0 {
// Only the ids Gmail still returns are kept, so the list cannot grow
// without bound as messages fall out of the 30-day window.
keep := make([]string, 0, len(ids))
// The ids this run saw, on top of the ones already known.
//
// It used to keep only the ids Gmail returned *this run*, to stop the list
// growing without bound. That threw away every id that was not on the page
// it happened to fetch — and the query is capped, so on an account with more
// starred mail than one page, each run forgot the messages the last one had
// remembered. They came back unseen, and became tasks again, and again. That
// is the duplicate.
//
// Bounded by a count instead. Newest first, and a ceiling far above any
// plausible page, so an id leaves the list because it is old rather than
// because it was not in the last answer.
keep := make([]string, 0, len(ids)+len(box.Seen))
added := map[string]bool{}
for _, id := range ids {
if seen[id] {
if seen[id] && !added[id] {
keep = append(keep, id)
added[id] = true
}
}
for _, id := range box.Seen {
if !added[id] {
keep = append(keep, id)
added[id] = true
}
}
if len(keep) > maxGmailSeen {
keep = keep[:maxGmailSeen]
}
box.Seen = keep
box.LastSyncAt = time.Now()
if err := s.db.SaveMailbox(ctx, box); err != nil {
+2 -2
View File
@@ -29,7 +29,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GoogleTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
@@ -97,7 +97,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GoogleTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
+28 -3
View File
@@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
// One reader of this mailbox at a time. Held for the whole run, marker and all:
// releasing before the marker is written would leave exactly the gap this is
// here to close.
unlock := s.lockMailbox(m.ID)
unlock := s.lockSync(m.ID)
defer unlock()
// Re-read under the lock. The row that came in may have been fetched before the
@@ -175,6 +175,10 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
created := 0
highest := m.LastUID
// The uids of the mails that actually became tasks. Only those get unflagged,
// and only after the task is committed: a flag removed from a mail whose task
// was never written is a mail nobody will ever see again.
done := make([]uint32, 0, len(messages))
for _, msg := range messages {
if ctx.Err() != nil {
// What was made so far is already committed and counted, and the marker
@@ -204,12 +208,27 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
continue
}
created++
done = append(done, msg.UID)
if msg.UID > highest {
highest = msg.UID
}
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// The flag comes off what became a task. This is what actually stops a mail
// arriving twice; the marker below is now a second line rather than the only
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
//
// Outside the request's context, like the marker: a cancelled request that
// already created tasks must still take the flags off them, or the next run
// makes them all again.
if err := client.Unflag(done); err != nil {
// Not fatal, and not the caller's problem. A folder somebody opened
// read-only, or a server that refuses the STORE, still gets the uid marker
// underneath — worse, but not broken.
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
}
// A run that got all the way through moves the marker to the whole of what the
// search considered, not merely to the last message it happened to read. That
// is what stops the same mail becoming a task again on the next run when the
@@ -231,8 +250,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
return created, nil
}
// lockMailbox serialises one mailbox against itself and returns the release.
func (s *Server) lockMailbox(id string) func() {
// lockSync serialises one thing that polls against itself and returns the release.
//
// A mailbox to begin with; a calendar connection now takes the same lock under a
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
// is in the middle of the same thing — both start from the state as it was, and
// the loser writes last. For a mailbox that made forty copies of one mail in a
// second; for a calendar it would be a window deleted after it was refilled.
func (s *Server) lockSync(id string) func() {
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
+166
View File
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
}, "task_id", "body"),
}, s.mcpAddComment)
s.registerNoteTools(m)
return m
}
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
}
return value
}
// --- notes ---------------------------------------------------------------------
// registerNoteTools adds the note half of the MCP surface.
//
// Separate from the task tools for one reason worth stating: this is how a note
// gets in from somewhere else. Apple Notes has no export worth the name, but a
// model running on the machine can read Notes.app and write what it finds through
// these — one note at a time, with the text as Markdown. That is a migration
// nobody has to build a parser for.
func (s *Server) registerNoteTools(m *mcp.Server) {
m.Register(mcp.Tool{
Name: "search_notes",
Description: "Find notes by text. Matches title and body, and is generous about " +
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
"With no query it returns the most recent notes. This is the tool for " +
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("Words to look for in the title and body."),
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
}),
}, s.mcpSearchNotes)
m.Register(mcp.Tool{
Name: "create_note",
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
"also exactly what an export produces, so nothing is converted and nothing " +
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
"pinned": mcp.Bool("Keep it at the top of the list."),
}),
}, s.mcpCreateNote)
m.Register(mcp.Tool{
Name: "update_note",
Description: "Change a note. Only the fields given are changed. Sending `body` " +
"replaces the whole text, so read the note first if you mean to add to it — " +
"and the links are read again from the new text, so a #tag removed is a link " +
"removed.",
InputSchema: mcp.Schema(map[string]any{
"note_id": mcp.Str("Which note."),
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
"pinned": mcp.Bool("Pin it or unpin it."),
}),
}, s.mcpUpdateNote)
}
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Text string `json:"text"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(args, &params)
limit := params.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
var notes []store.Note
var err error
if strings.TrimSpace(params.Text) == "" {
notes, err = s.db.LooseNotes(ctx, userID)
} else {
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
}
if err != nil {
return nil, err
}
// The same filter the HTTP surface applies, for the same reason: a search must
// not become a way to read what you cannot open.
out := make([]map[string]any, 0, len(notes))
for _, n := range notes {
if !s.mcpMayRead(ctx, &n, userID) {
continue
}
out = append(out, map[string]any{
"id": n.ID, "title": n.Title, "body": n.Body,
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
})
if len(out) >= limit {
break
}
}
return map[string]any{"notes": out, "count": len(out)}, nil
}
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
if n.ProjectID == "" {
return n.CreatedBy == userID
}
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
return err == nil
}
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Body string `json:"body"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n := &store.Note{
CreatedBy: userID,
Body: params.Body,
Pinned: params.Pinned,
}
if params.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
n.ProjectID = params.ProjectID
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
// The links are reported back rather than left silent. A note whose #tag was
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
// the row that was written.
return map[string]any{
"id": n.ID, "title": n.Title, "links": n.Links,
}, nil
}
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
NoteID string `json:"note_id"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n, err := s.db.Note(ctx, params.NoteID)
if err != nil {
return nil, err
}
if n == nil || !s.mcpMayRead(ctx, n, userID) {
return nil, fmt.Errorf("no such note")
}
if n.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
}
if params.Body != nil {
n.Body = *params.Body
}
if params.Pinned != nil {
n.Pinned = *params.Pinned
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
}
+444 -8
View File
@@ -1,8 +1,17 @@
package httpapi
import (
"archive/zip"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
@@ -57,7 +66,10 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
return
}
found, err := s.db.LooseNotes(r.Context(), user.ID)
// Everything they can see, not only the loose ones. Filing a note in a project
// is how it gets shared, and a list that dropped it at that moment made sharing
// look like deleting.
found, err := s.db.AllNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list notes", err)
return
@@ -94,9 +106,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, n)
}
// No title. It is the note's first line and is derived on every save — a field
// here would be a promise this server cannot keep.
type noteBody struct {
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
@@ -116,9 +129,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
@@ -161,9 +171,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
@@ -210,3 +217,432 @@ func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
}
// handleExportNotes writes every note this person can see as a zip of Markdown
// files, one per note.
//
// A zip of plain files rather than one bundle in a format of our own: what comes
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
// That is the promise the whole design was arranged around — the note on disk is
// already the file you would export — and this is where it is either kept or not.
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
notes, err := s.db.AllNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "export notes", err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
// twice is a zip that loses one of them.
used := map[string]int{}
for _, n := range notes {
name := noteFilename(n.Title)
used[name]++
if used[name] > 1 {
name = fmt.Sprintf("%s (%d)", name, used[name])
}
f, err := zw.CreateHeader(&zip.FileHeader{
Name: name + ".md",
Method: zip.Deflate,
Modified: n.UpdatedAt,
})
if err != nil {
// The response is already streaming, so there is no status left to send.
// Stopping leaves a truncated zip, which a zip tool says is truncated —
// better than a complete-looking file with a note missing from it.
s.log.Error("export notes", "err", err, "note", n.ID)
return
}
if _, err := io.WriteString(f, n.Body); err != nil {
s.log.Error("export notes", "err", err, "note", n.ID)
return
}
}
}
// noteFilename makes a title safe to be a filename on any of the three systems
// people actually use, without turning it into something unrecognisable.
func noteFilename(title string) string {
name := strings.TrimSpace(title)
if name == "" {
name = "uden titel"
}
// Windows refuses these outright; a slash would make directories on the others.
name = strings.Map(func(r rune) rune {
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
return '-'
}
return r
}, name)
// Long titles are the first line of a note, which can be a whole sentence.
if len([]rune(name)) > 80 {
name = string([]rune(name)[:80])
}
return strings.TrimRight(name, " .")
}
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
// a few megabytes — and small enough that a wrong file is refused rather than read.
const maxNoteImportBytes = 64 << 20
// handleImportNotes reads a zip of Markdown files and makes a note of each.
//
// The mirror of the export, and deliberately the same shape: whatever comes out of
// this program goes back into it. It is also the way in from anywhere else, since
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
// produce — and what the Apple Notes exporter in tools/ writes.
//
// Filenames are ignored. The title is the first line of the body, everywhere else
// in this program, and making it the filename here would be a second rule for the
// same thing that disagrees the moment somebody renames a file.
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
if err := r.ParseMultipartForm(4 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
if err != nil {
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
return
}
user := userFrom(r.Context())
created, skipped, files := 0, 0, 0
// Two passes. Everything that is not Markdown is stored first, so that when a
// note is read its pictures already have addresses to be rewritten to — a note
// and its images arrive in whatever order the archive happens to hold them, and
// one pass would leave half the links pointing at nothing.
stored := map[string]string{}
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
hashed, size, err := s.storeUpload(rc)
rc.Close()
if err != nil {
s.log.Warn("import note file", "err", err, "file", name)
continue
}
stored[name] = hashed
_ = size
files++
}
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
continue
}
// Counted, not passed over in silence. A Markdown file the filter refuses is
// a note that did not arrive, and the caller is told the same number either
// way unless it is said here — which is how one note went missing without
// anything looking wrong.
if skipFile(name) {
s.log.Warn("import skipped a note file", "file", name)
skipped++
continue
}
rc, err := f.Open()
if err != nil {
skipped++
continue
}
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
rc.Close()
if err != nil || strings.TrimSpace(string(body)) == "" {
skipped++
continue
}
text, wrote, changed := readFrontMatter(string(body))
n := &store.Note{CreatedBy: user.ID, Body: text}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.log.Warn("import note", "err", err, "file", name)
skipped++
continue
}
// After the save, not as part of it: SaveNote owns `updated_at` and is right
// to, so the dates a note had before this program existed are written over
// the top rather than passed through it.
if !wrote.IsZero() || !changed.IsZero() {
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
s.log.Warn("import note dates", "err", err, "file", name)
}
}
// The pictures the note referred to, now that it has an id to hang them on.
// The text is rewritten to the addresses they were given, so an image that
// sat in the middle of a paragraph is still in the middle of it.
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
n.Body = rewritten
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
}
}
created++
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "skipped": skipped, "files": files,
})
}
// skipFile is the things in an archive that are not content: directories' own
// entries, macOS resource forks, and anything trying to climb out of the archive.
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
// would double a whole library with each copy full of binary rubbish.
func skipFile(name string) bool {
if strings.HasPrefix(name, "__MACOSX/") ||
strings.HasPrefix(path.Base(name), "._") ||
strings.HasPrefix(path.Base(name), ".") {
return true
}
// Traversal, read as path segments rather than as a substring.
//
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
// wrong thing: it does not describe a path that climbs out of the archive, it
// describes any name with two dots next to each other anywhere in it. A note
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
// silently, and without even counting as skipped, so the number the import
// reported still looked right. One note in twelve hundred, found only by
// counting what came out.
//
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
// starts at the root, is not — and those are what a zip uses to write outside
// the folder it was unpacked into.
if strings.HasPrefix(name, "/") {
return true
}
for _, part := range strings.Split(name, "/") {
if part == ".." {
return true
}
}
return false
}
// attachToNote records the files a note's text points at and rewrites the text to
// where they now live. Returns the new body and how many were attached.
//
// Only the files this note refers to. An archive is one folder for many notes, and
// hanging every picture on every note would turn a library of a hundred into ten
// thousand attachments.
func (s *Server) attachToNote(
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
) (string, int) {
body := n.Body
attached := 0
for _, f := range zr.File {
hashed, ok := stored[f.Name]
if !ok {
continue
}
base := path.Base(f.Name)
// The three ways a Markdown file names a neighbouring image: by its path
// inside the archive, by the path relative to the note, and by its bare name.
//
// The archive path was missing, and it is the one an Apple Notes export
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
// root beside the folder. Only the bare name matched, so only the bare name
// was replaced, and every picture in every imported note ended up pointing at
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
// stored and attached correctly; it was the link that was wrong.
relative := strings.TrimPrefix(path.Join(dir, base), "./")
refs := []string{f.Name, relative, base}
if !strings.Contains(body, f.Name) &&
!strings.Contains(body, relative) &&
!strings.Contains(body, base) {
continue
}
a := &store.Attachment{
NoteID: n.ID,
Filename: base,
MimeType: mimeOf(base),
Size: int64(f.UncompressedSize64),
Path: hashed,
UploadedBy: n.CreatedBy,
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
s.log.Warn("attach to note", "err", err, "file", base)
continue
}
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
// stående foran adressen, og linket peger på ingenting.
url := "/api/v1/attachments/" + a.ID
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
for _, ref := range refs {
if ref != "" {
body = strings.ReplaceAll(body, ref, url)
}
}
attached++
}
return body, attached
}
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
// the two dates out of it, returning the rest of the text.
//
// Not a YAML parser, and deliberately not: the block this reads is the one this
// program writes, plus whatever Obsidian and Bear write, and all of them use the
// same handful of `key: value` lines. Pulling in a parser to read two dates would
// be a dependency that can fail in ways nobody here would recognise.
//
// A file with no front matter comes back untouched — that is the normal case, and
// it must never lose its first line to a rule about a block that is not there.
func readFrontMatter(body string) (text string, created, modified time.Time) {
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
return body, time.Time{}, time.Time{}
}
rest := body[strings.Index(body, "\n")+1:]
end := strings.Index(rest, "\n---")
if end < 0 {
// An opening fence with no close is not front matter; it is a horizontal
// rule somebody wrote at the top of their note.
return body, time.Time{}, time.Time{}
}
block := rest[:end]
after := rest[end+len("\n---"):]
after = strings.TrimPrefix(after, "\r")
after = strings.TrimPrefix(after, "\n")
for _, line := range strings.Split(block, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
switch strings.ToLower(strings.TrimSpace(key)) {
case "created", "created_at", "date":
created = parseNoteTime(value)
case "modified", "updated", "updated_at":
modified = parseNoteTime(value)
}
}
return strings.TrimLeft(after, "\n"), created, modified
}
// parseNoteTime accepts the shapes these files actually carry. A date without a
// time is that day at midnight, local — the note was written on that day, and
// pretending to know the hour would be worse than not having one.
func parseNoteTime(v string) time.Time {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
} {
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return t
}
}
return time.Time{}
}
// mimeOf guesses from the extension. The archive does not carry a type, and
// sniffing the bytes would mean reading every file twice.
func mimeOf(name string) string {
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
return t
}
return "application/octet-stream"
}
// handleUploadNoteAttachment takes a file into a note.
//
// The import could already do this, and nothing else could: a note brought in
// from Apple Notes arrived with its pictures, and a note written here could not
// be given one. Pasting a screenshot into a note is the most ordinary thing there
// is, and it did nothing at all.
//
// Editor rights on the note, checked the same way every other write to it is. The
// answer for "no such note" and "not yours" is the same, so an id cannot be
// probed by watching which one comes back.
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, r, "store upload", err)
return
}
user := userFrom(r.Context())
a := &store.Attachment{
NoteID: n.ID,
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, r, "record attachment", err)
return
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
@@ -0,0 +1,38 @@
package httpapi
import "testing"
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
//
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
// tælle efter, hvor mange der kom ud.
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
keep := []string{
"Så blev det endelig jul! Stay tuned... ☕️.md",
"Møde 12.8..md",
"vedhaeftninger/1-2-Pasted Graphic.png",
"noter/Referat....md",
}
for _, name := range keep {
if skipFile(name) {
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
}
}
drop := []string{
"../uden for.md",
"noter/../../etc/passwd",
"/etc/passwd",
"__MACOSX/._noget",
".skjult.md",
"noter/._AppleDouble.md",
}
for _, name := range drop {
if !skipFile(name) {
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
}
}
}
+251
View File
@@ -1,7 +1,13 @@
package httpapi
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
)
@@ -113,3 +119,248 @@ func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
t.Errorf("%d notes still point at Firma", got)
}
}
// The export is the promise the whole design was arranged around: the note on
// disk is already the file you would export, so nothing is converted on the way
// out and nothing can be lost in the conversion.
func TestNotesExportAsMarkdownFiles(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
bodies := []string{
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
"Slash/i/titlen\n\nog noget tekst",
"# Møde med Anders\n\nen anden note med samme titel",
}
for _, body := range bodies {
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d", resp.StatusCode)
}
}
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
t.Errorf("content type is %q", ct)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatalf("the archive does not open: %v", err)
}
if len(zr.File) != 3 {
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
}
byName := map[string]string{}
for _, f := range zr.File {
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
text, _ := io.ReadAll(r)
r.Close()
byName[f.Name] = string(text)
}
// Exactly what was stored, byte for byte. A converted export is an export that
// can be wrong.
if got := byName["Møde med Anders.md"]; got != bodies[0] {
t.Errorf("the note came out as %q", got)
}
// A slash in a title would make a directory, and on Windows it is refused
// outright.
if _, ok := byName["Slash-i-titlen.md"]; !ok {
t.Errorf("the awkward title became %v", keysOf(byName))
}
// Two notes with the same title are two notes; one entry twice is one lost.
if _, ok := byName["Møde med Anders (2).md"]; !ok {
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
}
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// Out and back in again. If an export cannot be imported, it is not an export —
// it is a file somebody hopes will be readable later.
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
files := map[string]string{
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
"noget andet.md": "Løse tanker\n\nog en linje til",
// A zip made on a Mac carries these beside every file. Importing them would
// double the whole library, with each copy full of binary rubbish.
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
"billede.png": "ikke en note",
"tom.md": " \n ",
}
for name, body := range files {
f, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(f, body); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var form bytes.Buffer
mw := multipart.NewWriter(&form)
part, _ := mw.CreateFormFile("file", "noter.zip")
part.Write(archive.Bytes())
mw.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("import: status %d", resp.StatusCode)
}
var out struct{ Created, Skipped int }
json.NewDecoder(resp.Body).Decode(&out)
if out.Created != 2 {
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
}
// The title came from the first line, not from the filename — which is the same
// rule as everywhere else, and the reason renaming a file cannot rename a note.
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
titles := map[string]bool{}
for _, raw := range listed["notes"].([]any) {
titles[raw.(map[string]any)["title"].(string)] = true
}
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
t.Errorf("the titles came out as %v", titles)
}
// And the #tag was read on the way in, so an imported note lands on its project
// exactly as a typed one does.
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if len(linked["notes"].([]any)) != 1 {
t.Errorf("the imported note did not link to its project")
}
}
// A note from Apple Notes is full of photographs. One that arrives as words alone
// is not that note — it is a summary of it.
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
write := func(name, body string) {
f, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(f, body); err != nil {
t.Fatal(err)
}
}
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n![](vedhaeftninger/bon.png)\n\nog resten af teksten")
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
// Another note in the same archive, which must not inherit the first one's
// picture: an archive is one folder for many notes.
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var form bytes.Buffer
mw := multipart.NewWriter(&form)
part, _ := mw.CreateFormFile("file", "noter.zip")
part.Write(archive.Bytes())
mw.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct{ Created, Skipped, Files int }
json.NewDecoder(resp.Body).Decode(&out)
if out.Created != 2 || out.Files != 1 {
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
}
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
notes := listed["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("found %d notes", len(notes))
}
body := notes[0].(map[string]any)["body"].(string)
// The link points at the file where it now lives, not at a path inside an
// archive nobody kept.
if strings.Contains(body, "vedhaeftninger/bon.png") {
t.Errorf("the link still points into the archive: %q", body)
}
if !strings.Contains(body, "/api/v1/attachments/") {
t.Errorf("the picture was not attached: %q", body)
}
// And it is still where it was written, in the middle of the note.
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
t.Errorf("the note was rearranged: %q", body)
}
// The picture is really there and really readable.
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
id = strings.SplitN(id, ")", 2)[0]
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
if got.StatusCode != http.StatusOK {
t.Errorf("the attachment answers %d", got.StatusCode)
}
// The other note did not inherit it.
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
for _, raw := range other["notes"].([]any) {
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
t.Error("a note that mentioned no picture was given one")
}
}
}
+3 -3
View File
@@ -102,7 +102,7 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
// Stands in for a run: takes the lock, notes how many are inside, and holds it
// until told to let go.
run := func(done chan<- struct{}) {
unlock := srv.lockMailbox("box-1")
unlock := srv.lockSync("box-1")
mu.Lock()
inside++
if inside > most {
@@ -136,10 +136,10 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
// And two different mailboxes must not wait for each other: one slow host is
// not everybody's problem.
a := srv.lockMailbox("box-a")
a := srv.lockSync("box-a")
done := make(chan struct{})
go func() {
srv.lockMailbox("box-b")()
srv.lockSync("box-b")()
close(done)
}()
select {
+44
View File
@@ -133,6 +133,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
// The calendar comes back on a URI of its own rather than sharing Gmail's.
// One callback deciding from stored state which of the two flows had
// returned would put the choice of *which token store to write* behind a
// lookup — and when that goes wrong it goes wrong by overwriting a working
// Gmail connection with calendar tokens.
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
})
r.Route("/caldav", s.caldavRoutes)
@@ -143,6 +149,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// The beacon's receiving end, and the number it produces. Both without a
// session, because the caller is a stranger's installation — that is the
// whole feature. Rate limited like the login routes: it is the one endpoint
// anybody on the internet can write to, and a row per request is a row per
// request whether or not the request is honest.
//
// Refused with a 404 unless this instance has been told it is the collector,
// so an ordinary installation is not a spam sink that answers 200 to
// anything.
r.Method(http.MethodPost, "/beacon",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
r.Get("/beacon/count", s.handleBeaconCount)
// Reachable without being signed in — these are how you become signed in.
r.Route("/auth", func(r chi.Router) {
r.Get("/setup", s.handleSetupState)
@@ -178,6 +197,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -282,10 +302,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Route("/notes", func(r chi.Router) {
r.Get("/", s.handleListNotes)
r.Post("/", s.handleCreateNote)
r.Post("/import", s.handleImportNotes)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
// A picture pasted or dropped into a note. The import could bring
// files in and nothing else could, which made the most ordinary act
// in a notes app — paste a screenshot — do nothing at all.
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
})
r.Route("/mailboxes", func(r chi.Router) {
@@ -314,12 +339,26 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
})
// A calendar read from somewhere else, laid under verdande's own dated
// tasks. Read-only, so there is no write here to gate: the events are a
// copy of somebody else's calendar, and the only thing a person decides
// is which of them to look at.
r.Route("/calendar", func(r chi.Router) {
r.Get("/", s.handleGetCalendar)
r.Put("/calendars", s.handleSetCalendars)
r.Delete("/", s.handleDisconnectCalendar)
r.Post("/authorize", s.handleCalendarAuthorize)
r.Post("/sync", s.handleCalendarSyncNow)
r.Get("/events", s.handleCalendarEvents)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
@@ -387,6 +426,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// `:latest` on the way, which is what makes a restart an update.
r.Get("/panel", s.handlePanelStatus)
r.Post("/panel/restart", s.handleRestartFromPanel)
// Who this instance reports to, and whether it does. An
// administrator's decision, not a person's: there is one
// installation and it either phones home or it does not.
r.Get("/beacon/settings", s.handleBeaconStatus)
r.Put("/beacon/settings", s.handleSetBeacon)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
+58
View File
@@ -310,3 +310,61 @@ func cut(text string) string {
}
return text
}
// Unflag takes the flag off the messages that have become tasks.
//
// This is what stops the same mail arriving twice, and it replaces a marker that
// could not be made to hold. The marker was a uid: remember the highest one seen,
// start above it next time. That is correct only if uids are what the server says
// they are, and it broke in every way such a scheme can — a fetch reply without a
// uid, a UIDVALIDITY that changed under a rebuilt mailbox, two mailbox rows
// pointing at the same folder and each keeping its own marker. Every one of those
// produced the same symptom: the same mail became a task again, and again.
//
// Unflagging moves the record out of our database and into the mailbox, where the
// question is asked. "Flagged" means "turn this into a task"; once it is a task,
// the flag has done its job and is not true any more. Nothing has to be
// remembered, so there is nothing to get out of step — a second sync, a second
// mailbox row, or a database restored from a backup all find the same answer,
// because the answer is in the mailbox.
//
// It is destructive in the small way that matters: it changes something in
// somebody's mail. That is the trade, and it is the right way round — a flag is a
// note to self that this needs doing, and it has been done.
//
// The folder is re-selected writable. Since() opens it read-only on purpose, and
// a search should not need write access just because a later step might.
func (c *Client) Unflag(uids []uint32) error {
if len(uids) == 0 {
return nil
}
if _, err := c.c.Select(c.folder, nil).Wait(); err != nil {
return fmt.Errorf("open %s for writing: %w", c.folder, err)
}
set := imap.UIDSetNum()
for _, u := range uids {
if u == 0 {
// No uid means nothing to address. The message keeps its flag and will
// be seen again — which is the failure this whole function exists to
// prevent, so it is worth saying rather than skipping quietly.
continue
}
set.AddNum(imap.UID(u))
}
if len(set) == 0 {
return nil
}
// Silent: the server has nothing to tell us that we would act on, and the
// untagged replies would only have to be collected and thrown away.
cmd := c.c.Store(set, &imap.StoreFlags{
Op: imap.StoreFlagsDel,
Silent: true,
Flags: []imap.Flag{imap.FlagFlagged},
}, nil)
if err := cmd.Close(); err != nil {
return fmt.Errorf("unflag in %s: %w", c.folder, err)
}
return nil
}
+78
View File
@@ -316,3 +316,81 @@ func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
}
}
}
// Den samme mail må ikke kunne blive en opgave to gange.
//
// Prøven kører hele vejen imod en rigtig server: den finder de flagede mails,
// tager flaget af dem, og spørger så forfra som en ny synkronisering ville — med
// markøren sat til nul, altså det værst tænkelige tilfælde, hvor alt, hvad vi
// selv havde husket, er væk. Der skal ikke komme noget tilbage.
//
// Det er det, markøren ikke kunne love. Den holdt kun, hvis uids var, hvad
// serveren sagde, og fejlede på et fetch-svar uden uid, på en UIDVALIDITY, der
// skiftede, og på to postkasse-rækker, der pegede samme sted og hver især førte
// deres eget regnskab. Alle tre gav det samme: mailen blev en opgave igen.
func TestAnUnflaggedMailDoesNotComeBack(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "Faktura 4711", from: "anders@example.dk", body: "Vedhæftet.", flagged: true},
{subject: "Nyhedsbrev", from: "noreply@example.com", body: "Læs mere", flagged: false},
{subject: "Kontrakt", from: "jura@example.dk", body: "Til underskrift", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("fandt %d flagede mails, ventede 2", len(msgs))
}
uids := make([]uint32, 0, len(msgs))
for _, m := range msgs {
uids = append(uids, m.UID)
}
if err := client.Unflag(uids); err != nil {
t.Fatalf("Unflag: %v", err)
}
// Forfra, og fra nul: selv en instans, der intet husker, må ikke finde dem igen.
again, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("%d mails kom igen efter at flaget var taget af — den første af dem er %q",
len(again), again[0].Subject)
}
}
// Et tomt kald må ikke åbne mappen skrivbar for ingenting.
func TestUnflagWithNothingToDoIsQuiet(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "Faktura", from: "a@example.dk", body: "x", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if err := client.Unflag(nil); err != nil {
t.Errorf("Unflag(nil) = %v", err)
}
// Kun nuller: der er intet at adressere, og mailen skal beholde sit flag.
if err := client.Unflag([]uint32{0, 0}); err != nil {
t.Errorf("Unflag(nuller) = %v", err)
}
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Errorf("mailen mistede sit flag, uden at nogen havde adresseret den")
}
}
+69
View File
@@ -41,10 +41,19 @@ type Runner struct {
// reason as SyncGmail: it owns turning a message into a task.
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
// SyncCalendars refreshes one person's connected calendars. Supplied the same
// way and for the same reason: the HTTP layer owns the token refresh.
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
// Push delivers a notification to somebody's devices. Supplied by the HTTP
// layer, which owns the VAPID keys and the subscription list.
Push func(userID, title, body, projectID string)
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
SendBeacon func(ctx context.Context) error
wg sync.WaitGroup
}
@@ -69,6 +78,17 @@ func (r *Runner) Start(ctx context.Context) {
// account for a to-do app. Ten minutes is fast enough for something somebody
// starred and slow enough not to look like abuse.
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
// becomes a task, so being a quarter of an hour behind on a meeting next
// Tuesday costs nothing — and the "fetch now" button covers the one case where
// it matters, which is somebody who has just moved something and is looking at
// the grid to check. A person who has just connected does not wait for this
// either; connecting fetches the list, and the first sync is one click away.
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
// Checked hourly, sent at most daily — the same shape as the backup, and for
// the same reason: a container asleep at the scheduled minute would otherwise
// skip the day. SendBeacon decides whether a day has passed.
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -103,6 +123,17 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
// sendBeacon reports that this installation exists, at most once a day.
//
// Every decision about what is sent, and why this is on by default rather than
// compulsory, lives in internal/beacon. This is only the clock.
func (r *Runner) sendBeacon(ctx context.Context) error {
if r.SendBeacon == nil {
return nil
}
return r.SendBeacon(ctx)
}
// --- reminders ------------------------------------------------------------------
// sendDueReminders delivers everything that has come due since the last pass.
@@ -322,6 +353,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
return nil
}
// --- calendars ------------------------------------------------------------------------
// syncCalendars refreshes everybody's connected calendars.
//
// Its own sweep rather than a branch inside the mailbox one. They share nothing
// past the word "poll": that loop asks each mailbox how far it has read and makes
// tasks, this one replaces a window of somebody else's events. Folding them
// together would mean one interval for two questions that do not want the same
// answer, and a Google that is down would count against a mailbox that is not.
//
// Nobody is waiting, so no budget of its own — the request handler has one because
// a person is watching a spinner. One account that has gone quiet costs this run
// its own timeout and nothing else: the loop moves on per person.
func (r *Runner) syncCalendars(ctx context.Context) error {
if r.SyncCalendars == nil {
return nil
}
userIDs, err := r.db.UsersWithCalendars(ctx)
if err != nil {
return err
}
for _, id := range userIDs {
user, err := r.db.UserByID(ctx, id)
if err != nil || user == nil {
continue
}
written, err := r.SyncCalendars(ctx, user)
if err != nil {
r.log.Warn("calendar sync", "err", err, "user", id)
continue
}
if written > 0 {
r.log.Info("calendar refreshed", "user", id, "events", written)
}
}
return nil
}
// --- housekeeping -----------------------------------------------------------------
func (r *Runner) emptyTrash(ctx context.Context) error {
+106
View File
@@ -0,0 +1,106 @@
// Package safedial refuses to open a connection to this machine or to the
// network it sits on.
//
// The problem it solves is the same one three times: somewhere a user supplies a
// URL or a host, and the server fetches it. An AI provider's base address, a push
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
// of them is also a way to ask the server to reach something the caller cannot —
// the panel next door, a metadata endpoint, a database on the same bridge — and
// to report back whether it answered. On a homelab that is the whole point of the
// box being where it is.
//
// # Why the check is on the address and not on the URL
//
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
// and it does not work: a name resolves to whatever its owner says, so
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
// time, after a parse-time check has passed. That is DNS rebinding, and the only
// place it can be caught is after resolution and before the connection, which is
// exactly where net.Dialer's Control hook runs.
//
// # What is refused
//
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
// lives), the private ranges, unique-local IPv6, and the unspecified address.
// Everything else is allowed: this is not an allowlist of the internet, it is a
// wall around the inside.
package safedial
import (
"fmt"
"net"
"net/http"
"syscall"
"time"
)
// ErrPrivate is returned when a connection was refused for where it pointed. It
// deliberately does not say which address it resolved to: the answer is itself
// the thing an attacker is fishing for.
type ErrPrivate struct{ Host string }
func (e *ErrPrivate) Error() string {
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
}
// Blocked reports whether an address must not be dialled.
func Blocked(ip net.IP) bool {
return ip == nil ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsPrivate()
}
// Control is a net.Dialer Control function that refuses a private destination.
//
// It runs after the name has been resolved and before the socket is connected,
// which is the only moment where the address that will actually be used is known.
func Control(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if Blocked(ip) {
return &ErrPrivate{Host: host}
}
return nil
}
// Dialer is a net.Dialer that will not reach inside.
func Dialer(timeout time.Duration) *net.Dialer {
return &net.Dialer{Timeout: timeout, Control: Control}
}
// Client is an http.Client for fetching something a user named.
//
// Redirects are followed but capped, and every hop goes through the same dialer —
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
// URL somebody typed.
func Client(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
DialContext: Dialer(timeout).DialContext,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
}
// AllowPrivate is the same client without the wall, for a destination the
// operator configured rather than a user — the panel, and nothing else. Named so
// that using it is a decision somebody wrote down.
func AllowPrivate(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
}
+55
View File
@@ -0,0 +1,55 @@
package safedial
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestBlockedCoversTheInside(t *testing.T) {
blocked := []string{
"127.0.0.1", "::1", "0.0.0.0",
// Skyens metadata-endepunkt. Den ene adresse, enhver SSRF sigter efter.
"169.254.169.254",
"10.0.0.5", "172.16.0.1", "192.168.1.10", "fd00::1", "fe80::1",
}
for _, s := range blocked {
if !Blocked(net.ParseIP(s)) {
t.Errorf("Blocked(%s) = false", s)
}
}
for _, s := range []string{"93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946"} {
if Blocked(net.ParseIP(s)) {
t.Errorf("Blocked(%s) = true, men det er en almindelig adresse", s)
}
}
}
// En httptest-server lytter på loopback, hvilket gør den til den perfekte prøve:
// den findes, den svarer, og den må ikke kunne nås.
func TestClientRefusesLoopback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("forbindelsen blev oprettet; den skulle være afvist")
}))
defer srv.Close()
_, err := Client(3 * time.Second).Get(srv.URL)
if err == nil {
t.Fatal("en forespørgsel til loopback skal afvises")
}
}
func TestAllowPrivateStillReaches(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
resp, err := AllowPrivate(3 * time.Second).Get(srv.URL)
if err != nil {
t.Fatalf("den skal kunne nå det, operatøren selv har peget på: %v", err)
}
resp.Body.Close()
}
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"context"
"time"
)
// The collector's side of the beacon: what has reported in, and how many.
//
// Every method here is about counting installations, never about identifying one.
// There is no lookup by anything but the id an installation made up for itself,
// and there is nothing to join it against.
// BeaconStats is the whole of what the collector can say.
type BeaconStats struct {
Total int `json:"total"`
Active7d int `json:"active_7d"`
Active30d int `json:"active_30d"`
ByVersion map[string]int `json:"by_version"`
LastPingAt *time.Time `json:"last_ping_at,omitempty"`
}
// maxBeaconInstalls bounds the table.
//
// The receiving endpoint is unauthenticated — it has to be, since the caller is a
// stranger's installation — and it inserts a row keyed on an id the caller made
// up. Anybody can therefore mint ids and grow this table until the volume the
// database lives on is full. A ceiling turns that from a disk-fill into a number
// that is merely wrong, which is the difference between an outage and an
// annoyance. A hundred thousand is far above any plausible real count and far
// below anything that matters on disk.
const maxBeaconInstalls = 100_000
// RecordBeacon writes down that an installation is alive, and on which version.
//
// `first_seen` is kept from the original row on purpose: it is the only thing
// that distinguishes an installation that has been running for a year from one
// that appeared this morning, and it costs nothing to keep.
func (db *DB) RecordBeacon(ctx context.Context, instanceID, version string) error {
now := time.Now().Unix()
// An installation already known keeps reporting whatever the ceiling says: the
// cap must never stop counting the real ones, only stop new rows from being
// invented without limit.
var known int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM beacon_installs WHERE instance_id = ?`, instanceID).Scan(&known); err != nil {
return err
}
if known == 0 {
var total int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM beacon_installs`).Scan(&total); err != nil {
return err
}
if total >= maxBeaconInstalls {
// Not an error to the caller. Their installation did nothing wrong, and
// there is nothing for them to do about it.
return nil
}
}
_, err := db.ExecContext(ctx, `
INSERT INTO beacon_installs (instance_id, version, first_seen, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
version = excluded.version,
last_seen = excluded.last_seen`,
instanceID, version, now, now)
return err
}
// BeaconCount is how many installations have reported within the window.
//
// A window rather than a running total, and that is not a detail. A total only
// ever climbs: every instance anybody ever started for ten minutes stays in it
// forever, so the number stops meaning "how many people use this" and starts
// meaning "how many people have ever tried it". The question being asked is the
// first one.
func (db *DB) BeaconCount(ctx context.Context, within time.Duration) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM beacon_installs WHERE last_seen >= ?`,
time.Now().Add(-within).Unix()).Scan(&n)
return n, err
}
// BeaconSummary is everything the settings page shows.
func (db *DB) BeaconSummary(ctx context.Context) (BeaconStats, error) {
out := BeaconStats{ByVersion: map[string]int{}}
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM beacon_installs`).Scan(&out.Total); err != nil {
return out, err
}
var err error
if out.Active7d, err = db.BeaconCount(ctx, 7*24*time.Hour); err != nil {
return out, err
}
if out.Active30d, err = db.BeaconCount(ctx, 30*24*time.Hour); err != nil {
return out, err
}
// Versions of the ones still alive, not of everything ever seen: an instance
// that stopped reporting two years ago should not still be counted as running
// the version it was on when it stopped.
rows, err := db.QueryContext(ctx, `
SELECT version, count(*) FROM beacon_installs
WHERE last_seen >= ? AND version <> ''
GROUP BY version`, time.Now().Add(-30*24*time.Hour).Unix())
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var v string
var n int
if err := rows.Scan(&v, &n); err != nil {
return out, err
}
out.ByVersion[v] = n
}
if err := rows.Err(); err != nil {
return out, err
}
var last *int64
if err := db.QueryRowContext(ctx,
`SELECT max(last_seen) FROM beacon_installs`).Scan(&last); err != nil {
return out, err
}
if last != nil {
t := time.Unix(*last, 0)
out.LastPingAt = &t
}
return out, nil
}
+380
View File
@@ -0,0 +1,380 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// CalendarAccount is one connected account belonging to one person.
type CalendarAccount struct {
ID string `json:"id"`
UserID string `json:"-"`
Provider string `json:"provider"`
Account string `json:"account,omitempty"`
RefreshToken string `json:"-"` // never leaves the server
AccessToken string `json:"-"`
ExpiresAt time.Time `json:"-"`
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Calendar is one calendar inside an account, shown or not.
type Calendar struct {
ID string `json:"id"`
AccountID string `json:"-"`
RemoteID string `json:"remote_id"`
Name string `json:"name"`
Colour string `json:"colour,omitempty"`
TimeZone string `json:"time_zone,omitempty"`
Primary bool `json:"primary"`
// Writable says the connected account could write to it. Nothing does — the
// scope is read-only — and it is stored so the day something can, the interface
// knows which calendars could even be offered.
Writable bool `json:"writable"`
Shown bool `json:"shown"`
SortOrder int `json:"-"`
}
// CalendarEvent is one occurrence, in the shape a grid asks about: days at both
// ends, inclusive.
type CalendarEvent struct {
ID string `json:"id"`
CalendarID string `json:"calendar_id"`
RemoteID string `json:"-"`
Summary string `json:"summary"`
StartsAt string `json:"starts_at,omitempty"`
EndsAt string `json:"ends_at,omitempty"`
StartDay string `json:"start_day"`
EndDay string `json:"end_day"`
AllDay bool `json:"all_day"`
Location string `json:"location,omitempty"`
URL string `json:"url,omitempty"`
// CalendarName and Colour ride along with the event rather than being looked up
// per row by the caller. Same reasoning as the assignee names on a task list:
// the chip has to be drawn in the calendar's own colour, and a second request
// per event to learn it is a request per event.
CalendarName string `json:"calendar_name,omitempty"`
Colour string `json:"colour,omitempty"`
}
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
access_token, expires_at, last_sync_at, created_at`
// CalendarAccountFor returns a person's connection to one provider, or nil.
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
a, err := db.scanCalendarAccount(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
// SaveCalendarAccount writes one, sealing the two columns that must not travel
// with a backup. Called by hand rather than by the settings tables' own seal,
// which does not reach this table.
func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error {
if a.ID == "" {
a.ID = NewID()
}
if a.Provider == "" {
a.Provider = "google"
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now()
}
refresh, err := db.sealValue(a.RefreshToken)
if err != nil {
return fmt.Errorf("seal refresh_token: %w", err)
}
access, err := db.sealValue(a.AccessToken)
if err != nil {
return fmt.Errorf("seal access_token: %w", err)
}
// The conflict is on the pair, not on the id: a reconnect builds a fresh
// struct with no id in it, and matching on the id would insert a second row
// that the unique index then refuses — which reads to the person as "connecting
// failed" when what happened is that it already had.
_, err = db.ExecContext(ctx, `
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, provider) DO UPDATE SET
account = excluded.account,
refresh_token = excluded.refresh_token,
access_token = excluded.access_token,
expires_at = excluded.expires_at,
last_sync_at = excluded.last_sync_at`,
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
if err != nil {
return err
}
// The row that is actually there wins. On a reconnect the insert above updated
// an existing row, so the id in hand is one nothing points at — and the
// calendars are hung off the id, so writing them against it would attach them
// to nothing.
return db.QueryRowContext(ctx,
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
a.UserID, a.Provider).Scan(&a.ID)
}
// MarkCalendarSynced records how far a run got. Written separately from the rest
// so a sync never rewrites the credentials it is holding in memory.
func (db *DB) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error {
_, err := db.ExecContext(ctx,
`UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID)
return err
}
// DeleteCalendarAccount disconnects one. The calendars and their events go with it
// — unlike the tasks a mailbox made, none of this is the person's own work; it is
// a copy of somebody else's calendar and it has no meaning without the account.
func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error {
_, err := db.ExecContext(ctx,
`DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// UsersWithCalendars returns everybody the sweep has to visit.
func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// Calendars returns what one account holds, in the order the provider listed them.
func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, account_id, remote_id, name, colour, time_zone, primary_one,
writable, shown, sort_order
FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour,
&c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ReplaceCalendars writes the list the provider just gave, keeping what the person
// chose.
//
// The choice survives because it is not in the list: Google says which calendars
// exist and what they are called, and only verdande knows which of them somebody
// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar
// on does not come undone the next time the sweep runs.
//
// A calendar that has disappeared from the account is removed, and its events go
// with it — a calendar somebody was unshared from should stop being drawn, not
// stay behind as a frozen copy of what it held on the last good day.
func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
keep := map[string]bool{}
for i, c := range list {
keep[c.RemoteID] = true
// The insert cannot carry `shown`: on conflict it would overwrite the
// choice with the default. A new calendar starts hidden — an account
// with a dozen shared calendars in it should not fill somebody's grid
// the moment they connect it.
if _, err := tx.ExecContext(ctx, `
INSERT INTO calendars
(id, account_id, remote_id, name, colour, time_zone, primary_one,
writable, shown, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (account_id, remote_id) DO UPDATE SET
name = excluded.name,
colour = excluded.colour,
time_zone = excluded.time_zone,
primary_one = excluded.primary_one,
writable = excluded.writable,
sort_order = excluded.sort_order`,
NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone,
c.Primary, c.Writable, c.Shown, i); err != nil {
return err
}
}
rows, err := tx.QueryContext(ctx,
`SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID)
if err != nil {
return err
}
var gone []string
for rows.Next() {
var id, remote string
if err := rows.Scan(&id, &remote); err != nil {
rows.Close()
return err
}
if !keep[remote] {
gone = append(gone, id)
}
}
// Closed before the next statement runs on this transaction. An open result
// set with a query behind it is the deadlock the pool comment in store.go
// exists to make slow rather than fatal.
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, id := range gone {
if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil {
return err
}
}
return nil
})
}
// ShowCalendars sets which of an account's calendars are drawn.
//
// The whole set in one write rather than one flip at a time, for the reason the
// projects' order is written the same way: a person has a handful of calendars, the
// question is "these ones", and a list that can only be built by several requests
// can land half applied.
func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil {
return err
}
for _, id := range shown {
// Scoped in the SQL rather than checked first: an id read in one step
// and written in the next is a window, and the account is the whole of
// the permission here.
if _, err := tx.ExecContext(ctx,
`UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`,
id, accountID); err != nil {
return err
}
}
// Events of a calendar nobody is looking at are dead weight in a table the
// grid queries. They come back with the next sync if it is turned on again.
_, err := tx.ExecContext(ctx, `
DELETE FROM calendar_events WHERE calendar_id IN
(SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID)
return err
})
}
// ReplaceCalendarEvents swaps one calendar's events inside a window.
//
// Replaced rather than merged, and no sync token. Google offers incremental sync,
// and it would save bytes verdande is not short of while costing the one thing it
// cannot afford: a token that has to be right, forever, for the grid not to be
// quietly wrong. A window that is deleted and rewritten cannot drift — an event
// somebody moved out of the window disappears because it is not in the answer, not
// because verdande remembered to delete it.
//
// Bounded by the window at both ends, so a sync of September does not throw away
// August.
func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM calendar_events
WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`,
calendarID, from, to); err != nil {
return err
}
for _, e := range events {
if _, err := tx.ExecContext(ctx, `
INSERT INTO calendar_events
(id, calendar_id, remote_id, summary, starts_at, ends_at,
start_day, end_day, all_day, location, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt,
e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil {
return err
}
}
return nil
})
}
// CalendarEvents returns everything one person can see between two days,
// inclusive at both ends.
//
// Scoped to the caller in the join rather than checked afterwards: the events of
// somebody else's calendar are somebody else's, and a filter applied after the read
// is a filter that can be forgotten.
func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) {
rows, err := db.QueryContext(ctx, `
SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at,
e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour
FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
JOIN calendar_accounts a ON a.id = c.account_id
WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ?
ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CalendarEvent
for rows.Next() {
var e CalendarEvent
if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt,
&e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL,
&e.CalendarName, &e.Colour); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
var a CalendarAccount
var expires, lastSync, created int64
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
&a.AccessToken, &expires, &lastSync, &created)
if err != nil {
return a, err
}
for _, field := range []*string{&a.RefreshToken, &a.AccessToken} {
plain, err := db.unsealValue(*field)
if err != nil {
return a, err
}
*field = plain
}
a.ExpiresAt = timeOrZero(expires)
a.LastSyncAt = timeOrZero(lastSync)
a.CreatedAt = time.Unix(created, 0)
return a, nil
}
+358
View File
@@ -0,0 +1,358 @@
package store
import (
"context"
"strings"
"testing"
"time"
)
func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount {
t.Helper()
a := &CalendarAccount{
UserID: userID, Provider: "google", Account: "kw@nolimit.dk",
RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access",
ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.SaveCalendarAccount(context.Background(), a); err != nil {
t.Fatal(err)
}
return a
}
// The same claim the mailbox tokens have to meet: what is written must not be
// readable in the file a backup copies.
func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
var refresh, access string
if err := db.QueryRowContext(ctx,
`SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`,
a.ID).Scan(&refresh, &access); err != nil {
t.Fatal(err)
}
if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") {
t.Fatalf("a token is in the row as written: %q %q", refresh, access)
}
// The address stays readable, which is the reason for sealing named columns
// rather than the whole row.
var account string
if err := db.QueryRowContext(ctx,
`SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil {
t.Fatal(err)
}
if account != "kw@nolimit.dk" {
t.Errorf("the address was sealed too: %q", account)
}
back, err := db.CalendarAccountFor(ctx, userID, "google")
if err != nil {
t.Fatal(err)
}
if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken {
t.Errorf("the tokens did not survive the round trip: %+v", back)
}
}
// Connecting again must replace what was there, not fail. It is the same OAuth
// flow run a second time, and the person's word for it is "connect", not "add".
func TestConnectingAgainReplacesTheAccount(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
first := connectedAccount(t, db, userID)
second := &CalendarAccount{
UserID: userID, Provider: "google", Account: "kw@nolimit.dk",
RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh",
}
if err := db.SaveCalendarAccount(ctx, second); err != nil {
t.Fatalf("connecting a second time failed: %v", err)
}
// And the id in hand is the row that is actually there. The calendars hang off
// it, so an id nothing points at would attach them to nothing.
if second.ID != first.ID {
t.Errorf("the second connect came back with id %q, want the existing %q",
second.ID, first.ID)
}
back, err := db.CalendarAccountFor(ctx, userID, "google")
if err != nil {
t.Fatal(err)
}
if back.RefreshToken != "1//0-fresh" {
t.Errorf("the token came back as %q", back.RefreshToken)
}
}
// Google says which calendars exist; only verdande knows which ones somebody wants
// to look at. A refresh that came back with the same list must not undo the choice.
func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
list := []Calendar{
{RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true},
{RemoteID: "fam", Name: "Familien", Colour: "#0b8043"},
{RemoteID: "old", Name: "Et delt"},
}
if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil {
t.Fatal(err)
}
// Nothing is shown to begin with: an account with a dozen shared calendars in
// it should not fill somebody's grid the moment they connect it.
stored, err := db.Calendars(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if len(stored) != 3 {
t.Fatalf("got %d calendars, want 3", len(stored))
}
for _, c := range stored {
if c.Shown {
t.Errorf("%q was shown without anybody asking for it", c.Name)
}
}
var famID string
for _, c := range stored {
if c.RemoteID == "fam" {
famID = c.ID
}
}
if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil {
t.Fatal(err)
}
// The sweep runs again, and one calendar has been renamed and one has gone.
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{
{RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true},
{RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"},
}); err != nil {
t.Fatal(err)
}
stored, err = db.Calendars(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if len(stored) != 2 {
t.Fatalf("got %d calendars; one that has left the account should stop being drawn",
len(stored))
}
for _, c := range stored {
switch c.RemoteID {
case "fam":
if !c.Shown {
t.Error("the choice was undone by a refresh of the list")
}
if c.Name != "Familien og hunden" {
t.Errorf("the new name did not arrive: %q", c.Name)
}
if c.ID != famID {
t.Error("the row was rebuilt rather than updated; its id is what was chosen")
}
case "primary":
if c.Shown {
t.Error("a calendar nobody chose became shown")
}
}
}
}
// A window is replaced, not merged. An event moved out of it has to disappear
// because it is absent from the answer — and the months on either side must not go
// with it.
func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil {
t.Fatal(err)
}
stored, _ := db.Calendars(ctx, a.ID)
cal := stored[0]
if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil {
t.Fatal(err)
}
july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"}
august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"}
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31",
[]CalendarEvent{july}); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31",
[]CalendarEvent{august}); err != nil {
t.Fatal(err)
}
got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d events, want both months: %+v", len(got), got)
}
// August is synced again and the meeting has been cancelled. July stays.
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil {
t.Fatal(err)
}
got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Summary != "Juli" {
t.Fatalf("got %+v; syncing August threw away July", got)
}
}
// The grid asks "which cells does this cover", so an event that straddles the edge
// of the question has to come back.
func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil {
t.Fatal(err)
}
stored, _ := db.Calendars(ctx, a.ID)
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30",
[]CalendarEvent{
{RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true},
}); err != nil {
t.Fatal(err)
}
// A week that contains neither end of it.
got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %+v; an event covering the whole week was not found", got)
}
// The calendar's own colour rides along, or the chip has to ask for it per row.
if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" {
t.Errorf("the calendar did not come with the event: %+v", got[0])
}
}
// Somebody else's calendar is somebody else's.
func TestOnlyTheOwnersEventsComeBack(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
other := &User{Email: "andreas@example.dk", Name: "Andreas"}
if err := db.CreateUser(ctx, other, "Indbakke"); err != nil {
t.Fatal(err)
}
theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"}
if err := db.SaveCalendarAccount(ctx, theirs); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil {
t.Fatal(err)
}
stored, _ := db.Calendars(ctx, theirs.ID)
if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
[]CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
t.Fatal(err)
}
got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("somebody else's calendar came back: %+v", got)
}
}
// A calendar turned off stops being drawn, and stops costing the grid anything.
func TestTurningACalendarOffRemovesItsEvents(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
t.Fatal(err)
}
stored, _ := db.Calendars(ctx, a.ID)
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
t.Fatal(err)
}
if err := db.ShowCalendars(ctx, a.ID, nil); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Errorf("%d events of a calendar nobody is looking at are still in the table", count)
}
}
// Disconnecting takes the calendars and the events with it. Unlike the tasks a
// mailbox made, none of this is the person's own work — it is a copy of somebody
// else's calendar and it means nothing without the account.
func TestDisconnectingTakesEverythingWithIt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
a := connectedAccount(t, db, userID)
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
t.Fatal(err)
}
stored, _ := db.Calendars(ctx, a.ID)
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
t.Fatal(err)
}
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
t.Fatal(err)
}
if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil {
t.Fatal(err)
}
for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} {
var count int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Errorf("%s still holds %d rows after disconnecting", table, count)
}
}
}
// The sweep finds people by the presence of an account, so it has to keep working
// now that the credential in it is ciphertext.
func TestTheSweepFindsAConnectedCalendar(t *testing.T) {
db, userID := sealedStore(t)
connectedAccount(t, db, userID)
users, err := db.UsersWithCalendars(context.Background())
if err != nil {
t.Fatal(err)
}
if len(users) != 1 || users[0] != userID {
t.Fatalf("the sweep found %v", users)
}
}
+24 -8
View File
@@ -27,6 +27,7 @@ type Attachment struct {
// GroupID hangs the file on a project group rather than on any one task in
// it: the contract that governs all of "Arbejde", not a step in it.
GroupID string
NoteID string
Filename string
MimeType string
Size int64
@@ -157,17 +158,18 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
a.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
`INSERT INTO attachments (id, task_id, comment_id, group_id, note_id, filename, mime_type,
size, path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
nullString(a.NoteID),
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
return err
}
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
if err != nil {
return nil, err
@@ -176,11 +178,24 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
return scanAttachments(rows)
}
// ListNoteAttachments is the files inside one note — the pictures and scans that
// came with it, and anything added since.
func (db *DB) ListNoteAttachments(ctx context.Context, noteID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE note_id = ? ORDER BY created_at`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAttachments(rows)
}
// ListGroupAttachments is the documents that belong to a whole group rather than
// to anything inside it.
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
if err != nil {
return nil, err
@@ -195,7 +210,7 @@ func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (ma
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
ORDER BY created_at`, args...)
if err != nil {
@@ -218,15 +233,16 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
out := []Attachment{}
for rows.Next() {
var a Attachment
var taskID, commentID, groupID sql.NullString
var taskID, commentID, groupID, noteID sql.NullString
var created int64
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &noteID, &a.Filename, &a.MimeType,
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CommentID = commentID.String
a.GroupID = groupID.String
a.NoteID = noteID.String
a.CreatedAt = time.Unix(created, 0).UTC()
out = append(out, a)
}
@@ -235,7 +251,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE id = ?`, attachmentID)
if err != nil {
return nil, err
@@ -0,0 +1,13 @@
-- The order of the fixed views in the sidebar.
--
-- On the account rather than in the browser, and for the reason the sidebar's
-- width is the other way round: a width is a fact about the screen you are at, and
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
-- on the laptop as on the phone.
--
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
-- is: the set of views changes with the program, and a key that no longer exists
-- costs nothing — the sidebar draws what it recognises and appends anything new at
-- the end. That is what makes adding a view later not a migration.
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
@@ -0,0 +1,29 @@
-- #garageristeriet and #GarageRisteriet are the same project.
--
-- They were not, because the key was stored exactly as typed and looked up the
-- same way — so a note that named a project in lower case simply did not appear
-- on it, silently and with nothing to suggest why.
--
-- Folded on the way in from now on; this brings the rows already written into
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
-- and "møde" are two notes.
--
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
CREATE TABLE note_links_folded (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
target_id TEXT NOT NULL,
PRIMARY KEY (note_id, kind, target_id)
);
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
SELECT note_id, kind,
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
FROM note_links;
DROP TABLE note_links;
ALTER TABLE note_links_folded RENAME TO note_links;
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
@@ -0,0 +1,45 @@
-- A note can carry files, the way a task and a group already can.
--
-- Apple Notes is full of photographs, scans and pasted screenshots, and a note
-- that arrives here as words alone is not that note — it is a summary of it. The
-- import writes the files as attachments and rewrites the links in the text, so a
-- picture that was in the middle of a paragraph is still in the middle of it.
--
-- Same table as everything else attaches to, rather than one of its own. The files
-- are content-addressed and deduplicated on disk, the upload path is written and
-- tested, and the cleanup that removes an orphan already walks this table. A
-- second table would be a second everything.
-- verdande:rebuild-tables
CREATE TABLE attachments_with_notes (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
note_id TEXT REFERENCES notes (id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL,
path TEXT NOT NULL,
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
-- Counted, not spelled out as pairs: with four columns the pairwise version is
-- six clauses, and the one that is wrong looks exactly like the one that is right.
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL)
+ (group_id IS NOT NULL) + (note_id IS NOT NULL) = 1)
);
INSERT INTO attachments_with_notes
(id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at)
SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments;
DROP TABLE attachments;
ALTER TABLE attachments_with_notes RENAME TO attachments;
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX idx_attachments_note ON attachments (note_id) WHERE note_id IS NOT NULL;
+26
View File
@@ -0,0 +1,26 @@
-- One row per installation that has ever reported in.
--
-- This table only exists on the instance that is acting as the collector. Every
-- other instance has it too, empty, because the schema is the same everywhere —
-- and because "am I the collector" is a setting, not a build.
--
-- What is deliberately NOT here is the point of the whole feature: there is no
-- column for an IP address, a hostname, a domain, an account, a count of tasks,
-- or anything else. The two things stored are the two things sent, and the table
-- is the promise written down where it cannot drift from the prose — a column
-- added here later is a promise broken, and it should be as awkward to add as
-- possible.
--
-- `instance_id` is the primary key rather than a rowid alias, because the same
-- installation reporting daily must update its row rather than grow a new one.
-- What is wanted is "how many installations", not "how many pings".
CREATE TABLE beacon_installs (
instance_id TEXT PRIMARY KEY,
version TEXT NOT NULL DEFAULT '',
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
-- The count is always "seen in the last N days", so last_seen is what every query
-- sorts and filters on.
CREATE INDEX beacon_by_last_seen ON beacon_installs (last_seen DESC);
@@ -0,0 +1,111 @@
-- Calendars read from somewhere else: the account, the calendars in it, and the
-- events they hold.
--
-- Not in `mailboxes`, and it was the first thing considered. The two look alike
-- from a distance — a person connects a Google account over OAuth, tokens are
-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`,
-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a
-- task", and nothing here turns into a task. What is here instead is a *set*:
-- one account holds many calendars, and a person picks which of them to look at.
-- `mailboxes` has no room for a one-to-many, and giving it one would push the
-- other kind's columns onto rows that can never use them.
--
-- The decisive part is smaller and more practical. `mailboxes.kind` carries
-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a
-- third value means rebuilding the table with foreign keys off, which is the
-- expensive, careful thing migration 0021 had to do for a reason. Paying that to
-- store something that shares no column with the table is the wrong trade.
--
-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it
-- should cost a row, not a rebuild.
-- One connected account per person per provider. The tokens live here rather than
-- on each calendar: a refresh token copied onto ten rows is ten copies of one
-- secret, ten refreshes racing each other, and ten places to miss when access is
-- revoked.
--
-- `refresh_token` and `access_token` are written sealed; see internal/secret. The
-- store's own seal/unseal only covers the settings tables, so this table's methods
-- call them by hand — the same fact worth knowing that migration 0014 wrote down.
CREATE TABLE calendar_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'google',
-- The address the account reports, so the settings page can say which one is
-- connected rather than only that one is.
account TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
last_sync_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow
-- is one account at a time, so a second row for the same provider would be a
-- second set of tokens for the same grant and no way to say which is live.
CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider);
-- One row per calendar the account can see, kept whether or not it is shown.
--
-- `shown` rather than deleting the ones that are not: a person who turns a calendar
-- off and on again should get the same row back, and the list is refreshed from
-- Google on every sync — so a calendar that only existed while it was ticked would
-- lose its choice every time somebody added a new one.
--
-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named
-- colours, which is the rule everywhere else in this database: those are names so
-- the interface can restyle them per theme, and they are *verdande's* palette. A
-- Google calendar is told apart by the colour the person already knows it by, and
-- that colour belongs to Google.
CREATE TABLE calendars (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE,
-- The id Google knows it by. Long, and an address for the primary calendar.
remote_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
colour TEXT NOT NULL DEFAULT '',
time_zone TEXT NOT NULL DEFAULT '',
primary_one INTEGER NOT NULL DEFAULT 0,
writable INTEGER NOT NULL DEFAULT 0,
shown INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id);
-- The events themselves, cached.
--
-- Cached rather than fetched per view, and the reason is what a month grid does:
-- it pages. Asking Google on every arrow press means a token refresh, a round trip
-- and a spinner for a question that was answered ninety seconds ago — and a
-- calendar that is slower than the calendar it is showing is not worth having.
-- The window is refreshed by the sweep and by the "fetch now" button.
--
-- Days rather than instants, and both ends inclusive. A grid asks one question —
-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that
-- question. `starts_at` is kept as Google wrote it, offset and all, because it is
-- what the chip shows and reparsing it here would ask *this process* what time
-- zone it is in. An all-day event has neither: it has a day, not a moment, and
-- inventing midnight for it is how an event moves a day when a container moves
-- time zone.
CREATE TABLE calendar_events (
id TEXT PRIMARY KEY,
calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE,
remote_id TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
starts_at TEXT NOT NULL DEFAULT '',
ends_at TEXT NOT NULL DEFAULT '',
start_day TEXT NOT NULL,
end_day TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT ''
);
-- The one query there is: everything a person can see, between two days. Ordered
-- by the account so the join has somewhere to start, then by the days the grid
-- compares against.
CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day);
+68 -6
View File
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
}
n.UpdatedAt = now
// A note with no title takes its first line, the way Apple Notes does. A list
// of untitled notes is a list nobody can read.
if strings.TrimSpace(n.Title) == "" {
n.Title = firstLine(n.Body)
}
// The title is always the first line, the way Apple Notes does it. Not "when it
// is empty": derived once and then left alone, it goes stale the moment somebody
// rewrites the opening of a note, and the list ends up calling a note by a name
// that is nowhere in it. The column is a cache of the body, not a field beside
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
add("task", strings.ToLower(m[1]))
}
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
add("project", m[1])
// Folded, because #garageristeriet and #GarageRisteriet are the same project
// to everybody except a database. The key is stored folded and looked up
// folded, so the comparison stays an exact match and keeps using its index —
// a comparison that lowered the column instead would read the whole table.
//
// The project's real spelling is not lost: it is on the project, which is
// what the interface shows. This column is a key, not a label.
add("project", strings.ToLower(m[1]))
}
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
add("note", strings.TrimSpace(m[1]))
@@ -184,11 +192,34 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
ORDER BY pinned DESC, updated_at DESC`, userID)
}
// AllNotes is everything a person can see: their own loose ones, and the ones in
// projects they are part of.
//
// This is what the Notes page lists. Filing a note in a project is how it gets
// shared, and a list that dropped it at that moment would make sharing look like
// deleting — which is exactly how it read before this existed.
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
//
// The reason note_links exists. Without it this would mean reading every note's
// prose, and a panel that slow is one nobody leaves open.
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
if kind == "project" {
targetID = strings.ToLower(targetID)
}
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
@@ -216,6 +247,37 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
LIMIT ?`, expr, limit)
}
// SetNoteTimes writes the dates a note had somewhere else.
//
// Only the import calls this, and it exists because SaveNote is right to refuse:
// `updated_at` is when this program last wrote the note, and letting any caller
// set it would make the column mean whatever the last writer felt like. A note
// brought in from Apple Notes is the one case where the dates are facts from
// before this database existed — and without them a move of twelve hundred notes
// is a pile that was all written the same evening, which throws away the order
// they were written in. That order is half of what an archive is.
func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error {
if created.IsZero() && updated.IsZero() {
return nil
}
// COALESCE, so a note carrying only one of the two keeps what it already had
// for the other rather than being stamped with the zero time — which would sort
// it to the year one.
var c, u any
if !created.IsZero() {
c = created.Unix()
}
if !updated.IsZero() {
u = updated.Unix()
}
_, err := db.ExecContext(ctx, `
UPDATE notes
SET created_at = COALESCE(?, created_at),
updated_at = COALESCE(?, updated_at)
WHERE id = ?`, c, u, id)
return err
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
+92 -4
View File
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
if len(projects) != 2 {
t.Fatalf("read %v out of the note, want the two projects", n.Links)
}
if projects[0] != "Firma" || projects[1] != "Regnskab" {
// Folded: the key is a key, not a label. #firma and #Firma are the same project
// to everybody except a database, and the project's own spelling is on the
// project, which is what the interface shows.
if projects[0] != "firma" || projects[1] != "regnskab" {
t.Errorf("got %v", projects)
}
}
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// The title is the first line now, so that is where it goes.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Title: "Grønt regnskab",
Body: "Målt på årsbasis for Århus.",
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
}); err != nil {
t.Fatal(err)
}
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
{"the same tag twice is one link", "#Firma og #Firma igen",
[]NoteLink{{"project", "Firma"}}},
[]NoteLink{{"project", "firma"}}},
{"two spellings are one tag", "#Firma og #firma",
[]NoteLink{{"project", "firma"}}},
{"a heading is not a tag", "# Overskrift", nil},
} {
t.Run(c.name, func(t *testing.T) {
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
t.Errorf("after deleting one, the count is %d, want 1", n)
}
}
// The title follows the first line, always — not only the first time.
//
// Derived once and then left alone, it goes stale the moment somebody rewrites
// the opening, and the list ends up calling a note by a name that is nowhere in
// it. That is exactly what happened: a note headed "Ny note" was listed as
// "fdgfgfgh", which was what its first line had been some edits ago.
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Første udgave" {
t.Fatalf("title started as %q", n.Title)
}
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("after rewriting the first line the title is %q", n.Title)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back.Title != "Ny note" {
t.Errorf("the stored title is %q", back.Title)
}
// And a title cannot be set beside the text, because then the two can disagree.
n.Title = "noget andet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("a title set by hand survived as %q", n.Title)
}
}
// A note that names a project in the other case must still turn up on it.
//
// It did not, and nothing said why: the key was stored exactly as typed and
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
// different things that looked identical to a person.
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Bønnerne kommer fredag #garageristeriet",
}); err != nil {
t.Fatal(err)
}
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
found, err := db.NotesLinking(ctx, "project", spelling)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
}
}
// A note title is a title, not a key: two notes called "Møde" and "møde" are
// two notes, and folding them together would merge things somebody kept apart.
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
t.Fatal(err)
}
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
t.Error("a note title was folded")
}
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
t.Error("a note title did not match itself")
}
}
+33 -5
View File
@@ -36,8 +36,13 @@ type User struct {
// is as true on the laptop as on the desktop. The sidebar's *width* is the
// opposite case and is stored per browser.
SidebarCollapsed []string
CreatedAt time.Time
UpdatedAt time.Time
// NavOrder is the order this person wants the fixed views in, by key. Empty
// means the order the program ships with. Unknown keys are ignored and missing
// ones are appended, so adding a view later is not a migration.
NavOrder []string
CreatedAt time.Time
UpdatedAt time.Time
}
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var totpEnabled, isAdmin int
var created, updated int64
var collapsed string
var collapsed, navOrder string
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
// worst case is a sidebar that opens fully, which is the state it shipped in.
u.SidebarCollapsed = []string{}
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.NavOrder = []string{}
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
}
// SetNavOrder records the order of the fixed views.
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
if order == nil {
order = []string{}
}
raw, err := json.Marshal(order)
if err != nil {
return err
}
res, err := db.ExecContext(ctx,
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
string(raw), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetSidebarCollapsed records which sidebar headings are folded.
//
// Its own method rather than a field on UpdateProfile: that one backs a form with
+16 -6
View File
@@ -621,13 +621,13 @@
</figure>
<figure class="shot">
<img
src="screenshots/today-light.webp"
alt="The same Today view in the light theme."
src="screenshots/notes.webp"
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The light theme.</figcaption>
<figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
</figure>
<figure class="shot">
<img
@@ -642,7 +642,7 @@
<figure class="shot">
<img
src="screenshots/detail.webp"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
width="1400"
height="900"
loading="lazy"
@@ -652,12 +652,12 @@
<figure class="shot">
<img
src="screenshots/themes.webp"
alt="The settings page showing the five themes as swatches."
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Five themes, all of them measured for contrast.</figcaption>
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
</figure>
<figure class="shot">
<img
@@ -689,6 +689,16 @@
invite link. Changes appear live.
</p>
</div>
<div class="cell">
<h3>Notes, beside the work</h3>
<p>
Rich text to write in, Markdown on disk. File a note in a project and
everybody in it can read it; <code>#project</code> and
<code>[[another note]]</code> are links, and a task shows what has been
written about it. A zip of Markdown goes in and comes back out the same
shape, so nothing is trapped here.
</p>
</div>
<div class="cell">
<h3>A real CalDAV server</h3>
<p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""Apple Noter → Markdown, med formateringen i behold.
Den første udgave af det her brugte `textutil -convert txt`, som er hurtigt at
skrive og smider alt væk: overskrifter, fed, monospace, lister. Ti prøvenoter
viste hvad det koster — en note er ikke kun sine ord, den er også hvordan de er
sat op, og et referat med overskrifter er ikke det samme referat som en klump.
Apple Noter er HTML indeni, og HTML kan læses. Kortet er lille, fordi Noter selv
bruger et lille sæt:
<h1> <h2> <h3> overskrifter → # ## ###
<b> <i> <u> fremhævning → ** * <u>
<tt> monospace → ```-blok
<ul><li> punktliste → -
<ol><li> nummereret → 1.
<div> en linje → linjeskift
<br> en tom linje → tom linje
Kører på din egen maskine og sender ingenting nogen steder.
./apple-notes-til-markdown.py ~/Desktop/noter # alle
./apple-notes-til-markdown.py ~/Desktop/noter 10 # de første ti
"""
import html
import os
import re
import subprocess
import sys
from html.parser import HTMLParser
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
# Fra lukkemærke tilbage til åbnemærke, når et mærke skal deles over to linjer.
OPENER = {"**": "**", "*": "*", "</u>": "<u>"}
MARKS_ONLY = re.compile(r"(?:\*\*|\*|~~|<u>|</u>|\s)*")
FORBIDDEN = set('/\\:*?"<>|')
def osascript(script: str) -> str:
"""Ét kald til Noter. Fejl er tomme frem for fatale: en enkelt note, der ikke
kan læses, skal ikke stoppe en kørsel på tolv hundrede."""
try:
out = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=60, check=False,
)
return out.stdout.rstrip("\n")
except subprocess.SubprocessError:
return ""
class NoteParser(HTMLParser):
"""Apple Noters HTML til Markdown.
Linjebaseret, fordi Noter er linjebaseret: hver <div> er en linje, og det er
dét, personen skrev. En rigtig Markdown-gengiver ville slå linjer sammen til
afsnit, hvilket er korrekt Markdown og forkert i forhold til noten.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.lines: list[str] = []
self.line: list[str] = []
self.block: str | None = None # h1, h2 …
self.mono = False # den aktuelle linje er monospace
self.mono_lines: list[str] = [] # samlet op til én kodeblok
self.list_kind: list[str] = [] # ul/ol i lag
self.list_no: list[int] = []
self.marks: list[str] = [] # b, i, u i lag
# --- linjer ---------------------------------------------------------------
def _flush_line(self):
# Et mærke, der står åbent, når linjen slutter, lukkes her og åbnes igen på
# den næste.
#
# Noter skriver <b>Møde forberedelse<br></b>: linjeskiftet ligger *inde i*
# det fede. Skrev man linjen ud uden videre, blev de to stjerner, der lukker,
# til den næste linjes første tegn — "**Møde forberedelse" og så en linje med
# "**" på. Markdown er linjebaseret; et mærke, der skal over et linjeskift,
# findes ikke, og skal derfor deles i to.
reopen = list(self.marks)
for closer in reversed(self.marks):
self.line.append(closer)
text = "".join(self.line).rstrip()
# En linje, der kun er mærker, er en tom linje. Det sker for den sidste
# stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet.
if MARKS_ONLY.fullmatch(text):
text = ""
self.line = [OPENER[c] for c in reopen]
mono, self.mono = self.mono, False
if mono:
# Monospace samles op og skrives som én blok, ikke som en række
# enkeltlinjer: en terminaludskrift er én ting.
self.mono_lines.append(text)
return
# En tom linje lukker ikke en kodeblok. Hver linje i Noter er pakket ind i
# sin egen <div>, og dens starttag tømmer den forrige linje — så mellem to
# <tt>-linjer kommer der altid en tom flush forbi. Lod man den lukke
# blokken, blev hver eneste linje i en terminaludskrift sin egen blok.
if not text and self.mono_lines:
return
self._close_mono()
if self.block:
self.lines.append(BLOCK_PREFIX[self.block] + text if text else "")
return
if self.list_kind:
if not text:
return
if self.list_kind[-1] == "ol":
self.list_no[-1] += 1
self.lines.append(f"{self.list_no[-1]}. {text}")
else:
self.lines.append(f"- {text}")
return
self.lines.append(text)
def _close_mono(self):
if not self.mono_lines:
return
# Tomme linjer i enderne er afstand omkring blokken, ikke en del af koden.
block = self.mono_lines
while block and not block[0].strip():
block.pop(0)
while block and not block[-1].strip():
block.pop()
if block:
self.lines.append("```")
self.lines.extend(block)
self.lines.append("```")
# Luft efter blokken. Uden den klæber den næste sætning til den
# afsluttende hegnslinje, og en Markdown-læser er i sin gode ret til
# at læse dem som ét.
self.lines.append("")
self.mono_lines = []
def _open(self, opener: str, closer: str):
"""Åbner et mærke — eller fortryder det, der lige lukkede.
Noter deler også fremhævet tekst op i kørsler: "Møde" står som
<b>M</b><b>øde</b>, fordi der er en attributkørsel bag hver stump. Skrevet
ligeud bliver det **M****øde**, som er ét ord i fed sagt på den grimmest
mulige måde — og for kursiv bliver det *M**øde*, hvor de to stjerner i
midten læses som fed af enhver Markdown-læser. Ordet kom ud forkert.
Lukkes et mærke og åbnes det samme igen med det samme, er der ikke sket
noget imellem. Så tages lukningen tilbage, og de to kørsler er ét mærke.
"""
if self.line and self.line[-1] == closer:
self.line.pop()
else:
self.line.append(opener)
self.marks.append(closer)
# --- tags -----------------------------------------------------------------
def handle_starttag(self, tag, attrs):
if tag in BLOCK_PREFIX:
# En overskrift, der fortsætter den forrige, er den samme linje.
#
# Noter deler en linje op i så mange stumper, den har brug for — en
# overskrift skrevet i ét hug står som
# <h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>, fordi der er en
# attributkørsel bag hver stump. Læste man et linjeskift ved hver <h1>,
# blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og
# noten så ødelagt ud på den mest synlige måde, der findes: i sin egen
# titel.
#
# Der brydes derfor kun, når blokken skifter til en anden slags. Selve
# linjeskiftene kommer fra <div> og <br>, som er dem, der betyder det.
if self.block != tag:
self._flush_line()
self.block = tag
elif tag in ("ul", "ol"):
self._flush_line()
self.list_kind.append(tag)
self.list_no.append(0)
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Ikke et linjeskift: <tt> står inde i den <div>, der ER linjen.
# Mærket sættes på linjen og aflæses, når den skrives ud.
self.mono = True
elif tag in ("b", "strong"):
self._open("**", "**")
elif tag in ("i", "em"):
self._open("*", "*")
elif tag == "u":
self._open("<u>", "</u>")
elif tag == "br":
self._flush_line()
self.block = None
elif tag == "div":
self._flush_line()
self.block = None
def handle_endtag(self, tag):
if tag in BLOCK_PREFIX:
# Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste
# stump af samme linje ikke se, at den hører til — det er <div> og <br>,
# der afgør, hvor linjen holder op.
pass
elif tag in ("ul", "ol"):
self._flush_line()
if self.list_kind:
self.list_kind.pop()
self.list_no.pop()
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Blokken lukkes IKKE her. To <tt>-linjer efter hinanden er én
# terminaludskrift, ikke to; den lukkes, når en linje uden monospace
# skrives ud, hvilket _flush_line sørger for.
pass
elif tag in ("b", "strong", "i", "em", "u"):
if self.marks:
self.line.append(self.marks.pop())
elif tag == "div":
self._flush_line()
self.block = None
def handle_data(self, data):
self.line.append(data)
def result(self) -> str:
self._flush_line()
self._close_mono()
# Tomme linjer i stimer bliver til én: Noter er fuld af <div><br></div>,
# og fem tomme linjer i træk er ikke afstand, det er rod.
out, blanks = [], 0
for line in self.lines:
if line.strip():
blanks = 0
out.append(line)
else:
blanks += 1
if blanks == 1:
out.append("")
while out and not out[-1].strip():
out.pop()
return "\n".join(out)
def to_markdown(body: str) -> str:
p = NoteParser()
p.feed(body)
p.close()
return p.result()
def safe_name(title: str, fallback: str) -> str:
name = "".join("-" if c in FORBIDDEN or ord(c) < 32 else c for c in title).strip()
name = name.rstrip(" .")
return name[:80] or fallback
# Skilletegn, der ikke står i en note: enhedsadskillere fra ASCII, som ingen
# skriver med et tastatur.
FIELD, RECORD = "\u241f", "\u241e"
def osascript_file(script: str) -> str:
"""Som osascript(), men til et manuskript, der er for stort til -e."""
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".applescript", delete=False,
encoding="utf-8") as f:
f.write(script)
path = f.name
try:
out = subprocess.run(["osascript", path], capture_output=True, text=True,
timeout=600, check=False)
return out.stdout
except subprocess.SubprocessError:
return ""
finally:
os.unlink(path)
def fetch(ids: list[str]) -> list[dict]:
"""Navn, krop og datoer for en håndfuld noter i ét kald.
Ét kald pr. note var det oplagte, og det tog halvanden time for tolv hundrede
— ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal
startes. Det gjorde det umuligt at rette en fejl i oversættelsen og se
resultatet: hver runde var en frokostpause. Nu hentes de i klumper, og hele
eksporten tager minutter.
Klumper frem for alle på én gang, fordi en enkelt uleselig note ellers ville
tage hele kørslen med sig — og fordi svaret skal kunne være i hukommelsen.
Datoerne læses som tal frem for som tekst. `creation date` giver en dato
formateret efter maskinens sprog, og den ville skulle gættes tilbage; year,
month og day er de samme tal på enhver maskine.
"""
quoted = ", ".join('"' + i.replace('"', '\\"') + '"' for i in ids)
script = f'''
tell application "Notes"
set out to ""
repeat with theId in {{{quoted}}}
try
set n to note id theId
set c to creation date of n
set m to modification date of n
set out to out & (name of n) & "{FIELD}" & ¬
((year of c) & "-" & (month of c as integer) & "-" & (day of c) & "T" & ¬
(hours of c) & ":" & (minutes of c) & ":" & (seconds of c)) & "{FIELD}" & ¬
((year of m) & "-" & (month of m as integer) & "-" & (day of m) & "T" & ¬
(hours of m) & ":" & (minutes of m) & ":" & (seconds of m)) & "{FIELD}" & ¬
(count of attachments of n) & "{FIELD}" & ¬
(body of n) & "{RECORD}"
on error
set out to out & "{FIELD}{FIELD}{FIELD}{FIELD}" & "{RECORD}"
end try
end repeat
return out
end tell
'''
raw = osascript_file(script)
rows = []
for chunk in raw.split(RECORD):
if not chunk.strip("\n "):
continue
parts = chunk.split(FIELD)
if len(parts) < 5:
rows.append({"name": "", "created": "", "modified": "", "files": 0, "body": ""})
continue
rows.append({
"name": parts[0].lstrip("\n"), "created": parts[1], "modified": parts[2],
"files": int(parts[3]) if parts[3].strip().isdigit() else 0,
"body": parts[4],
})
return rows
def iso(stamp: str) -> str:
""""2026-8-17T12:55:14" → "2026-08-17T12:55:14". Nul foran, så det sorterer."""
try:
date, _, time = stamp.partition("T")
y, mo, d = (int(x) for x in date.split("-"))
h, mi, se = (int(x) for x in time.split(":"))
return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{se:02d}"
except (ValueError, AttributeError):
return ""
def main() -> int:
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
os.makedirs(out_dir, exist_ok=True)
# Id'erne først, i ét kald.
#
# Ikke "note 1", "note 2" …: Noter sorterer listen efter ændringsdato, så et
# indeks peger på noget andet, hvis noget flytter sig imellem to kald — og
# navn og krop blev hentet hver for sig. Resultatet var en note med én titel
# og en andens indhold, hvilket først ses, når man læser dem igennem. Et id
# peger på den samme note hver gang.
ids = osascript(
'tell application "Notes" to return id of every note'
)
if not ids:
print("Noter svarer ikke. Første gang skal macOS have lov — se efter dialogen.")
return 1
ids = [x.strip() for x in ids.split(",") if x.strip()]
total = len(ids)
print(f"Fandt {total} noter.")
if limit and limit < total:
ids = ids[:limit]
total = limit
print(f"Tager de første {total}.")
used: dict[str, int] = {}
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
CHUNK = 40
fetched: list[dict] = []
for start in range(0, len(ids), CHUNK):
fetched.extend(fetch(ids[start:start + CHUNK]))
print(f" hentet {min(start + CHUNK, len(ids))} / {total}")
for i, (note_id, row) in enumerate(zip(ids, fetched), start=1):
title = row["name"].strip() or f"uden titel {i}"
text = to_markdown(row["body"])
# Kroppen begynder med notens egen titel. Skrives der en overskrift
# ovenover, står titlen to gange i hver eneste note.
#
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
# starttag tømmer linjen før den — så første linje er tom, og
# sammenligningen så på ingenting.
# Titlen kan være delt over flere overskrifter.
#
# Noter kalder noten det, der står på de første linjer, sat sammen med
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
#
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
lines = text.split("\n")
while lines and not lines[0].strip():
lines.pop(0)
taken, joined = 0, ""
for line in lines:
if not line.strip():
taken += 1
continue
if not line.lstrip().startswith("#"):
break
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
if not title.strip().startswith(candidate):
break
joined, taken = candidate, taken + 1
if joined == title.strip():
break
if joined == title.strip():
lines = lines[taken:]
while lines and not lines[0].strip():
lines.pop(0)
text = "\n".join(lines)
name = safe_name(title, f"note-{i}")
used[name] = used.get(name, 0) + 1
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
# Datoerne i et hoved, som enhver anden Markdown-læser kender.
#
# Uden dem er en flytning af tolv hundrede noter en bunke, der alle er
# skrevet samme aften: rækkefølgen, som er halvdelen af hvad et arkiv er,
# findes ikke længere. Obsidian, Bear og alt andet læser den her blok, og
# verdandes egen import gør nu også.
created, modified = iso(row["created"]), iso(row["modified"])
head = ""
if created or modified:
head = "---\n"
if created:
head += f"created: {created}\n"
if modified:
head += f"modified: {modified}\n"
head += "---\n\n"
with open(path, "w", encoding="utf-8") as f:
f.write(f"{head}# {title}\n\n{text}\n")
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
# note, der kommer over som ord alene, er ikke den note.
# Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver
# tiende note har et bilag, var de ni af dem en proces startet for at få
# svaret nul.
if row["files"] > 0:
os.makedirs(attachments_dir, exist_ok=True)
with open(path, "a", encoding="utf-8") as f:
for j in range(1, row["files"] + 1):
a_name = osascript(
f'tell application "Notes" to return name of attachment {j} '
f'of note id "{note_id}"'
) or f"bilag-{i}-{j}"
a_file = f"{i}-{j}-" + safe_name(a_name, f"bilag-{i}-{j}")
target = os.path.join(attachments_dir, a_file)
# Nogle vedhæftninger — links til websider, kort — har ingen
# fil bag sig og fejler her. De springes over.
osascript(
f'tell application "Notes" to save attachment {j} '
f'of note id "{note_id}" in POSIX file "{target}"'
)
if os.path.exists(target):
f.write(f"\n![](vedhaeftninger/{os.path.basename(target)})\n")
if i % 25 == 0:
print(f" {i} / {total}")
print(f"\nFærdig. {total} noter ligger i:\n {out_dir}\n")
print("Pak dem og importér zip'en under Indstillinger → Data:")
print(f' cd "{os.path.dirname(out_dir)}" && zip -r "{os.path.basename(out_dir)}.zip" "{os.path.basename(out_dir)}"')
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
gangen, og tunnelen foran den tager mindre — så det skal deles.
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
får hver deres. En note og dens billeder ligger altid i samme portion, for
linkene i teksten er relative, og en note, hvis billeder kom med i næste
portion, ville pege på ingenting.
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
"""
import os
import re
import sys
import zipfile
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
# portion mere end en, der ryger på målstregen.
DEFAULT_LIMIT_MB = 40
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
def main() -> int:
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
out_dir = src.rstrip("/") + "-portioner"
os.makedirs(out_dir, exist_ok=True)
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
if not notes:
print(f"Ingen .md-filer i {src}")
return 1
# Hvad hver note vejer, og hvad den trækker med sig.
weighed = []
for name in notes:
path = os.path.join(src, name)
text = open(path, encoding="utf-8", errors="replace").read()
files = []
size = os.path.getsize(path)
for rel in LINK.findall(text):
f = os.path.join(src, rel)
if os.path.exists(f):
files.append(rel)
size += os.path.getsize(f)
weighed.append((name, files, size))
# En note, der alene er større end grænsen, får sin egen portion frem for at
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
# kan sendes.
batches, current, current_size = [], [], 0
for item in weighed:
if current and current_size + item[2] > limit:
batches.append(current)
current, current_size = [], 0
current.append(item)
current_size += item[2]
if current:
batches.append(current)
print(f"{len(notes)} noter → {len(batches)} portioner\n")
for i, batch in enumerate(batches, start=1):
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
seen = set()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
for name, files, _ in batch:
z.write(os.path.join(src, name), name)
for rel in files:
if rel in seen:
continue
seen.add(rel)
z.write(os.path.join(src, rel), rel)
mb = os.path.getsize(zip_path) / 1024 / 1024
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
print(f"\nLigger i:\n {out_dir}")
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -2
View File
@@ -5,7 +5,7 @@
// cookie. Kept in the repository so the images can be regenerated when the
// interface changes, rather than being a thing somebody did once by hand.
//
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
package main
import (
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
func main() {
base := flag.String("url", "http://localhost:8096", "the running instance")
cookie := flag.String("cookie", "", "session cookie value")
out := flag.String("out", "site/screenshots", "where to write the PNGs")
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
// PNGs dropped in beside them are files nothing points at.
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
width := flag.Int("width", 1400, "viewport width")
height := flag.Int("height", 900, "viewport height")
flag.Parse()
+755 -2
View File
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
// Titlen alene, ikke rækken med datomærket i.
//
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
// datoer; den skal ikke vide hvilken dag det er.
await page.getByText('males inden', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
@@ -971,6 +979,211 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
* asserting against a project the admin can already see would pass with the old
* per-project endpoint behind it.
*/
/**
* Kalender: Google's events and verdande's own tasks in one grid, and the rule
* that tells them apart.
*
* The Google half is stubbed at the network layer rather than connected. There are
* no credentials in a test run and there never will be — an OAuth consent screen is
* a human pressing a button on somebody else's website — so what can be proved here
* is what the grid does with events once it has them, which is the part that has a
* rule in it: a task is draggable and reschedules, an event is not and cannot.
*
* The date is read out of the *page*, not out of this process. Playwright pins the
* browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of
* every day the two disagree about what today is — which is how a green suite
* starts failing at half past midnight. Today is also the one day guaranteed to be
* in the month grid whatever the weekday, so nothing here has to page.
*/
test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A day at least a week out, whose next day is in the same Monday-to-Sunday
// week — so the drag below crosses one cell and not one grid.
//
// Worked out in the browser, not here. Playwright pins the page to
// Europe/Copenhagen and this process is pinned to nothing, so for two hours of
// every day the two disagree about what today is. A week out also keeps this
// test's day clear of the ones the rest of the suite dates things on.
const { day, next } = await page.evaluate(() => {
const iso = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
const monday = (d) => {
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
return iso(m);
};
const cursor = new Date();
cursor.setDate(cursor.getDate() + 7);
for (let i = 0; i < 14; i++) {
cursor.setDate(cursor.getDate() + 1);
const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) };
}
throw new Error('no two consecutive days inside one week in three weeks');
});
// A calendar that is connected and holds two events that day: one with a clock
// and one that lasts the whole day. The window is deliberately wide, so the
// notice about having paged past it stays out of the way.
await page.route('**/api/v1/calendar/events*', (route) =>
route.fulfill({
json: {
from: '2000-01-01',
to: '2099-12-31',
events: [
{
id: 'ev-1',
calendar_id: 'c1',
summary: 'Bestyrelsesmøde',
starts_at: `${day}T14:00:00+02:00`,
ends_at: `${day}T15:30:00+02:00`,
start_day: day,
end_day: day,
all_day: false,
url: 'https://calendar.google.com/event?eid=abc',
calendar_name: 'Arbejde',
colour: '#0b8043'
},
{
id: 'ev-2',
calendar_id: 'c1',
summary: 'Feriedag',
start_day: day,
end_day: day,
all_day: true,
calendar_name: 'Familien',
colour: '#3f51b5'
}
]
}
})
);
await page.route('**/api/v1/calendar', (route) =>
route.fulfill({
json: {
connected: true,
account: 'kw@nolimit.dk',
read_only: true,
has_client: true,
redirect_uri: 'http://localhost/oauth/calendar/callback',
calendars: [
{ id: 'c1', remote_id: 'fam', name: 'Arbejde', colour: '#0b8043', shown: true }
]
}
})
);
// verdande's own half of the grid: a task on that day, made the way anybody
// would make it. The date is written out rather than typed as "i morgen", so
// the assertion does not depend on the parser as well.
const box = page.getByLabel('Ny opgave');
await box.fill(`vande blomster ${day}`);
await box.press('Enter');
// Not asserted here: this is the Today view and the task is dated a week out,
// so it is correctly absent. The grid below is where it has to turn up.
await expect(box).toHaveValue('');
// The menu item, which is the thing the sidebar gained.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.locator('a[href="/kalender"]').click();
await page.waitForURL('**/kalender');
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
// The week, not the month, and for a reason worth writing down: a month cell
// truncates at three chips and this suite has other tests putting tasks on
// other days. A week cell holds ten, so what is asserted below is what the view
// does with events rather than how full somebody else left the month.
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Forward a week at a time until the row is the one holding that day. Bounded,
// because a loop that pages for ever on a broken button is a timeout with no
// explanation in it.
const cell = page.locator(`[data-date="${day}"]`);
const forward = page.getByRole('button', { name: 'Næste uge' });
for (let i = 0; i < 6 && (await cell.count()) === 0; i++) {
await forward.click();
}
await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible();
// Connected, with a calendar ticked — so neither of the two "there is nothing
// here" notices belongs on the screen. They exist so an empty grid is never
// unexplained, and an explanation for a state you are not in is noise.
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
// Both kinds are in the same cell, which is the whole point of the view.
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
await expect(event).toBeVisible();
await expect(allDay).toBeVisible();
await expect(task).toBeVisible();
// A timed event carries its clock; an all-day one has none to carry.
await expect(event).toContainText('14:00');
await expect(allDay).not.toContainText(':');
// The rule. An event is read-only, so it must not offer to be moved — the drop
// targets would refuse it anyway, but a chip that lifts off the page and then
// will not land reads as a bug rather than as a rule.
await expect(event).toHaveAttribute('draggable', 'false');
await expect(allDay).toHaveAttribute('draggable', 'false');
await expect(task).toHaveAttribute('draggable', 'true');
// And it cannot be ticked off either: there is nothing to complete about
// somebody else's meeting.
expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON');
// It does link back to Google, which is what makes it a pointer to the event
// rather than a second copy of it.
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
// The task still reschedules by drag, in a grid that now has events in it.
await task.dragTo(page.locator(`[data-date="${next}"]`));
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
await expect(cell.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
await expect(cell.locator('.event')).toHaveCount(2);
expect(trouble).toEqual([]);
});
/**
* The view without a calendar connected, which is what every fresh instance is.
*
* It has to draw anyway. The grid is verdande's own tasks with due dates — the
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
// Seven weekday columns and a grid under them, whatever day of the week it is.
await expect(page.locator('.calendar .grid .day').first()).toBeVisible();
// It says so rather than looking like a calendar with nothing in it, and it
// says where to go about it.
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible();
await expect(
page.locator('a[href="/indstillinger/integrationer"]').first()
).toBeVisible();
// The week is the same grid with a different span, and switching to it must not
// lose the events prop on the way.
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await expect(page.locator('.calendar.week')).toBeVisible();
expect(trouble).toEqual([]);
});
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
browser,
page
@@ -1464,7 +1677,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Færdige' }).click();
// Færdige left the sidebar: it is a rare errand next to four entries read every
// day, and it now sits with the other "where did that go" answers.
await page.goto('/indstillinger/data');
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
@@ -1872,6 +2088,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
await box.fill('første i dag');
await box.press('Enter');
// In the project itself before looking at the sidebar. Without this the count
// can be read while the task is still on its way, or — worse — after it landed
// in the inbox because the box that took the text belonged to the previous
// view. Then the assertion below fails for a reason that has nothing to do with
// what it is testing.
await expect(page.getByText('første', { exact: true })).toBeVisible();
// Reloaded before looking. What is asserted is that the number is open tasks
// and comes from the server, which holds on any load.
//
@@ -1951,3 +2174,533 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({
expect(trouble).toEqual([]);
});
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
await page_.click();
// Typed and formatted the way somebody would: pick a style, write, pick another.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Titel' }).click();
await page.keyboard.type('Møde med Anders');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// The styles are real elements, not asterisks on screen. This is the whole of
// "it should feel like Apple Notes".
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// Bold through the toolbar, on a selection made with the keyboard. Not
// Shift+Home — in a contenteditable that is the start of the document, so it
// took the heading with it and the button toggled bold off instead of on. Not a
// double-click either: it leaves the selection empty here.
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
await page.getByRole('button', { name: 'Fed' }).click();
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
// both are read as bold on the way back. Asserting one of them would be
// asserting which browser this is.
await expect(page_.locator('b, strong')).toBeVisible();
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
await page_.blur();
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Møde med Anders');
await expect(back.locator('b, strong')).toBeVisible();
await expect(back).toContainText('kaffe hver uge');
// No stray Markdown on screen: if the marks are showing, the conversion failed
// in the direction nobody notices until a note looks wrong.
await expect(back).not.toContainText('**');
await expect(back).not.toContainText('# ');
expect(trouble).toEqual([]);
});
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Delenoter');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const face = () =>
page.evaluate(() => getComputedStyle(document.body).fontFamily);
const ground = () =>
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
const startFace = await face();
await page.getByRole('button', { name: /^Rolig/ }).click();
const serif = await face();
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
expect(serif.toLowerCase()).toContain('serif');
// The other axis is untouched: choosing a face must not drag the palette with
// it. That is the whole reason they are two settings and not one list.
const darkGround = await ground();
await page.getByRole('button', { name: /^Papir/ }).click();
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
expect(await face(), 'temaet ændrede skriften').toBe(serif);
// And it is on before the first paint, not applied afterwards — otherwise the
// page is laid out once in one face and again in another.
await page.reload();
expect(await face()).toBe(serif);
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
// Back to the default, which carries no attribute at all.
await page.getByRole('button', { name: /^Verdande/ }).click();
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Straight in, without choosing a style. The first line is the title because
// that is what the list calls the note by.
await page.keyboard.type('Titelprøven');
await expect(ed.locator('h1')).toHaveText('Titelprøven');
await page.keyboard.press('Enter');
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// And the line after is body, not a second title. A note where every line is a
// heading is a note with no heading at all.
await expect(ed.locator('h1')).toHaveCount(1);
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// A third line stays body too.
await page.keyboard.press('Enter');
await page.keyboard.type('Og han ringer selv.');
await expect(ed.locator('h1')).toHaveCount(1);
// It survives the trip through Markdown: the title is a heading again on the
// way back, and the body is not.
await ed.blur();
await page.reload();
await page.getByRole('button', { name: /Titelprøven/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Titelprøven');
await expect(back.locator('h1')).toHaveCount(1);
await expect(back).toContainText('ringer selv');
expect(trouble).toEqual([]);
});
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Første udkast');
await page.keyboard.press('Enter');
// Half a tag is enough. The name is long and nobody should have to spell it.
await page.keyboard.type('Handler om #Forslags');
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
await expect(option).toBeVisible();
// Return takes the highlighted one — the list owns the key while it is open.
await page.keyboard.press('Enter');
await expect(ed).toContainText('#Forslagsprojektet');
await expect(option).toBeHidden();
// And the syntax is spelled out under the field, where it is being typed.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
// The title is the first line. It used to be derived once and then left alone,
// so rewriting the opening left the list calling the note by an old name.
await page.keyboard.press('Enter');
await ed.blur();
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
// Rewrite the first line; the list has to follow.
await ed.click();
await page.keyboard.press('ControlOrMeta+Home');
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.type('Andet udkast');
await ed.blur();
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
expect(trouble).toEqual([]);
});
test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Written in lower case, the way somebody types when they are not thinking
// about it. It used to mean the note simply never appeared on the project, with
// nothing anywhere to say why.
await page.keyboard.type('Aftale om levering');
await page.keyboard.press('Enter');
await page.keyboard.type('Handler om #storebogstaver');
await page.keyboard.press('Escape');
await ed.blur();
// The panel shows the project's own spelling, not the folded key: a label
// nobody wrote and that is nowhere in the note would be worse than none.
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en opgave viser de noter, der nævner den, og linket åbner noten', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('ring til leverandøren i dag');
await box.press('Enter');
await expect(page.getByText('ring til leverandøren', { exact: true })).toBeVisible();
// Asked for rather than read off the address bar: opening a task shows a drawer
// without navigating, so the URL is still the list's.
const taskId = await page.evaluate(async () => {
const r = await fetch('/api/v1/tasks?limit=100', {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
const j = await r.json();
return (j.tasks ?? []).find((t) => t.content === 'ring til leverandøren')?.id;
});
expect(taskId, 'opgaven blev ikke oprettet').toBeTruthy();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Referat fra tirsdag');
await page.keyboard.press('Enter');
await page.keyboard.type('Aftalt at han ringer, se /opgave/' + taskId);
await ed.blur();
// Back on the task: the other direction of the same link. Without it the
// connection only works if you happen to be reading the note, which is the half
// nobody needs help with.
await page.goto('/opgave/' + taskId);
const linked = page.locator('.linked-notes').getByRole('link', { name: /Referat fra tirsdag/ });
await expect(linked).toBeVisible();
// And it opens the note rather than dropping you on a list to find it again.
await linked.click();
await expect(page.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Aftalt at han ringer'
);
expect(trouble).toEqual([]);
});
test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const pre = ed.locator('pre');
await expect(pre).toBeVisible();
// Mørk flade uanset tema: en terminal er sort, også midt på dagen.
const look = await pre.evaluate((el) => {
const s = getComputedStyle(el);
return { bg: s.backgroundColor, font: s.fontFamily };
});
expect(look.bg).toBe('rgb(20, 24, 27)');
expect(look.font.toLowerCase()).toMatch(/mono/);
// Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
await expect(pre.locator('.tok-prompt')).toBeVisible();
await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
await expect(pre.locator('.tok-string')).toContainText('hej');
// Teksten omkring er ikke blevet til kode.
await expect(ed.locator('h1')).toHaveText('Terminalprøven');
await expect(ed).toContainText('og resten.');
// Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
// hegnet blok med sit sprog, ikke som en klump linjer.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('```bash');
expect(body).toContain('kw@shell:~$ ls -la');
expect(trouble).toEqual([]);
});
test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Formerne, der hver især gik tabt: en punktliste, en nummereret, en der
// begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et
// citat. Den sidste er den, en note fra Apple Noter er fuld af.
const markdown = [
'# Former',
'',
'- Punkt',
'- Punkt 2',
'',
'1. Nummer',
'2. Nummer 2',
'',
'10. Ti',
'11. Elleve',
'',
'- Ydre',
' - Indre',
'',
'> Et citat',
'> - med en liste i'
].join('\n');
const id = await page.evaluate(async (body) => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body })
});
return (await r.json()).id;
}, markdown);
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('ul > li').first()).toBeVisible();
// Vist som lister, ikke som linjer med bindestreger i.
await expect(ed.locator('ol')).toHaveCount(2);
await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i');
// Den indlejrede ligger inde i sit punkt, ikke ved siden af.
await expect(ed.locator('ul li ul li')).toHaveText('Indre');
// Og den, der begyndte ved ti, gør det stadig.
await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti');
// Rør noten, sådan som en tastning gør, og lad den gemme.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
// Det, der står i filen bagefter, er det, der stod i den før.
//
// Det var her, det gik galt: alt fra det første punkt blev til én linje —
// "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som
// tekst. Ingen havde rørt de linjer.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('- Punkt\n- Punkt 2');
expect(body).toContain('1. Nummer\n2. Nummer 2');
expect(body).toContain('10. Ti\n11. Elleve');
expect(body).toContain('- Ydre\n - Indre');
expect(body).toContain('> Et citat\n> - med en liste i');
expect(trouble).toEqual([]);
});
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
// og resten stod som markup: et img med en onerror på. En note deles gennem et
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Ondsindet\n\n![" onerror="window.__pwned = true](/api/v1/attachments/aaaabbbbccccdddd)\n'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('img')).toHaveCount(1);
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
// den vej, en onerror ville blive kaldt ad.
await page.waitForTimeout(800);
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
const alt = await ed.locator('img').getAttribute('alt');
expect(alt).toContain('onerror');
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
// andet skal stadig være stille.
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
});
test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed).toBeVisible();
// Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil
// på disken — og lagt i både en paste og et drop, som er de to veje ind.
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
for (const kind of ['paste', 'drop']) {
await ed.click();
await page.evaluate(
async ({ kind, png }) => {
const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0));
const file = new File([bytes], `${kind}.png`, { type: 'image/png' });
const dt = new DataTransfer();
dt.items.add(file);
const ed = document.querySelector('.page[contenteditable]');
ed.focus();
ed.dispatchEvent(
kind === 'paste'
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
: new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })
);
},
{ kind, png }
);
await page.waitForTimeout(1200);
}
// To billeder, begge hentet og tegnet — ikke to stykker tekst med ![]( i.
await expect(ed.locator('img')).toHaveCount(2);
const loaded = await ed.locator('img').evaluateAll((els) =>
els.filter((e) => e.complete && e.naturalWidth > 0).length
);
expect(loaded).toBe(2);
// Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en
// data-URL, som ville lægge fotografiet i noteteksten.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2);
expect(body).not.toContain('data:image');
expect(trouble).toEqual([]);
});
+370 -70
View File
@@ -9,17 +9,22 @@
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
* running instance and a session cookie pasted in by hand — and which therefore
* ran rarely enough that the images fell many rounds behind the app they show.
* This one starts the real binary, creates its own account, fills it with the
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
* This one starts the real binary, creates its own two accounts, fills them with
* the demo data, and takes the pictures. Nothing to set up and nothing to remember.
*
* The binary, not the dev server: the screenshots should show what ships. That
* means building the frontend and copying it into cmd/verdande/webbuild first,
* exactly as the smoke tests do — a capture of a stale embedded build would be a
* picture of an app nobody is running.
*
* English, because the people who read the landing page and the docs read English.
* Not by translating anything here: the account is created with an English locale
* and the app is then in English by itself, which also means a string nobody
* translated shows up in a picture instead of hiding until a user finds it.
*/
import { chromium } from '@playwright/test';
import { spawn, execSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
const PORT = 8098;
const baseURL = `http://127.0.0.1:${PORT}`;
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
// Somebody to share with. Half of what the app is for only exists once there are
// two people in it: an assignee on a task, a name in the history, a face in the
// members list, and "Waiting on others" as something other than an empty page.
const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' };
// 1400×900 is what the landing page's <img> declares. A capture at another size
// would be resampled by the browser and look soft next to the text beside it.
const WIDTH = 1400;
const HEIGHT = 900;
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
// The docs and the README read the PNGs; the landing page reads the WebP, which is
// a fifth of the weight over the wire. Both are written from the same capture, so
// the two sets can never drift apart.
const PNG_DIR = join(repo, 'docs', 'screenshots');
const WEBP_DIR = join(repo, 'site', 'screenshots');
/**
* The pictures, and why each one is here.
@@ -54,31 +67,64 @@ const SHOTS = [
// The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
// By address rather than by clicking the first row: the drawer has to be over
// the one task that was given a description, sub-tasks, a file, a reminder and a
// conversation, or the picture shows an empty form and proves nothing.
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
// Scrolled, because the themes are the second panel on the page and a picture
// captioned "five themes" that shows the profile form is a caption about
// nothing.
{ name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes },
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }
];
const NOTE_TITLE = 'Launch meeting — agenda';
async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click();
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.waitForTimeout(600);
}
async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await page.getByRole('button', { name: 'Month', exact: true }).click();
await page.waitForTimeout(800);
}
async function showWeek(page) {
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await page.getByRole('button', { name: 'Week', exact: true }).click();
await page.waitForTimeout(800);
}
async function openFirstTask(page) {
await page.locator('.row').first().click();
async function openMeetingNote(page) {
// By text inside the row rather than by its accessible name: a row's name is its
// title, its date, the first line of its body and the project it is filed in,
// all run together.
await page.locator('button.row', { hasText: NOTE_TITLE }).first().click();
await page.waitForTimeout(800);
}
async function showNoteSource(page) {
await openMeetingNote(page);
await page.getByRole('button', { name: 'Show as code' }).click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
// decides nothing is needed and the picture is of the profile form above it.
//
// `.first()`, because the looks below the themes carry the same heading — one
// panel, two questions, and both of them called Appearance.
await page
.getByRole('heading', { name: 'Appearance' })
.first()
.evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' }));
await page.waitForTimeout(600);
}
@@ -114,29 +160,42 @@ let browser;
try {
await waitForHealth();
browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: WIDTH, height: HEIGHT },
// The same pinning the smoke tests use, and for the same reason: the sign-up
// form sends navigator.language as the account's locale, and every due date
// is resolved in the account's zone.
locale: 'da-DK',
timezoneId: 'Europe/Copenhagen',
deviceScaleFactor: 2
});
// The browser's own language, which is not the same setting as the context's.
// `<input type="date">` is drawn by Chromium, not by the app, and it takes its
// order from this one — so without it the drawer shows an American date inside
// a day-month app, which is the exact thing i18n.svelte.js warns about.
browser = await chromium.launch({ args: ['--lang=en-GB'] });
const context = await browser.newContext(viewing());
const page = await context.newPage();
await page.goto(baseURL);
// The sign-up form is the one screen that is always Danish: it is drawn before
// there is an account to read a language off, and Danish is the fallback. So
// these labels are Danish on purpose, and the app is English from the moment
// the account exists — which is every screen this script photographs.
await page.getByLabel(/Navn/).fill(USER.name);
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
console.log('seeding…');
const ids = await page.evaluate(seed);
const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
mkdirSync(OUT[0], { recursive: true });
mkdirSync(OUT[1], { recursive: true });
console.log('adding the second person…');
const ids2 = await addColleague(browser, ids);
Object.assign(ids, ids2);
await page.reload();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
mkdirSync(PNG_DIR, { recursive: true });
mkdirSync(WEBP_DIR, { recursive: true });
// A page of its own for the WebP encoding, on about:blank: the app's pages send
// a content security policy, and an encoder that borrows whichever page was last
// photographed would be one policy change away from silently producing nothing.
const encoder = await context.newPage();
await encoder.goto('about:blank');
for (const shot of SHOTS) {
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
@@ -151,9 +210,9 @@ try {
await page.waitForTimeout(400);
}
const png = await page.screenshot();
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
console.log(` ${shot.name}.png`);
writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
console.log(` ${shot.name}`);
}
} finally {
await browser?.close();
@@ -166,6 +225,25 @@ try {
rmSync(dataDir, { recursive: true, force: true });
}
/** The browser settings every context here shares. */
function viewing() {
return {
viewport: { width: WIDTH, height: HEIGHT },
// The locale is not a formatting preference: the sign-up form sends
// navigator.language as the account's language, so this is what makes the
// interface, the Inbox's name and every date English. The zone is pinned for
// the same reason the smoke tests pin it — every due date is resolved in it.
locale: 'en-GB',
timezoneId: 'Europe/Copenhagen',
// Dark, because that is what the shots are forced to below. app.html reads
// prefers-color-scheme before first paint, and Playwright's default is light
// — which left the settings page rendering dark with the *light* theme
// ringed as the chosen one.
colorScheme: 'dark',
deviceScaleFactor: 2
};
}
async function waitForHealth() {
for (let i = 0; i < 240; i++) {
try {
@@ -179,6 +257,92 @@ async function waitForHealth() {
throw new Error('the server never came up');
}
/**
* Encodes a PNG as WebP, in the browser that is already open.
*
* Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` —
* would be something to install first, in a script whose whole point is that there
* is nothing to install. Resized on the way through: the landing page shows these
* 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail
* no screen renders.
*/
async function toWebP(encoder, png) {
const base64 = await encoder.evaluate(
async ({ data, width, quality }) => {
const image = new Image();
image.src = `data:image/png;base64,${data}`;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width);
const ctx = canvas.getContext('2d');
// The default resampler leaves the interface's hairlines ragged, which on a
// screenshot of a screenshot is the first thing the eye finds.
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/webp', quality).split(',')[1];
},
// 0.75 is cwebp's default and where these stop getting smaller for anything
// the eye finds: 0.85 is a fifth more bytes for a difference that survives
// only under magnification, and the landing page's first image is the one
// somebody waits for.
{ data: png.toString('base64'), width: 1800, quality: 0.75 }
);
return Buffer.from(base64, 'base64');
}
/**
* Signs the second person up through the invite the seed left behind, has them say
* something, and hands their work back to the first.
*
* A context of its own because a session is a cookie: signing up in the page the
* screenshots are taken from would replace the account they are taken as.
*/
async function addColleague(browser, ids) {
const context = await browser.newContext(viewing());
const page = await context.newPage();
try {
await page.goto(ids.invite);
// Danish labels again, and for the same reason: nobody is signed in yet.
await page.getByLabel(/Navn/).fill(COLLEAGUE.name);
await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
return await page.evaluate(handOver, ids);
} finally {
await context.close();
}
}
/**
* What the second person does, from inside their own session: answer the task they
* were opened on, and take the three they are being waited for.
*
* The assignments are made here rather than by the owner because an editor may
* assign, and doing it from this side proves it. Written as one function because it
* is passed to the browser as source.
*/
async function handOver(ids) {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.status === 204 ? null : r.json();
};
const me = await api('/auth/me', null, 'GET');
for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH');
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
return { colleague: me.id };
}
/**
* The demo data, created through the app's own API from inside the page.
*
@@ -186,11 +350,11 @@ async function waitForHealth() {
* header the API insists on come along without being reconstructed here. Written
* as one function because it is passed to the browser as source.
*
* The content is ordinary Danish household and work tasks. Screenshots of "Task
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
* thing it is for.
* Ordinary English household and work tasks, the same shape the Danish set had.
* Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
* is any good at the thing it is for.
*/
async function seed() {
async function seed({ colleague, noteTitle }) {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
@@ -210,63 +374,199 @@ async function seed() {
};
// Two headings over the projects, which is what the sidebar grew for.
const arbejde = await api('/project-groups', { name: 'Arbejde' });
const privat = await api('/project-groups', { name: 'Privat' });
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
const work = await api('/project-groups', { name: 'Work' });
const home = await api('/project-groups', { name: 'Home' });
await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
const work = await api('/projects', {
name: 'Sæsonstart',
const launch = await api('/projects', {
name: 'Autumn launch',
color: 'indigo',
view_mode: 'board',
group_id: arbejde.id
group_id: work.id
});
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id });
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
const tasks = [
// One thing that slipped. Today with an overdue band on it is what a Thursday
// actually looks like, and it is the only way to photograph the band at all.
{
content: 'Chase the printer about the labels',
due_date: day(-2),
priority: 2,
project_id: launch.id,
section_id: inFlight.id,
labels: ['waiting']
},
// Today, in the Inbox and across the projects: the Today view is the first
// picture and it has to look like a real day.
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
{ content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
{
content: 'Skriv oplæg til sæsonmødet',
content: 'Write the opening notes for the launch meeting',
due_date: day(0),
priority: 2,
project_id: work.id,
project_id: launch.id,
section_id: planning.id,
duration_min: 90
duration_min: 90,
labels: ['meeting']
},
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
{ content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// The next couple of weeks, so Kommende and the month grid have something in
// The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
{ content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
{ content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
{ content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
{ content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
{ content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
{ content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id },
// A few without dates, so the board has cards that are not all deadlines.
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
{ content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
{ content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
{ content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] }
];
for (const task of tasks) await api('/tasks', task);
const made = {};
for (const task of tasks) made[task.content] = await api('/tasks', task);
// A saved filter, because the sidebar shows them and an empty heading reads as
// a feature that does not work.
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
return { work: work.id, house: house.id, garden: garden.id };
// The one task that is photographed open. Everything the drawer can hold, so the
// picture answers "what is in there" rather than showing an empty form.
const detail = made['Write the opening notes for the launch meeting'];
await api(
`/tasks/${detail.id}`,
{
description:
'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.'
},
'PATCH'
);
for (const content of [
'Pull the numbers from last season',
'Ask Mette for the packaging quotes',
'Book the room'
]) {
await api('/tasks', { content, parent_id: detail.id, project_id: launch.id });
}
await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 });
await api(`/tasks/${detail.id}/comments`, {
body: 'Keeping it to three points. Anything else goes on the list for the week after.'
});
// A file on the task, because "comments, attachments" is a claim and an empty
// Files heading is the opposite of proof.
const form = new FormData();
form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf');
await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form });
// The notes, newest last: the list opens on what was touched most recently, so
// the one the screenshots open is the one written last.
const notes = [
{
body: `# Reading list
- *Shape Up* — the six-week bit, not the pitch bit
- The SQLite WAL paper, again
- Anything at all about pricing`
},
{
project_id: garden.id,
body: `# The bulb order
Two hundred, split three ways, in before the first frost:
1. Narcissus along the south wall
2. Alliums in the bed by the shed
3. Crocus wherever there is room
The bed by the shed is heavy — grit in the hole, or they rot. #Garden`
},
{
body: `# What the roofer said
Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave
over a winter either.
- Quote by the end of the month
- Two days' work, scaffold on the north side
- Ask whether the gutters can be done at the same time
#House`
},
{
project_id: launch.id,
body: `# Price list — what changes
Nothing has been looked at since March.
- 250 g bag: **79 → 85**
- 1 kg bag: **265 → 279**
- Subscription: **199 → 209**
Subscribers keep the old price for three months. That is the whole of the goodwill
budget and it is worth it.`
},
{
pinned: true,
project_id: launch.id,
body: `# ${noteTitle}
Thursday, fifteen minutes, no slides.
## What shipped over the summer
- The new packaging, minus the labels
- A CalDAV feed nobody asked for and everybody uses
## What is left
- Labels. Still with the printer, chased on Thursday
- The price list: [[Price list — what changes]]
> If it is not on the list by Friday it is not in the launch.
The build we cut the release from:
\`\`\`bash
cd web && npm run build && cd ..
go build -tags embedweb -o verdande ./cmd/verdande
\`\`\`
Room is booked. Coffee is **not** my job this week.
The opening notes are a task, so they can be ticked off: task:${detail.id}`
}
];
for (const note of notes) await api('/notes', note);
// Shared with somebody, and an invite left standing to the instance itself: two
// of the three states the members page has, and the one it never showed before.
const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' });
await api('/users', { email: 'anders@example.dk' });
return {
work: launch.id,
detail: detail.id,
invite: invite.link,
delegate: [
made['Send the minutes round'].id,
made['Update the price list'].id,
made['Find a new packaging supplier'].id
]
};
}
+73 -1
View File
@@ -363,7 +363,10 @@ button {
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
/* Ingen radius her. En omrids følger elementets egen afrunding af sig selv, og
at tvinge en anden på tegner en firkantet bue uden om en rund kant — to
streger, hvor der skulle være én. Det ses tydeligst om et pilleformet
søgefelt. */
}
::selection {
@@ -444,3 +447,72 @@ button {
white-space: nowrap;
border: 0;
}
/* --- looks -----------------------------------------------------------------
*
* A second axis, beside the colour themes.
*
* A theme answers "how bright"; a look answers "what does it feel like to read".
* Typeface, how tight the rows sit, how round the corners are. They are separate
* because they are separate questions: somebody who wants a serif face at night
* should not have to give up the dark palette to get it.
*
* It is also the honest answer to looking like something else. The default is
* deliberately familiar — that is what was asked for — but a program with one
* possible appearance has borrowed a shape it cannot give back. With looks, the
* resemblance is one setting among several rather than the only thing it can be.
*/
/* Serif, roomier, softer. For reading and writing rather than scanning — which is
what a program with notes in it is half the time. */
[data-look='rolig'] {
--font: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
--text-base: 1.0625rem;
--text-sm: 0.9375rem;
--radius: 10px;
--radius-lg: 16px;
--s2: 0.5625rem;
--s3: 0.875rem;
--s4: 1.25rem;
}
[data-look='rolig'] body {
line-height: 1.65;
letter-spacing: 0.006em;
}
/* Tighter. More rows on a screen, squarer corners, less air — for a long list
read at a glance rather than a page read a paragraph at a time. */
[data-look='taet'] {
--text-base: 0.875rem;
--text-sm: 0.78125rem;
--text-xs: 0.6875rem;
--radius: 5px;
--radius-lg: 8px;
--radius-sm: 3px;
--s1: 0.1875rem;
--s2: 0.375rem;
--s3: 0.5625rem;
--s4: 0.75rem;
}
[data-look='taet'] body {
line-height: 1.4;
}
/* One face for everything, including the prose. Not a joke setting: a monospaced
list of tasks lines up in a way a proportional one never does, and some people
read code all day and want the same shapes here. */
[data-look='terminal'] {
--font: var(--font-mono);
--text-base: 0.875rem;
--text-sm: 0.8125rem;
--radius: 3px;
--radius-lg: 4px;
--radius-sm: 2px;
}
[data-look='terminal'] body {
line-height: 1.5;
letter-spacing: -0.01em;
}
+6
View File
@@ -22,6 +22,12 @@
stored ??
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = theme;
// The look changes the typeface and the spacing, so it has to be on
// before first paint too — otherwise the page is laid out once in one
// face and again in another, which is a visible jump.
const look = localStorage.getItem('verdande:look');
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
} catch (e) {
// Private browsing can refuse localStorage; the dark default stands.
}
+26
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);
@@ -188,6 +195,7 @@ export const api = {
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setNavOrder: (order) => put('/auth/nav-order', { order }),
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
@@ -226,6 +234,9 @@ export const api = {
gmailClient: () => get('/gmail/client'),
setGmailClient: (data) => put('/gmail/client', data),
beacon: () => get('/beacon/settings'),
setBeacon: (body) => put('/beacon/settings', body),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
// A link rather than a fetch: the browser's own download handles a file of
@@ -333,6 +344,7 @@ export const api = {
deleteComment: (id) => del(`/comments/${id}`),
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
@@ -360,6 +372,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 = {}) => {
@@ -390,6 +412,10 @@ export const api = {
importTodoist: (file) => upload('/import/todoist', file),
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
+1 -1
View File
@@ -64,7 +64,7 @@
// The unsectioned column comes first, as it does in the list view: it is where
// quick add puts things, so it is where new work appears.
let columns = $derived([
{ id: '', name: 'Uden sektion' },
{ id: '', name: t('project.noSection') },
...sections.map((s) => ({ id: s.id, name: s.name }))
]);
+209 -5
View File
@@ -49,7 +49,19 @@
* flight swallows the click that would page. A week that straddles the
* 31st and the 1st has both days in the same row.
*/
span = 'month'
span = 'month',
/**
* Events read from somewhere else, laid over the same grid.
*
* An event is not a task, and the cell has to say so. It has a span of time
* rather than a day, it cannot be ticked off, and — while the connection is
* read-only — it cannot be moved: verdande holds a copy of somebody else's
* calendar, and a chip that lets itself be dragged is a promise the server
* has no way to keep.
*
* Empty everywhere but Kalender, so no other view pays for it.
*/
events = []
} = $props();
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
@@ -137,6 +149,66 @@
(!projectId || t.project_id === projectId)
);
/**
* Which cells an event covers, rather than where it starts. A trip from Friday
* to Monday is in all four, and asking only about the start day would draw it
* once and leave three days looking clear.
*
* Ordered here rather than trusted from the caller. The server does sort, but a
* cell that shows 14:00 above 09:15 does not look like a list in the wrong
* order — it looks like the clock on the chip is wrong, which is a far worse
* thing to have to rule out. All-day first, because an all-day event is a band
* over the whole day rather than something that happens at a point in it.
*/
const eventsOn = (date) => {
const day = iso(date);
return events
.filter((e) => e.start_day <= day && e.end_day >= day)
.toSorted((a, b) => {
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
return (a.starts_at ?? '').localeCompare(b.starts_at ?? '');
});
};
/**
* The time on the chip, and only where it means something.
*
* An all-day event has no time. A timed one shows its start, and only on the
* day it starts: a meeting that runs past midnight would otherwise claim to
* begin at 20:00 on both days.
*
* The clock is read out of the string rather than parsed into a Date. Google
* writes the event's own offset into it, and handing that to the browser asks
* *this device* what time it is — so a phone in another time zone would show a
* Copenhagen meeting an hour out.
*/
function timeOn(event, date) {
if (event.all_day || !event.starts_at) return '';
if (event.start_day !== iso(date)) return '';
return event.starts_at.slice(11, 16);
}
/**
* The calendar's own colour, or nothing.
*
* Checked rather than trusted, even though it comes from Google: this goes into
* an inline `style`, and a value that is not a colour is a value that can close
* the declaration and open another. A hex triple is the whole of what Google
* sends, so anything else is not a colour that failed to render — it is a
* string that has no business being here.
*/
const HEX = /^#[0-9a-f]{3,8}$/i;
const swatch = (colour) => (HEX.test(colour ?? '') ? colour : '');
/**
* Whether the event links back to Google.
*
* `https:` and nothing else. An href is where a `javascript:` URL becomes a
* click that runs, and the address in this field was written by a service, not
* by the person looking at it.
*/
const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : '');
const monthName = $derived(
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
);
@@ -232,7 +304,14 @@
<div class="grid">
{#each grid as date (date.toISOString())}
{@const tasks = tasksOn(date)}
{@const dayEvents = eventsOn(date)}
<!-- Events first, then whatever room is left goes to tasks. An event is
fixed to a time somebody else chose; a task is only dated, and if
something in a full cell has to fall behind "+3 mere", it should be
the one that can be moved. -->
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
{@const hidden =
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
<!-- A week has no outside: every day in it is the week you asked for. -->
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -254,7 +333,36 @@
{/if}
<span class="number">{date.getDate()}</span>
{#each tasks.slice(0, chipLimit) as task (task.id)}
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
{@const link = linkOf(event)}
{@const label = t('cal.eventCannotMove', {
name: event.summary,
calendar: event.calendar_name || t('cal.title')
})}
<!-- Not draggable, and it says so twice: the attribute stops the
browser dragging an anchor of its own accord, and the title says
why to anybody who tries. The drop targets would refuse it anyway
— they only accept the task MIME type — but a chip that lifts off
the page and then will not land reads as a bug rather than as a
rule. It is also not a button: there is nothing here to complete,
and a checkbox next to a meeting is an offer verdande cannot keep. -->
<svelte:element
this={link ? 'a' : 'span'}
class="event"
class:allday={event.all_day}
draggable="false"
href={link || undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noreferrer noopener' : undefined}
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
>
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
{event.summary}
</svelte:element>
{/each}
{#each tasks as task (task.id)}
<!-- Draggable as well as clickable: within a month, moving something
to another day is the whole reason to be looking at a month. The
browser needs a few pixels of movement before it calls a press a
@@ -271,8 +379,8 @@
</button>
{/each}
{#if tasks.length > chipLimit}
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
{#if hidden > 0}
<span class="more">{t('view.more', { n: hidden })}</span>
{/if}
</div>
{/each}
@@ -451,6 +559,58 @@
background: var(--surface);
}
/* An event reads as a different kind of thing at a glance, before any of the
words are read: a solid bar of colour where a task is a card with a coloured
edge. That is the whole distinction the eye needs — a task is something you
can move and finish, an event is something that is simply happening.
The colour is Google's own for that calendar, so two calendars in one grid
are told apart by the colour the person already knows them by. `--line-strong`
is the fallback for a calendar Google gave no colour, which is rare and not
worth a second decision. */
.event {
--event-colour: var(--line-strong);
font-size: var(--text-xs);
text-align: left;
display: block;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: var(--surface-sunken);
color: var(--ink-muted);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* A drag that cannot land is worse than one that cannot start. The grab
cursor is the promise; this withholds it. */
cursor: default;
}
.event[href] {
cursor: pointer;
}
.event:hover {
background: var(--surface);
color: var(--ink);
}
/* An all-day event has no clock to show, so the colour carries it instead:
filled rather than merely edged, which is what an all-day band looks like in
every calendar anybody has used. */
.event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
}
.at {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
margin-right: 2px;
}
.more {
font-size: var(--text-xs);
color: var(--ink-faint);
@@ -482,6 +642,29 @@
background: var(--p3);
}
/* Counted rather than read, like the task chips beside it — a 45px column
fits no title. A bar rather than a dot, so an event is still tellable from
a task at a glance: what the month grid says on a phone is "how much is
on this day, and how much of it is somebody else's". */
.event {
font-size: 0;
padding: 0;
height: 4px;
border-radius: 1px;
border-left: none;
box-shadow: none;
background: var(--event-colour);
}
.event.allday {
background: var(--event-colour);
box-shadow: none;
}
.at {
display: none;
}
.more {
font-size: 10px;
}
@@ -536,6 +719,27 @@
background: var(--surface-raised);
}
/* Readable again in the week, for the reason the task chips are: a
full-width row has room for a title, and a week somebody opened on
purpose is one they want to read rather than count. */
.calendar.week .event {
font-size: var(--text-xs);
height: auto;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: var(--surface-sunken);
}
.calendar.week .event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
}
.calendar.week .at {
display: inline;
}
.calendar.week .more {
font-size: var(--text-xs);
}
+13 -2
View File
@@ -9,6 +9,7 @@
let query = $state('');
let tasks = $state([]);
let projects = $state([]);
let notes = $state([]);
let selected = $state(0);
let input;
let controller = null;
@@ -32,13 +33,21 @@
if (!value) {
tasks = [];
projects = [];
notes = [];
return;
}
timer = setTimeout(async () => {
try {
const result = await api.search(value);
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
// søger ⌘K kun i halvdelen af programmet, hvilket er svært at gætte
// som bruger: feltet ser ud til at kunne finde alt.
const [result, found] = await Promise.all([
api.search(value),
api.notes({ q: value }).catch(() => ({ notes: [] }))
]);
tasks = result.tasks ?? [];
projects = result.projects ?? [];
notes = found.notes ?? [];
selected = 0;
} catch {
// A failed search shows nothing rather than an error inside a
@@ -49,6 +58,7 @@
let results = $derived([
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
kind: 'task',
id: t.id,
@@ -60,6 +70,7 @@
function choose(item) {
open = false;
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
@@ -109,7 +120,7 @@
onclick={() => choose(item)}
onmouseenter={() => (selected = i)}
>
<span class="kind">{item.kind === 'project' ? 'Projekt' : 'Opgave'}</span>
<span class="kind">{t('palette.' + item.kind)}</span>
<span class="label">{item.label}</span>
</button>
</li>
File diff suppressed because it is too large Load Diff
+271 -54
View File
@@ -1,7 +1,7 @@
<script>
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
@@ -117,6 +117,93 @@
}
// --- the fixed views ---------------------------------------------------------
// Delegated is only offered where delegating is possible at all: on an instance
// with one person that view can never have anything in it, and a permanent empty
// entry is a question the sidebar keeps asking and answering "no".
let navItems = $derived(
[
{ key: 'today', href: '/', label: 'nav.today' },
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
app.projects.some((p) => p.shared)
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
: null,
// No href: the inbox is a project and is drawn by projectRow, which knows
// its id. It is in this list only so it can be dragged with the others.
{ key: 'inbox', label: 'nav.inbox' },
// Noter hører til her, og bærer et ark frem for en prik.
//
// Den stod før for sig selv over en streg, hvilket sagde det rigtige og
// kostede halvfjerds pixels: en streg, luft over og luft under, og
// projekterne skubbet så langt ned, at de to første var alt, man så uden
// at rulle. Grunden til at skille den ud var, at den ellers læste som
// endnu et filter — men det, der gør den til et filter for øjet, er
// prikken, ikke pladsen. Et andet mærke siger "en anden slags ting" og
// koster ingenting.
//
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
// den et andet sted her, ser en ny konto én rækkefølge og alle
// eksisterende en anden.
{ key: 'notes', href: '/noter', label: 'notes.title' },
// Kalenderen bærer et gitter, af samme grund som Noter bærer et ark: den
// er ikke endnu et filter over de samme opgaver. Den er de samme opgaver
// med noget andet lagt over — Googles begivenheder — og en prik ville
// sige, at den hørte til blandt I dag og Kommende.
//
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
// den et andet sted her, ser en ny konto én rækkefølge og alle
// eksisterende en anden.
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
].filter(Boolean)
);
let orderedNav = $derived(
app
.navOrder(navItems.map((i) => i.key))
.map((key) => navItems.find((i) => i.key === key))
.filter(Boolean)
);
let draggingNav = $state(null);
let overNav = $state(null);
function onNavDragStart(event, key) {
draggingNav = key;
event.dataTransfer.effectAllowed = 'move';
// A payload, because Firefox refuses to start a drag without one.
event.dataTransfer.setData('text/plain', key);
}
function onNavDragOver(event, key) {
if (!draggingNav || key === draggingNav) return;
event.preventDefault();
overNav = key;
}
function clearNavDrag() {
draggingNav = null;
overNav = null;
}
async function onNavDrop(event, key) {
const moved = draggingNav;
// Checked before anything is swallowed. Stopping the event first meant a task
// dragged onto I dag never reached the handler that gives it a date — the row
// took the drop and then discovered it was not for it.
if (!moved || moved === key) {
clearNavDrag();
return;
}
event.preventDefault();
event.stopPropagation();
clearNavDrag();
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
keys.splice(keys.indexOf(key), 0, moved);
await app.setNavOrder(keys);
}
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
@@ -271,16 +358,27 @@
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
function onTaskDragOver(event, project) {
if (!carries(event, TASK) || !canReceive(project)) return;
if (!(carries(event, TASK) || carries(event, NOTE)) || !canReceive(project)) return;
accept(event);
overProject = project.id;
}
async function onTaskDrop(event, project) {
event.preventDefault();
const id = dragged(event, TASK);
overProject = null;
if (!id || !canReceive(project)) return;
if (!canReceive(project)) return;
// The same target takes both. A note dropped on a project is filed there,
// which is also how it gets shared — one gesture, one meaning, and the
// sidebar already reads as "put this here".
const noteID = dragged(event, NOTE);
if (noteID) {
await app.moveNoteToProject(noteID, project.id);
return;
}
const id = dragged(event, TASK);
if (!id) return;
await app.moveToProject(id, project.id);
}
@@ -340,56 +438,93 @@
<div class="views">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/"
class:active={current === '/'}
class:receiving={overToday}
onclick={onnavigate}
ondragover={(e) => {
if (!carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span>
{t('nav.today')}
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.upcoming')}
</a>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
person this view can never have anything in it, and a permanent empty
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.delegated')}
</a>
{/if}
<!-- After the three views of what is left, because it is the one that is not
about what is left. It earns a permanent place rather than a toggle in
each view: somebody looking for a task they closed by mistake does not
know which view they closed it in. -->
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('done.title')}
</a>
{#if app.inbox}
{@render projectRow(app.inbox, false)}
<!-- Notes sit with the fixed places rather than among the projects: it is a
place you go, not a thing filed under one. -->
<a href="/noter" class="fixed" onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
{/if}
<!-- The fixed views, in the order this person put them.
Drawn from a list rather than written out, because they can be reordered
now and a hand-written run cannot be. Unknown keys are dropped and new
ones appended, so adding a view later does not strand anybody's order. -->
{#each orderedNav as item (item.key)}
{#if item.key === 'inbox'}
{#if app.inbox}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
{@render projectRow(app.inbox, false)}
</div>
{/if}
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
<!-- I dag also takes a task dropped on it — the most-made rescheduling
there is, and the sidebar is where the pointer already is. The
handlers were lost when this run became a list; the test caught
it, which is the second time that drop has earned its test. -->
<a
href={item.href}
class:active={current === item.href}
class:receiving={item.key === 'today' && overToday}
onclick={onnavigate}
ondragover={(e) => {
if (item.key !== 'today' || !carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
>
{#if item.key === 'notes'}
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M4 1.5h5.2L12.5 5v9.5H4z"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linejoin="round"
/>
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
</svg>
{:else if item.key === 'calendar'}
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<rect
x="2.2"
y="3.2"
width="11.6"
height="10.6"
rx="1.4"
fill="none"
stroke="currentColor"
stroke-width="1.4"
/>
<path
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
{:else}
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
{/if}
{t(item.label)}
</a>
</div>
{/if}
{/each}
</div>
<!-- One row for every project the sidebar shows — your own, the Inbox and the
@@ -397,6 +532,29 @@
three copies of that would be three chances to fix a bug in two of them.
Only your own reorder: `sort_order` is a column on the project, so a shared
one stays where its owner put it. -->
<!-- A handle rather than the whole row.
Making the row draggable made everything inside it draggable too, and the
sidebar is already a drop target for tasks — a task dragged onto I dag
stopped landing, because the row had claimed the gesture. A grip claims
nothing until it is grabbed. -->
<!-- Håndtaget har ingen prik i sig længere.
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
draggable, ville alt indeni også blive det — og en opgave trukket hen på
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
er hele forklaringen. -->
{#snippet grip(key)}
<span
class="grip"
draggable="true"
ondragstart={(e) => onNavDragStart(e, key)}
ondragend={clearNavDrag}
aria-hidden="true"
></span>
{/snippet}
{#snippet projectRow(project, sortable)}
<a
href="/projekt/{project.id}"
@@ -873,6 +1031,65 @@
gap: 1px;
}
.navrow {
position: relative;
display: flex;
align-items: center;
}
/* Takes no room until it is wanted: it sits in the padding the row already has,
so nothing moves when it appears. */
.grip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 14px;
cursor: grab;
}
.grip:active {
cursor: grabbing;
}
.navrow > :global(a) {
flex: 1;
min-width: 0;
}
.navrow.dragging {
opacity: 0.4;
}
/* A line where it will land, not a box around what it will land on: the first
reads as a position and the second as a target. */
.navrow.over::before {
content: '';
position: absolute;
top: -1px;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
border-radius: 1px;
}
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
— mærket skifter, kolonnen gør ikke. */
.mark {
width: 13px;
height: 13px;
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
margin: 0 -3.5px;
color: var(--ink-faint);
flex: none;
}
a.active .mark {
color: var(--accent);
}
.group-head {
display: flex;
align-items: center;
+44 -13
View File
@@ -14,6 +14,7 @@
import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props();
@@ -29,20 +30,10 @@
/** Who the task can be given to. Only ever more than one on a shared project. */
let members = $state([]);
// The rules worth a menu entry. Anything more specific is typed into the
// title, where the parser understands far more than a list ever could.
const REPEATS = [
{ rule: '', label: 'Aldrig' },
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
];
let subtasks = $state([]);
let comments = $state([]);
let attachments = $state([]);
let notes = $state([]);
let reminders = $state([]);
let newSubtask = $state('');
@@ -80,6 +71,7 @@
subtasks = [];
comments = [];
attachments = [];
notes = [];
reminders = [];
const id = task.id;
@@ -96,6 +88,11 @@
api.listReminders(id)
.then((r) => id === seededId && (reminders = r.reminders))
.catch(() => {});
// Quietly, because a task with no notes is the ordinary case and a failure
// here must not put an error in front of somebody reading a task.
api.notesLinking('task', id)
.then((r) => id === seededId && (notes = r.notes ?? []))
.catch(() => {});
});
/**
@@ -445,13 +442,13 @@
<label for="repeat">{t('detail.repeats')}</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option>
<option value={option.rule}>{t(option.label)}</option>
{/each}
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
showing it rather than snapping to the nearest preset is the
difference between a menu and a menu that quietly edits. -->
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
{/if}
</select>
</div>
@@ -540,6 +537,22 @@
</form>
</section>
<!-- What has been written about this task.
The other direction of the same link: a note that mentions a task shows
it, and this is the task showing the note. Without it the connection only
works if you happen to be reading the note — which is the half nobody
needs help with. -->
{#if notes.length}
<section class="linked-notes">
<h3>{t('notes.title')}</h3>
{#each notes as note (note.id)}
<a class="linked-note" href="/noter?note={note.id}">
<strong>{note.title || t('notes.untitled')}</strong>
</a>
{/each}
</section>
{/if}
<section>
<h3>{t('detail.comments')}</h3>
@@ -582,6 +595,24 @@
</aside>
<style>
.linked-note {
display: block;
padding: var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
}
.linked-note:hover {
background: var(--surface);
color: var(--ink);
}
.linked-note strong {
font-size: var(--text-sm);
font-weight: 560;
}
.scrim {
position: fixed;
inset: 0;
+7 -4
View File
@@ -10,9 +10,12 @@
*/
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
let { task, onedit } = $props();
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
let leaving = $state(false);
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
@@ -84,7 +87,7 @@
<span class="description">{task.description}</span>
{/if}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
@@ -102,15 +105,15 @@
{due.text}{#if time}&nbsp;{time}{/if}
</span>
{/if}
{#if task.recurrence_text}
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
{#if repeats}
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 01-4 4H3" />
</svg>
{task.recurrence_text}
{repeats}
</span>
{/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells
+1
View File
@@ -17,6 +17,7 @@
export const TASK = 'application/x-verdande-task';
export const PROJECT = 'application/x-verdande-project';
export const GROUP = 'application/x-verdande-group';
export const NOTE = 'application/x-verdande-note';
/** Starts a drag carrying one id of the given kind. */
export function startDrag(event, type, id) {
+32 -24
View File
@@ -1,40 +1,48 @@
/**
* What each recorded activity event is called.
*
* The keys are the server's and they are stable; this is the only place they
* become Danish. It lives in its own module because two surfaces read the same log
* now — a project's own history panel, and the administrator's instance-wide audit
* page — and two copies of nineteen strings is two places for them to drift apart.
* The keys are the server's and they are stable; this is the only place they get a
* name. It lives in its own module because two surfaces read the same log now — a
* project's own history panel, and the administrator's instance-wide audit page —
* and two copies of twenty strings is two places for them to drift apart.
*
* The names themselves are in the dictionaries rather than here. They were written
* out in Danish, which made the whole of both history pages Danish for somebody
* reading the rest of the app in English.
*
* An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it
* happened.
*/
import { t } from './i18n.svelte.js';
export const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
'project.created': 'event.projectCreated',
'project.updated': 'event.projectUpdated',
'project.deleted': 'event.projectDeleted',
'project.imported': 'event.projectImported',
'section.created': 'event.sectionCreated',
'section.updated': 'event.sectionUpdated',
'section.deleted': 'event.sectionDeleted',
'member.invited': 'event.memberInvited',
'member.added': 'event.memberAdded',
'member.removed': 'event.memberRemoved',
'member.role_changed': 'event.memberRoleChanged',
'task.created': 'event.taskCreated',
'task.updated': 'event.taskUpdated',
'task.completed': 'event.taskCompleted',
'task.reopened': 'event.taskReopened',
'task.moved': 'event.taskMoved',
'task.deleted': 'event.taskDeleted',
'task.split': 'event.taskSplit',
'comment.created': 'event.commentCreated',
'attachment.added': 'event.attachmentAdded'
};
/** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) {
return EVENTS[event] ?? event;
const key = EVENTS[event];
return key ? t(key) : event;
}
/** The bit of context an entry carries, when it has one worth reading. */
+118
View File
@@ -0,0 +1,118 @@
/**
* Farver til en kodeblok.
*
* Ikke et bibliotek: et fuldt syntaksbibliotek er hundredvis af kilobytes for at
* gøre nogle ord blå i en note. Det her er et lille sæt regler pr. sprog, der
* rammer det, øjet leder efter — strenge, tal, kommentarer, nøgleord — og som er
* ligeglad med resten. En farve for lidt er ingenting; en forkert er heller ikke
* meget, når teksten stadig står der, som den blev skrevet.
*
* Reglerne prøves i rækkefølge, og den første, der rammer, vinder. Strenge og
* kommentarer står derfor først: et nøgleord inde i en streng er ikke et nøgleord.
*/
const COMMON = {
string: /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/,
number: /^\b\d+(\.\d+)?\b/
};
const LANGUAGES = {
yaml: [
['comment', /^#[^\n]*/],
['string', COMMON.string],
// Nøglen, som er det, man scanner efter i en YAML-fil.
['key', /^[ \t]*[-\w.]+(?=\s*:)/],
['punct', /^[:\-|>]/],
['number', COMMON.number],
['bool', /^\b(true|false|null|yes|no|on|off)\b/i]
],
json: [
['string', COMMON.string],
['punct', /^[{}[\],:]/],
['number', COMMON.number],
['bool', /^\b(true|false|null)\b/]
],
bash: [
['comment', /^#[^\n]*/],
['string', COMMON.string],
// Prompten er ikke kode, men den er det første øjet finder i en udskrift.
['prompt', /^\S+@\S+:[^$#]*[$#]/],
['variable', /^\$\{?\w+\}?/],
['keyword', /^\b(if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|sudo)\b/],
['flag', /^\s-{1,2}[\w-]+/],
['number', COMMON.number]
],
go: [
['comment', /^\/\/[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(func|package|import|var|const|type|struct|interface|if|else|for|range|return|defer|go|chan|select|switch|case|default|map|nil|true|false)\b/],
['number', COMMON.number]
],
js: [
['comment', /^\/\/[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(const|let|var|function|return|if|else|for|while|import|export|from|class|new|await|async|try|catch|throw|typeof|null|undefined|true|false)\b/],
['number', COMMON.number]
],
sql: [
['comment', /^--[^\n]*/],
['string', COMMON.string],
['keyword', /^\b(select|from|where|insert|into|values|update|set|delete|create|table|index|join|left|inner|on|and|or|not|null|primary|key|references|order|by|group|limit)\b/i],
['number', COMMON.number]
]
};
// Det, folk skriver, mod det, filen hedder.
const ALIASES = {
sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash', terminal: 'bash',
yml: 'yaml', javascript: 'js', ts: 'js', typescript: 'js', golang: 'go'
};
const escape = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* Gætter sproget, når blokken ikke siger det.
*
* Med vilje forsigtigt: at gætte forkert farver de forkerte ord, og en
* terminaludskrift uden farver er stadig læselig. Kun mønstre, der er svære at
* forveksle, tæller med.
*/
export function guessLanguage(code) {
if (/^\s*[{[]/.test(code) && /["}\]]\s*$/.test(code.trim())) return 'json';
if (/^\s*\S+@\S+:.*[$#]/m.test(code)) return 'bash';
if (/^\s*(package|func)\s+\w/m.test(code)) return 'go';
if (/^\s*[-\w.]+:\s*(\S|$)/m.test(code) && !/[;{}]/.test(code)) return 'yaml';
if (/^\s*(SELECT|INSERT|UPDATE|CREATE TABLE)\b/im.test(code)) return 'sql';
return '';
}
/** Kode til HTML med spans. Ukendt sprog giver teksten uændret, blot undsluppet. */
export function highlight(code, language) {
const rules = LANGUAGES[ALIASES[language] ?? language];
if (!rules) return escape(code);
let out = '';
let rest = code;
let guard = 0;
while (rest && guard++ < 20000) {
let matched = false;
for (const [kind, re] of rules) {
const m = re.exec(rest);
if (!m || !m[0]) continue;
out += `<span class="tok-${kind}">${escape(m[0])}</span>`;
rest = rest.slice(m[0].length);
matched = true;
break;
}
if (!matched) {
// Ét tegn frem. Langsommere end at springe til næste interessante sted og
// umuligt at få til at gå i stå, hvilket er den rigtige byttehandel i noget,
// der kører på hvert tastetryk i en note, nogen sidder og skriver i.
out += escape(rest[0]);
rest = rest.slice(1);
}
}
return out + escape(rest);
}
+141 -1
View File
@@ -16,6 +16,50 @@
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Del i',
'notes.private': 'Kun mig',
'notes.shared': 'Noten er delt med projektets folk.',
'notes.unshared': 'Noten er din igen.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
'notes.bold': 'Fed',
'notes.italic': 'Kursiv',
'notes.underline': 'Understreget',
'notes.sortBy': 'Rækkefølge',
'notes.sortUpdated': 'Senest rørt',
'notes.sortCreated': 'Oprettet',
'notes.sortTitle': 'Navn',
'beacon.title': 'Tælling af installationer',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.send': 'Send en anonym daglig melding',
'beacon.exactly': 'Præcis det her — ikke mere:',
'beacon.lastPing': 'Sidst sendt: {when}',
'beacon.collector': 'Modtagerens adresse',
'beacon.collectorHint': 'Hvor meldingen sendes hen. Lad den stå, medmindre du kører din egen tæller.',
'beacon.beCollector': 'Denne instans er tælleren',
'beacon.receiving': 'Modtager anonyme meldinger — id og version, aldrig en IP.',
'beacon.total': 'I alt',
'beacon.active7': 'Aktive 7 dage',
'beacon.active30': 'Aktive 30 dage',
'beacon.byVersion': 'Efter version (30 dage):',
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
'notes.styleTitle': 'Titel',
'notes.styleHeading': 'Overskrift',
'notes.styleSubheading': 'Underrubrik',
'notes.styleBody': 'Brødtekst',
'notes.styleMono': 'Monotype',
'notes.styleQuote': 'Blokcitat',
'notes.styleBullet': 'Punktliste',
'notes.styleNumbered': 'Nummereret liste',
'notes.favourite': 'Gør til favorit',
'notes.unfavourite': 'Fjern som favorit',
'notes.syntaxProject': 'projekt',
'notes.syntaxNote': 'en anden note',
'notes.yesterday': 'i går',
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
@@ -29,10 +73,14 @@ export const da = {
'notes.saved': 'Gemt',
'notes.delete': 'Slet',
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
'palette.project': 'Projekt',
'palette.task': 'Opgave',
'palette.note': 'Note',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
'nav.inbox': 'Indbakke',
'nav.calendar': 'Kalender',
'nav.settings': 'Indstillinger',
'nav.signOut': 'Log ud',
'nav.main': 'Hovedmenu',
@@ -124,6 +172,16 @@ export const da = {
'account.appearance': 'Udseende',
'account.appearanceHint':
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
'account.look': 'Udseende',
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
'look.verdande': 'Verdande',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
'look.taet': 'Tæt',
'look.taetNote': 'Flere rækker på skærmen, mindre luft.',
'look.terminal': 'Terminal',
'look.terminalNote': 'Én bredde til det hele. Alt flugter.',
'account.password': 'Adgangskode',
'account.passwordHint':
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
@@ -261,6 +319,9 @@ export const da = {
'users.taskOne': 'opgave',
'users.taskMany': 'opgaver',
'users.never': 'aldrig set',
'users.toProject': 'til {name}',
'users.toInstance': 'til instansen',
'users.invitedBy': 'inviteret af {name}',
// --- settings: history and errors -----------------------------------------------
'history.title': 'Historik',
@@ -276,6 +337,30 @@ export const da = {
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
'history.nothing': 'Der er ikke registreret noget endnu.',
'history.deletedAccount': 'En slettet konto',
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
// events.js; her står de ord, de bliver til.
'event.projectCreated': 'oprettede projektet',
'event.projectUpdated': 'ændrede projektet',
'event.projectDeleted': 'slettede projektet',
'event.projectImported': 'importerede projektet',
'event.sectionCreated': 'oprettede en sektion',
'event.sectionUpdated': 'ændrede en sektion',
'event.sectionDeleted': 'slettede en sektion',
'event.memberInvited': 'inviterede',
'event.memberAdded': 'tilføjede',
'event.memberRemoved': 'fjernede',
'event.memberRoleChanged': 'ændrede rollen for',
'event.taskCreated': 'oprettede en opgave',
'event.taskUpdated': 'ændrede en opgave',
'event.taskCompleted': 'lukkede en opgave',
'event.taskReopened': 'genåbnede en opgave',
'event.taskMoved': 'flyttede en opgave',
'event.taskDeleted': 'slettede en opgave',
'event.taskSplit': 'delte en opgave op',
'event.commentCreated': 'skrev en kommentar',
'event.attachmentAdded': 'vedhæftede en fil',
'errors.title': 'Serverfejl',
'errors.hint':
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
@@ -318,6 +403,8 @@ export const da = {
'data.csvFile': 'CSV-fil',
'data.importing': 'Importerer …',
'data.openProject': 'Åbn projektet',
'data.doneHint': 'Alt du har lukket, nyeste først. Lukkede du noget ved en fejl, ligger det her — og kan åbnes igen.',
'data.openDone': 'Åbn færdige opgaver',
'data.trash': 'Papirkurv',
'data.trashEmpty': 'Papirkurven er tom.',
'data.restore': 'Hent tilbage',
@@ -331,6 +418,10 @@ export const da = {
'data.swept': 'ryddet væk',
'data.empty': 'tom',
'data.export': 'Eksport',
'data.importNotes': 'Importér noter (zip med Markdown-filer)',
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
'data.notesImported': '{n} noter og {files} filer hentet ind.',
'data.exportNotes': 'Alle noter som Markdown-filer',
'data.exportAccount': 'Hele kontoen som JSON',
'data.exportOne': 'Ét projekt',
'data.pickProject': 'Vælg et projekt',
@@ -435,6 +526,7 @@ export const da = {
'auth.forgot': 'Glemt adgangskode?',
// --- a task, opened ---------------------------------------------------------------
'detail.notFound': 'Opgaven findes ikke, eller den er ikke din.',
'detail.task': 'Opgave',
'detail.close': 'Luk',
'detail.text': 'Opgavens tekst',
@@ -448,8 +540,13 @@ export const da = {
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
'detail.repeats': 'Gentages',
'detail.never': 'Aldrig',
'detail.daily': 'Hver dag',
'detail.weekly': 'Hver uge',
'detail.fortnightly': 'Hver anden uge',
'detail.monthly': 'Hver måned',
'detail.yearly': 'Hvert år',
'detail.asWritten': 'Som skrevet',
'detail.assignee': 'Ansvarlig',
'detail.subtasks': 'Undertasks',
'detail.addSubtask': 'Tilføj en undertask',
@@ -497,7 +594,7 @@ export const da = {
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
'int.oauthClient': 'OAuth-klient hos Google',
'int.registerHint':
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, slå Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, og indsæt de to værdier her. Den samme klient bruges til både Gmail og Google Kalender: slå det API til, du skal bruge, og tilføj begge redirect-URI\'er — den nedenfor og kalenderens, som står i panelet Google Kalender. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
'int.redirectURI': 'Godkendt redirect-URI',
'int.clientID': 'Klient-id',
'int.clientSecret': 'Klienthemmelighed',
@@ -537,6 +634,32 @@ export const da = {
'int.server': 'Server',
'int.caldavAuth':
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
'int.calendar': 'Google Kalender',
'int.calendarHint':
'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.',
'int.connectCalendar': 'Forbind Google Kalender',
'int.calendarConnectedAs': 'Forbundet som',
'int.calendarSameClient':
'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.calendarLastSync': 'Sidst hentet {when}.',
'int.calendarNeverSynced': 'Ikke hentet endnu.',
'int.calendarFetchedOne': '{n} begivenhed hentet.',
'int.calendarFetchedMany': '{n} begivenheder hentet.',
'int.calendarNothingNew': 'Ingen begivenheder i vinduet.',
'int.calendarConnected': 'Google Kalender er forbundet.',
'int.calendarState':
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.',
'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
'int.calendarFailed': 'Google afviste ombytningen af koden.',
'int.calendarNoRefresh':
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
'int.calendarDenied': 'Adgangen blev afvist hos Google.',
'int.calendarOrgInternal':
'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.',
'int.calendarOther': 'Google svarede: {what}',
'int.gmailFetchedOne': '{n} opgave hentet.',
'int.gmailFetchedMany': '{n} opgaver hentet.',
'int.gmailNothingNew': 'Ingen nye beskeder.',
@@ -551,6 +674,18 @@ export const da = {
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
'int.gmailOther': 'Gmail svarede: {what}',
// --- kalenderen, med Googles begivenheder lagt over ---------------------------------
'cal.title': 'Kalender',
'cal.notConnected':
'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.',
'cal.settings': 'Indstillinger',
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
'cal.allDay': 'Hele dagen',
'cal.openInGoogle': 'Åbn i Google Kalender',
'cal.beyondWindow':
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.',
// --- a project ----------------------------------------------------------------------
'project.name': 'Projektets navn',
'project.clickToRename': 'Klik for at omdøbe',
@@ -639,6 +774,7 @@ export const da = {
'push.upToDate': 'Ingen nyere version fundet.',
'view.nothingMoreToday': 'Ikke mere i dag.',
'view.overdue': 'Forsinket',
'project.roleFor': 'Rolle for {name}',
'project.more': 'Flere handlinger',
@@ -692,6 +828,10 @@ export const da = {
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
'error.gmailSaid': 'Gmail svarede: {what}',
'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.',
'error.calendarSaid': 'Google svarede: {what}',
'int.disconnectCalendarQuestion':
'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.',
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
'error.mailboxSaid': 'Postkassen svarede: {what}',
+139 -1
View File
@@ -12,6 +12,50 @@
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Share in',
'notes.private': 'Only me',
'notes.shared': "The note is shared with the project's people.",
'notes.unshared': 'The note is yours again.',
'notes.save': 'Save',
'notes.format': 'Format',
'notes.bold': 'Bold',
'notes.italic': 'Italic',
'notes.underline': 'Underline',
'notes.sortBy': 'Order',
'notes.sortUpdated': 'Last touched',
'notes.sortCreated': 'Created',
'notes.sortTitle': 'Name',
'beacon.title': 'Counting installations',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.send': 'Send an anonymous daily report',
'beacon.exactly': 'Exactly this — nothing more:',
'beacon.lastPing': 'Last sent: {when}',
'beacon.collector': 'Collector address',
'beacon.collectorHint': 'Where the report goes. Leave it unless you run your own collector.',
'beacon.beCollector': 'This instance is the collector',
'beacon.receiving': 'Receiving anonymous reports — id and version, never an IP.',
'beacon.total': 'Total',
'beacon.active7': 'Active 7 days',
'beacon.active30': 'Active 30 days',
'beacon.byVersion': 'By version (30 days):',
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.source': 'Note source',
'notes.styleTitle': 'Title',
'notes.styleHeading': 'Heading',
'notes.styleSubheading': 'Subheading',
'notes.styleBody': 'Body',
'notes.styleMono': 'Monospaced',
'notes.styleQuote': 'Block quote',
'notes.styleBullet': 'Bulleted list',
'notes.styleNumbered': 'Numbered list',
'notes.favourite': 'Make a favourite',
'notes.unfavourite': 'Remove from favourites',
'notes.syntaxProject': 'project',
'notes.syntaxNote': 'another note',
'notes.yesterday': 'yesterday',
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
@@ -25,10 +69,14 @@ export const en = {
'notes.saved': 'Saved',
'notes.delete': 'Delete',
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
'palette.project': 'Project',
'palette.task': 'Task',
'palette.note': 'Note',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
'nav.inbox': 'Inbox',
'nav.calendar': 'Calendar',
'nav.settings': 'Settings',
'nav.signOut': 'Sign out',
'nav.main': 'Main menu',
@@ -120,6 +168,16 @@ export const en = {
'account.appearance': 'Appearance',
'account.appearanceHint':
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
'account.look': 'Appearance',
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
'look.verdande': 'Verdande',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
'look.taet': 'Tight',
'look.taetNote': 'More rows on screen, less air.',
'look.terminal': 'Terminal',
'look.terminalNote': 'One width for everything. It all lines up.',
'account.password': 'Password',
'account.passwordHint':
'Changing it signs every other session out — which is usually the reason to do it.',
@@ -256,6 +314,9 @@ export const en = {
'users.taskOne': 'task',
'users.taskMany': 'tasks',
'users.never': 'never seen',
'users.toProject': 'to {name}',
'users.toInstance': 'to the instance',
'users.invitedBy': 'invited by {name}',
// --- settings: history and errors -----------------------------------------------
'history.title': 'History',
@@ -271,6 +332,30 @@ export const en = {
'history.noMatch': 'Nothing matches the filters.',
'history.nothing': 'Nothing has been recorded yet.',
'history.deletedAccount': 'A deleted account',
// What each recorded event is called. The keys are the server's and live in
// events.js; these are the words they become.
'event.projectCreated': 'created the project',
'event.projectUpdated': 'changed the project',
'event.projectDeleted': 'deleted the project',
'event.projectImported': 'imported the project',
'event.sectionCreated': 'created a section',
'event.sectionUpdated': 'changed a section',
'event.sectionDeleted': 'deleted a section',
'event.memberInvited': 'invited',
'event.memberAdded': 'added',
'event.memberRemoved': 'removed',
'event.memberRoleChanged': 'changed the role of',
'event.taskCreated': 'created a task',
'event.taskUpdated': 'changed a task',
'event.taskCompleted': 'closed a task',
'event.taskReopened': 'reopened a task',
'event.taskMoved': 'moved a task',
'event.taskDeleted': 'deleted a task',
'event.taskSplit': 'split a task up',
'event.commentCreated': 'wrote a comment',
'event.attachmentAdded': 'attached a file',
'errors.title': 'Server errors',
'errors.hint':
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
@@ -313,6 +398,8 @@ export const en = {
'data.csvFile': 'CSV file',
'data.importing': 'Importing …',
'data.openProject': 'Open the project',
'data.doneHint': 'Everything you have closed, newest first. Closed something by mistake and it is here — and can be opened again.',
'data.openDone': 'Open finished tasks',
'data.trash': 'Trash',
'data.trashEmpty': 'The trash is empty.',
'data.restore': 'Restore',
@@ -326,6 +413,10 @@ export const en = {
'data.swept': 'swept away',
'data.empty': 'empty',
'data.export': 'Export',
'data.importNotes': 'Import notes (a zip of Markdown files)',
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
'data.notesImported': '{n} notes and {files} files brought in.',
'data.exportNotes': 'Every note as Markdown files',
'data.exportAccount': 'The whole account as JSON',
'data.exportOne': 'One project',
'data.pickProject': 'Pick a project',
@@ -431,6 +522,7 @@ export const en = {
'auth.forgot': 'Forgotten your password?',
// --- a task, opened ---------------------------------------------------------------
'detail.notFound': 'That task does not exist, or it is not yours.',
'detail.task': 'Task',
'detail.close': 'Close',
'detail.text': 'The task’s text',
@@ -444,8 +536,13 @@ export const en = {
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
'detail.repeats': 'Repeats',
'detail.never': 'Never',
'detail.daily': 'Every day',
'detail.weekly': 'Every week',
'detail.fortnightly': 'Every other week',
'detail.monthly': 'Every month',
'detail.yearly': 'Every year',
'detail.asWritten': 'As written',
'detail.assignee': 'Assigned to',
'detail.subtasks': 'Sub-tasks',
'detail.addSubtask': 'Add a sub-task',
@@ -493,7 +590,7 @@ export const en = {
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
'int.oauthClient': 'OAuth client at Google',
'int.registerHint':
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, enable the Gmail API, and paste the two values here. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application and paste the two values here. The same client serves both Gmail and Google Calendar: enable whichever API you need, and add both redirect URIs — the one below and the calendar\'s, which is shown in the Google Calendar panel. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
'int.redirectURI': 'Authorised redirect URI',
'int.clientID': 'Client ID',
'int.clientSecret': 'Client secret',
@@ -533,6 +630,31 @@ export const en = {
'int.server': 'Server',
'int.caldavAuth':
'The username is your email. The password is an API token — not your real password.',
'int.calendar': 'Google Calendar',
'int.calendarHint':
'Read, never written. The events appear in Calendar beside your own tasks with due dates — they cannot be moved or completed from here, because they belong to Google.',
'int.connectCalendar': 'Connect Google Calendar',
'int.calendarConnectedAs': 'Connected as',
'int.calendarSameClient':
'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.calendarLastSync': 'Last fetched {when}.',
'int.calendarNeverSynced': 'Not fetched yet.',
'int.calendarFetchedOne': '{n} event fetched.',
'int.calendarFetchedMany': '{n} events fetched.',
'int.calendarNothingNew': 'No events in the window.',
'int.calendarConnected': 'Google Calendar is connected.',
'int.calendarState': 'Google’s reply did not match the attempt that started here. Try again.',
'int.calendarExpired': 'The attempt expired on the way. Try again.',
'int.calendarInvalid': 'Google’s reply could not be read. Try again.',
'int.calendarFailed': 'Google refused to exchange the code.',
'int.calendarNoRefresh':
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
'int.calendarDenied': 'Access was refused at Google.',
'int.calendarOrgInternal':
'The Google app is registered as Internal and only accepts accounts inside the organisation. A private account cannot consent to it, whatever the screen says.',
'int.calendarOther': 'Google replied: {what}',
'int.gmailFetchedOne': '{n} task fetched.',
'int.gmailFetchedMany': '{n} tasks fetched.',
'int.gmailNothingNew': 'No new messages.',
@@ -546,6 +668,17 @@ export const en = {
'int.gmailDenied': 'Access was refused at Google.',
'int.gmailOther': 'Gmail replied: {what}',
// --- the calendar, with Google’s events laid over ------------------------------------
'cal.title': 'Calendar',
'cal.notConnected': 'No calendar is connected yet. That is done under Settings → Integrations.',
'cal.settings': 'Settings',
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
'cal.allDay': 'All day',
'cal.openInGoogle': 'Open in Google Calendar',
'cal.beyondWindow':
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
'cal.eventCannotMove': '{name} — an event in {calendar}. It cannot be moved from here.',
// --- a project ----------------------------------------------------------------------
'project.name': 'The project’s name',
'project.clickToRename': 'Click to rename',
@@ -634,6 +767,7 @@ export const en = {
'push.upToDate': 'No newer version found.',
'view.nothingMoreToday': 'Nothing more today.',
'view.overdue': 'Overdue',
'project.roleFor': 'Role for {name}',
'project.more': 'More actions',
@@ -687,6 +821,10 @@ export const en = {
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
'error.gmailSaid': 'Gmail said: {what}',
'error.calendarRefused': 'Google refused the call. Try connecting the calendar again.',
'error.calendarSaid': 'Google replied: {what}',
'int.disconnectCalendarQuestion':
'Disconnect Google Calendar? The tokens are forgotten and the fetched copy of the events is deleted. Your own tasks are untouched.',
'error.mailboxRefused': 'The mailbox could not be read.',
'error.mailboxSaid': 'The mailbox answered: {what}',
+401
View File
@@ -0,0 +1,401 @@
/**
* Between what you see and what is stored.
*
* The editor is rich text; the note on disk is Markdown. That is not a compromise
* anybody has to live with silently — it is the reason the export is trustworthy:
* the file a note becomes is the file it already was. So these two functions are
* the seam, and they have to agree with each other exactly, or a note changes
* shape a little every time it is opened and saved.
*
* The set of styles is deliberately small and matches what Apple Notes offers,
* because that is the vocabulary somebody coming from there already has:
*
* Titel → #
* Overskrift → ##
* Underrubrik → ###
* Brødtekst → nothing
* Monotype → four spaces
* Punktliste → -
* Nummereret → 1.
* Blokcitat → >
*
* Bold, italic and strikethrough have Markdown of their own. Underline does not —
* there is no such thing in any Markdown dialect — so it is stored as inline HTML,
* which every Markdown reader passes through untouched. It is the one place where
* the file is not pure Markdown, and it is that rather than dropping a style the
* source program has.
*/
const BLOCKS = [
{ style: 'title', tag: 'h1', prefix: '# ' },
{ style: 'heading', tag: 'h2', prefix: '## ' },
{ style: 'subheading', tag: 'h3', prefix: '### ' },
{ style: 'quote', tag: 'blockquote', prefix: '> ' },
{ style: 'mono', tag: 'pre', prefix: ' ' }
];
const escapeHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* En værdi, der skal stå inde i en attribut.
*
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
* forskellen mellem tekst og et onerror.
*/
const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
/**
* Billeder løftes ud af teksten, før noget andet rører den.
*
* De stod før først i kæden, og så kørte de fire resterende regler hen over en
* streng, der nu indeholdt rigtig HTML — ind i den `alt`, der lige var lavet.
* Det gik godt, så længe ingen af erstatningerne indeholdt et anførselstegn, og
* det er en betingelse, der stod skrevet ingen steder: den dag nogen tilføjer en
* regel for links, er den brudt, og det er præcis den vej, XSS-en kom ind ad.
*
* Værre endnu var undslupningen. `escapeHtml` kørte på hele teksten, og `attr`
* kørte den igen på `alt` — så et og-tegn blev til en dobbelt undslupning, der
* voksede ved hver eneste åbning og gemning. En note med "Kaffe & te" i et
* billede blev længere, hver gang nogen så på den.
*
* En pladsholder løser begge dele. Billedet tages ud af den rå tekst, resten
* undslippes og formateres som altid, og billedet lægges tilbage til sidst med en
* `alt`, der er undsluppet nøjagtig én gang. Rækkefølgen af de øvrige regler kan
* så ikke længere betyde noget.
*
* Nul-tegnet som mærke, fordi det er det ene tegn, en note ikke kan indeholde:
* det overlever hverken en contenteditable eller en Markdown-fil.
*/
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
const MARK = "\u0000";
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
// Kun vores egen adresse bliver til et billede. Et `![](http://…)` fra en
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
// have en sporingspixel. Alt andet står som den tekst, det er.
const images = [];
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
images.push(`<img src="${src}" alt="${attr(alt)}">`);
return `${MARK}${images.length - 1}${MARK}`;
});
const marked = escapeHtml(lifted)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
}
/**
* markdownToHtml renders a note for the editor.
*
* Every line becomes exactly one block. Markdown would normally join consecutive
* lines into one paragraph; here it must not, because the person typed a line and
* expects to get it back — an editor that silently reflows what somebody wrote is
* an editor they stop trusting.
*/
export function markdownToHtml(markdown) {
const lines = (markdown ?? '').split('\n');
const out = [];
let fence = null; // sproget, mens vi er inde i en ```-blok
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
//
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
// var væk efter én gemning.
//
// Underlisten lægges *inde i* sit `<li>`, ikke ved siden af. Browsere viser
// begge dele ens, men kun den ene er en liste i en liste, når den læses tilbage
// — og det er læsningen tilbage, der skriver filen.
const stack = [];
let liOpen = false;
const closeItem = () => {
if (liOpen) {
out.push('</li>');
liOpen = false;
}
};
const closeList = (toDepth = 0) => {
while (stack.length > toDepth) {
closeItem();
out.push(`</${stack.pop()}>`);
// Efter et niveau lukkes, står vi igen inde i ophavets `<li>`.
liOpen = stack.length > 0;
}
if (stack.length === 0) liOpen = false;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
// begynder med - inde i et shell-script er ikke et punkt i en liste.
const fenced = /^```(\w*)\s*$/.exec(line);
if (fence !== null) {
if (fenced) {
out.push('</code></pre>');
fence = null;
} else {
out.push(escapeHtml(line) + '\n');
}
continue;
}
if (fenced) {
closeList();
fence = fenced[1] ?? '';
// attr, ikke escapeHtml: værdien står inde i en attribut. Den er sikker i
// dag alene fordi hegn-regexen er \w*, som ikke rummer anførselstegn — og
// den dag nogen udvider den for at tage imod "c++" eller "f#", er det en
// XSS. Undslupningen skal passe til stedet, ikke til det, der
// tilfældigvis kan stå der nu.
out.push(`<pre data-lang="${attr(fence)}"><code>`);
continue;
}
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
if (item) {
const indent = item[1].replace(/\t/g, ' ').length;
const depth = Math.floor(indent / 2);
const want = item[2] ? 'ul' : 'ol';
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
// `</ul>` lander inde i det `<li>`, det hører til.
closeList(depth + 1);
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
// nummereret er to lister, ikke én med blandede mærker.
if (stack.length === depth + 1 && stack[depth] !== want) {
closeList(depth);
}
while (stack.length < depth + 1) {
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
// gør en gemning uden ændringer noten om.
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
out.push(`<${want}${want === 'ol' ? start : ''}>`);
stack.push(want);
liOpen = false;
}
closeItem();
out.push(`<li>${inlineToHtml(item[4])}`);
liOpen = true;
continue;
}
closeList();
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
// det blev en liste i et citat til seks citater med et bindestreg i hver.
if (/^>(?: |$)/.test(line)) {
const inner = [];
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
inner.push(lines[i].replace(/^> ?/, ''));
i++;
}
i--;
out.push(`<blockquote>${markdownToHtml(inner.join('\n'))}</blockquote>`);
continue;
}
const block = BLOCKS.find((b) =>
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
);
if (block) {
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}</${block.tag}>`);
continue;
}
// An empty line is still a line. Without the break the browser collapses the
// block to nothing and the caret has nowhere to stand.
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
}
closeList();
if (fence !== null) out.push('</code></pre>');
return out.join('');
}
/** The inline marks, read back off the DOM. */
function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
// — og den første gemning ville tage hvert billede ud af noten uden at
// nogen havde rørt det.
case 'IMG':
return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
case 'EM':
case 'I':
return inner ? `*${inner}*` : '';
case 'S':
case 'STRIKE':
case 'DEL':
return inner ? `~~${inner}~~` : '';
case 'U':
return inner ? `<u>${inner}</u>` : '';
case 'CODE':
return inner ? `\`${inner}\`` : '';
case 'SPAN': {
// The browser's own way of saying bold or italic when it is writing styles
// rather than tags. Read as well as tags, because `styleWithCSS` is not
// honoured everywhere and a note must not lose a format on the way out.
const style = node.getAttribute('style') ?? '';
if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : '';
if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : '';
if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : '';
if (/text-decoration[^;]*underline/.test(style)) return inner ? `<u>${inner}</u>` : '';
return inner;
}
case 'BR':
return '';
default:
return inner;
}
}
/**
* htmlToMarkdown reads the editor back.
*
* Written against the DOM rather than against a string of HTML, because that is
* what contenteditable actually produces — and what it produces is not tidy. The
* browser will nest, split and re-wrap as somebody types, and a regular expression
* over that is a guess.
*/
/** The tags that are a block of their own, and must never be read as inline text. */
const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
export function htmlToMarkdown(root) {
const lines = [];
/**
* @param node hvad der læses
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
* indeholde afsnit og lister uden at de bliver til én linje.
*/
const walk = (node, emit) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (child.textContent.trim()) emit(child.textContent);
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
switch (child.tagName) {
case 'UL':
case 'OL': {
list(child, 0, emit);
break;
}
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
// — det var sådan en note kom tilbage som én lang linje efter en
// gemning, uden at nogen havde rørt teksten.
case 'BLOCKQUOTE': {
if (holdsBlocks(child)) {
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
} else {
emit('> ' + inlineToMarkdown(child));
}
break;
}
case 'DIV':
// The browser's own container when nothing else applies. Read through
// it rather than treating it as a block, or every paste adds a level.
if (holdsBlocks(child)) {
walk(child, emit);
} else {
emit(inlineToMarkdown(child));
}
break;
case 'PRE': {
// Back out as a fence, with the language it came in with. The old
// four-space form is still read on the way in; it is not written
// out any more, because a fence survives being pasted somewhere
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
emit(l);
}
emit('```');
break;
}
default: {
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
// citatet: læst som én linje forsvandt hvert punktmærke.
if (holdsBlocks(child)) {
walk(child, emit);
break;
}
const text = inlineToMarkdown(child);
emit(block ? block.prefix + text : text);
}
}
}
};
/**
* Én liste, og de lister der ligger i den.
*
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
* fejl, fordi den sker uden at nogen har rørt noget.
*/
const list = (el, depth, emit) => {
let n = Number(el.getAttribute('start') || 1);
if (!Number.isFinite(n) || n < 1) n = 1;
const pad = ' '.repeat(depth);
for (const li of el.children) {
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
let text;
if (nested.length) {
const clone = li.cloneNode(true);
for (const sub of [...clone.children]) {
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
}
text = inlineToMarkdown(clone).trim();
} else {
text = inlineToMarkdown(li).trim();
}
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
for (const sub of nested) list(sub, depth + 1, emit);
}
};
walk(root, (l) => lines.push(l));
// Trailing blank lines are what an editor accumulates from people pressing
// return at the end; they are not content and they grow every time.
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
return lines.join('\n');
}
+114
View File
@@ -49,6 +49,8 @@ class AppState {
* small request against modelling a count in two places.
*/
labelsChanged = $state(0);
/** Bumped when a note moves, so a list showing them knows to look again. */
notesChanged = $state(0);
#socket = null;
#reconnectDelay = 1000;
@@ -421,6 +423,17 @@ class AppState {
}
}
/** Files a note under a project, which is what sharing it means. */
async moveNoteToProject(noteID, projectID) {
try {
await api.updateNote(noteID, { project_id: projectID });
this.notesChanged++;
this.toast(t('notes.filed'));
} catch (e) {
this.toast(humanMessage(e));
}
}
async refreshProjects() {
const { projects } = await api.listProjects();
this.projects = projects;
@@ -562,6 +575,31 @@ class AppState {
}
}
/**
* The order of the fixed views, as keys.
*
* Unknown keys are dropped and missing ones appended, so the stored order never
* has to be migrated when a view is added or taken away — which has already
* happened once, when Færdige left the sidebar.
*/
navOrder(keys) {
const saved = this.user?.nav_order ?? [];
const known = new Set(keys);
const ordered = saved.filter((k) => known.has(k));
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
}
async setNavOrder(order) {
const before = this.user?.nav_order ?? [];
this.user = { ...this.user, nav_order: order };
try {
await api.setNavOrder(order);
} catch (e) {
this.user = { ...this.user, nav_order: before };
this.toast(humanMessage(e));
}
}
sectionCollapsed(key) {
return (this.user?.sidebar_collapsed ?? []).includes(key);
}
@@ -797,6 +835,43 @@ function read() {
export const upcomingView = new UpcomingView();
/**
* Month or week on Kalender.
*
* Its own key rather than Kommende's, even though the two switchers look alike.
* They are not the same choice: Kommende opens on a list of the next seven days
* and offers a grid, and Kalender is the grid — sharing the value would mean
* choosing a week on one and finding the other had changed, and one of them would
* have to answer for the `list` mode the other does not have.
*
* In localStorage rather than on the account, and for the same reason as the
* sidebar's width: a grid of seven columns needs room, and the answer on a phone
* is not the answer on a wide monitor.
*/
const CALENDAR_MODES = ['week', 'month'];
class CalendarViewMode {
mode = $state(readCalendarMode());
set(next) {
if (!CALENDAR_MODES.includes(next)) return;
this.mode = next;
try {
localStorage.setItem('verdande:calendar', next);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
function readCalendarMode() {
if (typeof localStorage === 'undefined') return 'month';
const stored = localStorage.getItem('verdande:calendar');
return CALENDAR_MODES.includes(stored) ? stored : 'month';
}
export const calendarView = new CalendarViewMode();
/**
* Whether the lists show what has already been finished.
*
@@ -844,6 +919,45 @@ export const THEMES = [
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
];
/**
* The looks: how it reads, as opposed to how bright it is.
*
* Separate from the themes because they are separate questions. Somebody who
* wants a serif face at night should not have to give up the dark palette to get
* one, and the two multiply out to fifteen appearances from eight small blocks of
* CSS rather than needing one block each.
*/
export const LOOKS = [
{ id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' },
{ id: 'rolig', name: 'look.rolig', note: 'look.roligNote' },
{ id: 'taet', name: 'look.taet', note: 'look.taetNote' },
{ id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' }
];
class Look {
current = $state(
typeof document === 'undefined'
? 'verdande'
: (document.documentElement.dataset.look ?? 'verdande')
);
set(id) {
if (!LOOKS.some((l) => l.id === id)) return;
this.current = id;
// The default carries no attribute, so the plain tokens are the plain tokens
// and there is nothing to override them back to.
if (id === 'verdande') delete document.documentElement.dataset.look;
else document.documentElement.dataset.look = id;
try {
localStorage.setItem('verdande:look', id);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const look = new Look();
/** Theme, kept in localStorage and applied to the document element. */
class Theme {
/**

Some files were not shown because too many files have changed in this diff Show More