Compare commits

...
22 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 43f8c0ea85 feat(site): landingssiden siger hvad der findes nu, og siger det med kanter
Release / Build and publish image (push) Failing after 10m0s
Siden stod fra før kalenderen, noternes billeder og arkiv, sikkerhedsarbejdet og
beaconet. Nu står de der: søgning der læser dansk, kalenderen, Venter på andre,
gentagelser, AI slået fra som udgangspunkt, andenfaktor, at ingen skriver sig
selv op, og beaconet — sagt som det er, tændt fra start og med ét klik til at
slukke.

Google Kalender står med sit forbehold på selve kortet. Den er skrevet efter
dokumentationen og har aldrig kørt mod Google, og en landingsside, der siger
noget andet, er den ene sætning, resten af siden ikke kan bære.

Ikke-målene løj om posten: "No mail integration beyond Gmail and the forwarding
address" har været forkert, siden IMAP kom til. iCloud og Fastmail virker. Det,
der faktisk ikke virker, er Outlook, og det er nu det, der står.

Designet: en bjælke der bliver stående, fordi siden er 5.600 px og vejen ud kun lå
i toppen. Fire fakta under knapperne, hvor en landingsside plejer at sætte
logoer, den ikke har gjort sig fortjent til. Kort med egne kanter frem for celler
i ét gitter — ti celler i tre søjler er et hul i tegningen, atten kort i tre
grupper er ikke. To paneler under "Running it", fordi de er alternativer og ikke
trin. Ikke-målene som en liste, der kan tælles. Og tre skærmbilleder, der lå i
site/screenshots uden at være brugt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 8ecefbbdc2 fix(notes): rullebjælken svævede midt i arket
Det er `.page`, der ruller — og det var også `.page`, der blev smallere for at
holde linjelængden nede. Så bjælken fulgte med ind til tekstsøjlens kant og stod
med en håndflade tom rude til højre for sig, en tredjedel inde i ruden.

Grænsen sættes som polstring i stedet for som bredde. Elementet er lige så bredt
som ruden — bjælken bliver ved kanten, hvor den hører til — og teksten er
nøjagtig lige så bred som før. `max()` mod nul, fordi en smal rude ellers ville få
negativ polstring, og `border-box` er sat globalt, så bredden er hele boksen og
ikke boksen plus polstringen; uden det ville det give en vandret rullebjælke.

Kildevisningen deler `.page` med editoren og havde samme fejl. Den følger med.

Målt: elementet slutter nu på rudens indholdskant i stedet for 74 px inde, og
`scrollWidth - clientWidth` er nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 6b4448b245 fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden
Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget
bagefter.

`user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række
per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre
scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver
skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge
veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede
kalenderen.

Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen
tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede
postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje
gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre.

Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der
redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er
noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer
den nøgle.

Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker,
der findes, kopieres som de er: hver person har højst én, og den holder det scope,
der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke
hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og
Gmail-forbindelsen tjekkes.

To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne,
hvor den blev set.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:48 +02:00
Kristian WindandClaude Opus 5 e46aaa462a feat(projects): sektioner kan trækkes op og ned, og båndet står ikke længere skævt
Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi det
er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette en
sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
område på vejen.

Hele overskriften er håndtaget. En sektionsoverskrift indeholder ikke noget, der
selv bliver trukket, så der er ingen geste at stjæle — hvilket er grunden til, at
sidebjælkens rækker skulle have et greb, og grunden til at disse ikke skal. Ikke
draggable mens den omdøbes: et draggable element lader ikke markøren vælge tekst
i det felt, det indeholder.

Serversiden er hele listen på én gang som projekter og grupper, og af samme
grund: et projekt har en håndfuld sektioner, så 0, 1, 2 … er én lille transaktion
uden præcision at løbe tør for, og en liste kan ikke ende halvt anvendt.
`ReorderSections` er scopet til projektet og ikke til en ejer — en sektion har
ingen ejer, den hører til et projekt, og om du må rykke rundt er et spørgsmål om
det projekt, som handleren allerede har stillet. Id'er fra et andet projekt
springes over af WHERE-klausulen; der er en prøve på præcis det, fordi ingenting
ellers kontrollerer id'erne enkeltvis.

Båndet stod fire pixels skævt. Det var trukket ud med --s2 og polstret med --s3 —
samme idé gjort med to forskellige tal — så det blev otte pixels bredere end
listen i hver side, og teksten landede fire pixels uden for den kolonne, rækkerne
holder. Til venstre var det usynligt. Til højre var det Omdøb og Slet, der stod
fire pixels forbi alt over og under sig, hvilket er præcis langt nok til at ligne
en fejl og ikke langt nok til at se hvorfor. En række er `.row` med
`padding: var(--s2)` inde i en boks uden, så båndet har nu samme polstring og
ingen margen.

Board-visningen er ikke rørt: der er kolonnerne vandrette, og det er en anden
geste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:26 +02:00
Kristian WindandClaude Opus 5 98bfa216d6 fix(look): mærket og de faste visninger fulgte med op, når listen rullede
Hele sidebjælken rullede, så runen og I dag forsvandt over kanten, så snart der
var projekter nok — og de er dem, man er på vej hen til oftest. Alt under de
faste visninger ligger i en rulleboks nu, så rullebjælken begynder under dem.

En boks indeni frem for `position: sticky` udenom: et klæbende hoved efterlader
rullebjælken i fuld højde ved siden af sig, og det var netop den, der skulle
starte længere nede. Snippet-erklæringerne bliver liggende udenfor — de tegner
ikke noget selv, men de kaldes oppe fra .views, og en snippet erklæret indeni
hører kun til indeni.

Chevronen for en mappe dækkede gruppens prik med et felt i --surface. På den
sænkede bjælke er det en lys firkant, og en foldet gruppe har sin chevron fremme
permanent, så klodsen stod der hele tiden. Prikken tones ud i stedet.

Det kostede en fælde: en `opacity` under 1 laver en stakkontekst og maler
elementet, som var det placeret med `z-index: 0`. Prikken lagde sig dermed oven
på knappen, den lige var forsvundet for, og tog klikket — usynlig og stadig i
vejen, så gruppen ikke kunne foldes. Ét lag op på knappen, og rækkefølgen er
som før.

Prøven for vandret rul måler nu begge bokse. Bjælken selv er `overflow: hidden`,
så en for bred række ville blive klippet dér og aldrig nå at tælle med — den
ville bestå af den forkerte grund.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:01 +02:00
Kristian WindandClaude Opus 5 6c3d66108b fix(look): stregen under hurtig tilføjelse havde ingen luft under sig
Release / Build and publish image (push) Failing after 9m56s
Den skiller "skriv en ny" fra "det, der allerede er". Uden afstand sad den første
sektion klods op ad den, så den læste som en kant om sektionen frem for som en
afslutning på feltet over. En streg skal have plads på begge sider for at kunne
ses som en streg.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:36:23 +02:00
Kristian WindandClaude Opus 5 1437667483 fix(notes): stjerne og arkiv står oven på hinanden
Release / Build and publish image (push) Failing after 9m56s
To knapper ved siden af hinanden er toogfyrre pixels taget fra en liste, der er
tre hundrede bred — og de er det, man rører en gang imellem, mens titlen og
uddraget er det, man læser hele tiden. I en søjle koster de toogtyve.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:23:21 +02:00
Kristian WindandClaude Opus 5 035d079a8e fix(notes): teksten fulgte ikke med, når ruden blev bred
Release / Build and publish image (push) Failing after 9m59s
46rem var sat for at holde linjelængden i skak, og det er stadig det rigtige
hensyn — men det blev sat som ét tal, og på en skærm halvanden gang så bred stod
teksten i en smal søjle med en håndflade tom plads ved siden af. Kodeblokke og
billeder, som er de to ting i en note, der *vil* have bredde, blev klemt sammen
uden grund.

clamp frem for et tal: en smal rude får det hele, en bred får en linjelængde,
øjet kan finde tilbage i. På 1920 er tekstsøjlen nu 1088 px af arkets 1304 i
stedet for 736.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:08:26 +02:00
Kristian WindandClaude Opus 5 aadfb197b6 feat(notes): arkiv, flere ad gangen, og en kopiér-knap på koden
Release / Build and publish image (push) Failing after 9m55s
**Arkivet, fordi papirkurven er det forkerte redskab.** Den siger "det her var en
fejl, og om tredive dage er det væk" — så alt, der er værd at beholde, kan ikke
komme i den. Et arkiv på tolv hundrede importerede noter er præcis det tilfælde,
hvor de fleste er værd at beholde og næsten ingen er værd at kigge på; uden et
sted at lægge dem er listen hele arkivet for evigt, og at finde ugens note
betyder at læse forbi et årti.

En kolonne, ikke en mappe. Noterne afviste et andet hierarki i 0018, og
arkivering er ikke arkivering i den forstand: det er én oplysning om, hvorvidt en
note er aktuel, og den skal kunne besvares ved siden af `deleted_at`, som er den
anden af samme slags. `updated_at` røres ikke — at rydde op er ikke at skrive.

**Flere ad gangen.** ⌘-klik lægger til, skift-klik tager intervallet i den
rækkefølge, listen faktisk står i — ikke notens egen, som er en anden, når der er
sorteret eller søgt. Trækker man en note, der er markeret, følger hele
markeringen med; trækker man en, der ikke er, er det den ene, for ellers ville et
træk i en tilfældig række flytte halvtreds noter, man havde glemt var markeret.

Ét kald frem for et pr. note: efter en import af tolv hundrede er det at vælge
halvtreds det almindelige tilfælde. Hver note kontrolleres stadig for sig — et sæt
er en bekvemmelighed for den, der kalder, og aldrig en vej til noget, de ikke
kunne have rørt én ad gangen.

En kant *og* en tone på det markerede: `--accent-sunken` er meget lys i lyst tema,
og tre markerede rækker var svære at skelne fra tre, der ikke var — en dårlig ting
at være i tvivl om lige inden man trykker Slet.

**Kopiér-knappen spiste næsten den kode, den skulle kopiere.** Den ligger inde i
`<pre>` for at kunne stå i hjørnet af blokken, og `colourCode` læste
`pre.textContent` og malede resultatet tilbage — så et klik på "Kopiér" skrev
"Kopieret" ind i koden, og næste gemning lagde det i filen. Nu ét sted, der siger
hvad en kodeblok indeholder, brugt af både farvelægningen og oversættelsen til
Markdown: var de to uenige, ville forskellen ende i noten. Prøven klikker,
læser udklipsholderen og læser filen bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:03:02 +02:00
Kristian WindandClaude Opus 5 8aad7c13b0 perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde.

**Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele
noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems
procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i
hukommelsen hver gang siden blev åbnet. Nu 763 KB.

Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt,
der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville
åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget
uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten
kan se efter — og den gør det og henter hele noten, når den åbner en.

Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der
er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så
hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme
begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der
lige havde haft en titel.

**Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind
ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader
browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og
uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme
ud af trit med sig selv.

**Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket
gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som
søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal
fundne.

Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede
noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom
tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 10:36:35 +02:00
Kristian WindandClaude Opus 5 3a02bc8304 fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv
hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og
loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med
en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket
læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis
dét.

Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten
*handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold`
vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem
for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel
smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop
det væk, der gør en søgning i tolv hundrede noter brugbar.

**Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod
både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre,
mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder
noget. Den værst mulige form for i stykker: den så ud, som om den ville virke,
helt indtil man slap.

Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi
prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger
noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var
netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen
var i stykker.

**Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter
er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede
efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter,
og har begge i sit værktøjstip.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 09:58:41 +02:00
Kristian WindandClaude Opus 5 4aa8bd6a40 fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og
hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret.
Det var en fungerende skanner af det interne net for enhver bruger: peg den på en
vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det
det meste af, hvad der er værd at nå.

Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren
har stadig brug for beskeden — for en lokal model er den som regel selve
problemet — så den er der, bare kun for den, der kører instansen.

**M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på
en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og
serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med
`user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne
overtage det — offerets telefon ville få angriberens beskeder og ingen af sine
egne. Og `DeletePushSubscription` havde ingen ejer-kontrol.

**H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr.
ord uden grænse; en søgning på en kvart megabyte blev til to megabyte
MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun
fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig
for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk —
halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én
forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten
læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til
seksogtres gigabyte skrevet ned på den disk, databasen ligger på.

**M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i
adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på
et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en
fil under en andens kommentar i et projekt, hun kun må læse.

**M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og
AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives.
Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise
værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu
skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at
adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen
virker.

Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde
XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der
rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning,
uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'`
står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 08:39:52 +02:00
Kristian WindandClaude Opus 5 ce3fc466b2 fix(auth): andenfaktoren kunne gættes igennem, og et token kunne tilmelde sin egen
To huller fra sikkerhedsgennemgangen, og de er samme historie fortalt to gange:
den ene kontrol, der findes til når adgangskoden allerede er tabt, kunne omgås af
netop den, der havde adgangskoden.

**H1 — spanden blev fyldt af adgangskoden alene.** `handleLogin` nulstillede
ratebegrænseren ved hver korrekt adgangskode, *før* den så på, om der var en
andenfaktor. Det gav én, der havde kodeordet, ubegrænsede portioner på ti gæt:
log ind, få en frisk spand og en frisk ventende session, brug ti gæt på koden, log
ind igen. Med tre gyldige koder i ethvert øjeblik og tyve forespørgsler i
sekundet er det lige odds inden for en eftermiddag.

Spanden ryddes nu først, når login er *færdigt*, og der er kommet en spand mere:
koder tælles pr. konto og ikke kun pr. adresse. Adressespanden er slået af enhver
med mere end én adresse, hvilket er det almindelige — en telefon og en bærbar er
allerede to. Det, der gør en sekscifret kode sikker, er at gæt er en knap
ressource, og knapheden skal tælles imod det, der bliver angrebet.

Den ventende session får lov at leve. At afslutte den ved første forkerte ciffer
blev overvejet og valgt fra: det ville koste en, der taster ét tal forkert, sin
adgangskode igen, og det køber næsten ingenting oven i loftet pr. konto.

**M3 — et lækket token kunne tilmelde sin egen andenfaktor.** `/auth/totp/setup`
lå kun bag `requireAuth` og spurgte om ingenting, mens `disable` altid har
krævet adgangskoden: døren åbnede med et token og lukkede kun med et kodeord. Et
stjålet token kunne hente hemmeligheden, lægge den i sin egen godkender, bekræfte
den og beholde den eneste kopi af gendannelseskoderne. Ejerens næste login ville
stoppe ved en kode, de ikke havde, og de kunne ikke slå den fra uden at logge ind
først. Et tyveri bliver til et lejemål — den sætning står allerede i filen ved
siden af om passkeys, og det er den samme regel.

Nu bag `requireSession` sammen med passkeys og sessionerne, og `setup` spørger om
adgangskoden ligesom `disable`. Symmetrien er pointen: at slå den til er mindst
lige så indgribende som at slå den fra.

Og en note kan oprettes inde fra et projekt. At ligge i et projekt *er* måden en
note deles på, så det er én handling frem for to — man skulle før oprette noten
under Noter og bagefter finde projektet frem i "Del i". Ruden findes nu, også før
der er en eneste note, for ellers er der ingen knap dér, hvor man opdager at der
skal skrives noget ned.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 08:29:43 +02:00
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 <[email protected]>
2026-08-20 07:39:12 +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 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 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 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 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 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 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
70 changed files with 7532 additions and 843 deletions
+4 -3
View File
@@ -103,15 +103,16 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| 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.
+21 -6
View File
@@ -10,13 +10,28 @@ reply within a week.
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
be raised later without locking anyone out.
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
stored only as a SHA-256 hash. A database dump contains nothing that can be
presented.
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
database dump contains nothing that can be presented.
- **The calendar feed address and the mail-to-task address are not hashed.** They
are looked up by their exact value, and they are shown in the interface more
than once, which a hash cannot do. Anybody holding a backup file therefore holds
those two URLs: the feed reads that person's tasks without a login, and the mail
address files tasks into their inbox. Rotating either from **Settings →
Integrations** invalidates the old one. This is written down rather than fixed
because fixing it means the address can only ever be shown once, which is a
decision about how the feature works and not only about how it is stored.
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
AES-GCM under a key kept outside the database — see `internal/secret`. That is
what makes a stray copy of the database less than a stray copy of everything:
the backup does not carry the key.
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
- **Uploads** are stored content-addressed by hash and always served as
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
on this origin.
- **Uploads** are stored content-addressed by hash and served as
`application/octet-stream` with `nosniff` — with one exception, added so that a
note can show the pictures in it: a short allowlist of raster image types is
served inline as its own type. The list is named one format at a time rather
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
through, and an SVG is a document that can run script. `nosniff` stays, so the
browser must read the file as that image or not at all.
- **The filter language** compiles to parameterised SQL. No typed text is ever
concatenated into a statement.
- **Permission checks** are route middleware, and anything you may not see answers
+1
View File
@@ -75,6 +75,7 @@ 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)
+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
+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:
+311
View File
@@ -1396,6 +1396,57 @@ paths:
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/bulk:
post:
tags: [notes]
summary: Archive, unarchive or delete a set of notes in one call.
description: |
One call rather than one per note, because the thing being asked is one
thing: "put these away". Fifty separate requests would be fifty round
trips, fifty chances for half of them to fail, and a list that redraws
fifty times — and after an import of twelve hundred notes, selecting fifty
is the ordinary case rather than the extreme one.
Each note is still checked on its own. A set is a convenience for the
caller and never a way to touch something they could not have touched one
at a time; anything they may not write to is counted in `skipped` rather
than failing the whole call, so one note somebody else just deleted does
not stop the other forty-nine.
Archiving is not deleting. The trash says "this was a mistake, and in
thirty days it is gone"; the archive says "this is finished, and I want to
keep it".
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
items: { type: string }
description: At most 500.
archived:
type: boolean
description: Put away, or bring back out.
delete:
type: boolean
description: Into the trash instead. Wins over `archived`.
responses:
"200":
description: How many were changed, and how many were passed over.
content:
application/json:
schema:
type: object
properties:
done: { type: integer }
skipped: { type: integer }
"422":
description: No ids, too many, or neither archived nor delete was asked for.
/api/v1/notes/{noteID}/attachments:
post:
tags: [notes]
@@ -1998,6 +2049,36 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/sections/reorder:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
post:
tags: [projects]
summary: Set the order the project's sections appear in. Editors and owners.
description: |
The whole list at once, for the same reasons as `/projects/reorder`: a
project has a handful of sections, so sending the order you want is
simpler than midpoints and cannot land half-applied.
Ids belonging to another project are skipped rather than refused.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Section ids, top to bottom.
responses:
"204": { description: Reordered. }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/sections/{sectionID}:
parameters:
- { name: sectionID, in: path, required: true, schema: { type: string } }
@@ -2654,6 +2735,126 @@ 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]
@@ -3297,6 +3498,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]
@@ -3479,6 +3700,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:
@@ -3496,6 +3751,62 @@ 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.
+37 -2
View File
@@ -19,6 +19,8 @@ import (
"fmt"
"io"
"net/http"
"github.com/kristianwind/verdande/internal/safedial"
"strings"
"time"
)
@@ -76,7 +78,16 @@ func New(cfg Config) *Client {
// Generous: a local model on modest hardware can take a while to produce
// its first token, and the alternative is a timeout that only ever fires
// for the people running their own.
http: &http.Client{Timeout: 120 * time.Second},
// safedial, not a plain client. `base_url` is typed into a field in the
// interface, so it is a user-supplied address — and a server that fetches a
// user-supplied address is a way to ask it to reach things the caller
// cannot: the panel next door, a database on the same bridge, the cloud
// metadata endpoint. On a homelab that is most of what is worth reaching.
//
// The check is on the resolved address rather than on the URL, because a
// name answers whatever its owner says — and can answer differently the
// second time, after a parse-time check has passed.
http: safedial.Client(120 * time.Second),
}
}
@@ -268,11 +279,35 @@ func (c *Client) do(req *http.Request, out any) error {
// The provider's own message is included: for a local model it is usually
// the actual problem ("model not found"), and hiding it would leave the
// operator with nothing to go on.
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
// The provider's body is no longer passed back to the caller.
//
// It was, and combined with a free-text base URL that made a working
// internal scanner: point it at a host, read the first three hundred bytes
// of whatever answered. The operator still needs the message — for a local
// model it is usually the actual problem — so it goes to the log, where it
// is available to the person running the instance and to nobody else.
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
}
return json.Unmarshal(raw, out)
}
// upstreamError keeps the provider's own words for the log and away from the
// response. Error() is what reaches a caller; Detail() is what is written down.
type upstreamError struct {
Host string
Status string
Body string
}
func (e *upstreamError) Error() string {
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
}
// Detail is the whole of it, for the instance's own error log.
func (e *upstreamError) Detail() string {
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
}
func truncate(s string, max int) string {
if len(s) <= max {
return 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
+30
View File
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
if len(p.tokens) == 0 {
return Compiled{}, fmt.Errorf("filteret er tomt")
}
// Refused before it is parsed, not while.
//
// `combine` rebuilds the whole accumulated SQL string and re-copies the
// argument slice per operator, so the cost is quadratic in the number of
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
// query string — took seven seconds of CPU and forty-six gigabytes of
// allocation in one request. The preview endpoint runs this on whatever is
// typed, and there is no write timeout on the server to cut it off.
//
// Two hundred terms is a filter nobody has ever written and far below where it
// costs anything.
if len(p.tokens) > maxTokens {
return Compiled{}, fmt.Errorf("filteret er for langt")
}
node, err := p.parseExpression()
if err != nil {
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
// --- parser -------------------------------------------------------------------
// The ceilings on one filter. See Parse for why.
const (
maxTokens = 200
maxDepth = 32
)
type parser struct {
tokens []token
pos int
ctx Context
// depth guards against a filter that is only parentheses. Each level is a
// stack frame, and nothing else stops somebody sending a megabyte of them.
depth int
}
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
if p.done() {
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
}
// Nesting is stack depth. A filter made of half a million opening parentheses
// is a megabyte of text and a stack frame for each.
p.depth++
defer func() { p.depth-- }()
if p.depth > maxDepth {
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
}
switch t := p.peek(); t.kind {
case tokNot:
+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": "[email protected]", "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 <[email protected]>",
// 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)
}
}
}
+42 -1
View File
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
}
s.loginLimiter.reset(clientIP(r))
// The bucket is cleared only when the login is *finished*.
//
// It used to be cleared here, on the strength of the password alone — before
// the second factor had been asked for at all. That handed somebody who
// already had the password an unlimited supply of ten-guess batches: log in,
// get a fresh bucket and a fresh pending session, spend ten guesses on the
// code, log in again. At three valid codes in any moment and twenty requests a
// second, that is even odds inside an afternoon — against the one control that
// exists precisely for when the password is already gone.
//
// So an account with a second factor clears nothing yet. handleLoginTOTP does
// it, once the code is right.
if !user.TOTPEnabled {
s.loginLimiter.reset(clientIP(r))
}
// A session that still needs a code gets a short life of its own: long enough
// to fetch a phone, not long enough to be worth stealing.
@@ -191,6 +205,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
session := sessionFrom(r.Context())
// Counted per account as well as per address.
//
// The address bucket alone is defeated by anybody with more than one, which is
// the ordinary case — a phone and a laptop is already two. A code is six
// digits: the only thing that makes it safe is that guesses are scarce, and
// scarcity has to be counted against the thing being attacked.
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
w.Header().Set("Retry-After", retryAfterSeconds(retry))
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
"too many verification codes; wait a moment and try again")
return
}
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
if err != nil {
// A recovery code is accepted in the same field. Making somebody find a
@@ -198,6 +225,15 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
// introduce a second concept.
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
if rerr != nil || !used {
// The pending session is left alive on purpose.
//
// Ending it on the first wrong digit was considered and rejected: it
// would make somebody who mistyped one number type their password again,
// and it buys almost nothing. What makes a six-digit code safe is that
// guesses are scarce, and the per-account bucket above is what makes
// them scarce — ten an hour, however many times the password is
// re-entered. The attack this closes was never "one session, many
// guesses"; it was "a fresh bucket for every password check".
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"that verification code is not valid")
return
@@ -205,6 +241,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
s.log.Info("recovery code used", "user", user.ID)
}
// Finished. Now the buckets may be cleared — both of them, so somebody who
// fumbled the code twice is not still waiting once they get it right.
s.loginLimiter.reset(clientIP(r))
s.totpLimiter.reset(user.ID)
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
s.internal(w, r, "promote session", err)
return
+70 -2
View File
@@ -301,7 +301,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
// not lock the account.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
// at slå den fra, og den har altid spurgt.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
@@ -368,7 +372,9 @@ func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
@@ -897,3 +903,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
}
}
}
// Andenfaktoren kan ikke slås til uden adgangskoden.
//
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "det forkerte kodeord",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
if resp.StatusCode == http.StatusOK {
t.Fatal("en tom adgangskode blev accepteret")
}
}
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
//
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
ts.do(t, "POST", "/api/v1/auth/logout", nil)
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
// her kunne blive ved i det uendelige.
refused := 0
for i := 0; i < 14; i++ {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
continue
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
}
}
if refused == 0 {
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
}
}
+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": "[email protected]", "summary": "[email protected]", "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(), "[email protected]")
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["[email protected]"] = []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"] != "[email protected]" {
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, "[email protected]", "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(), "[email protected]")
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)
}
}
+18 -3
View File
@@ -225,17 +225,32 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
a := &store.Attachment{
TaskID: taskID,
CommentID: r.FormValue("comment_id"),
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
// An attachment hangs on a task or on a comment, never both — the schema says
// so, and the form may have supplied a comment id.
if a.CommentID != "" {
a.TaskID = ""
//
// The comment is resolved and checked against the task in the URL. It was
// taken on trust, which meant the permission check above guarded the *task
// somebody named* while the file landed on *whatever comment they asked for*.
// A viewer on a shared project can list its comments, so she could read an id
// there and then post to a task in her own project with that id attached: the
// file appears under somebody else's comment, in a project she may only read,
// and is served to everyone in it from this origin under a name she chose.
// Somebody removed from a project could do the same with ids they kept.
if id := r.FormValue("comment_id"); id != "" {
owner, err := s.db.CommentTask(r.Context(), id)
if err != nil || owner != taskID {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a.CommentID, a.TaskID = id, ""
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"strings"
"testing"
)
// Politikken skal stå i svaret, og den skal stadig være stram.
//
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
policy := resp.Header.Get("Content-Security-Policy")
if policy == "" {
t.Fatal("der er ingen Content-Security-Policy på svaret")
}
// Målt på script-src alene, og ikke på hele politikken.
//
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
// bliver til et kørende script, og det er derfor den, prøven ser på.
scriptSrc := ""
for _, part := range strings.Split(policy, ";") {
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
scriptSrc = part
}
}
if scriptSrc == "" {
t.Fatal("der er ingen script-src i politikken")
}
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
if strings.Contains(scriptSrc, forbidden) {
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
}
}
// Og de fire, der skal være der.
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
if !strings.Contains(policy, want) {
t.Errorf("politikken mangler %q", want)
}
}
}
// Et bilag må ikke kunne blive læst som et dokument.
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
if inlineImage[mime] {
t.Errorf("%s står på listen over det, der vises inline", mime)
}
}
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
for _, mime := range []string{"image/png", "image/jpeg"} {
if !inlineImage[mime] {
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
}
}
}
+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
+4 -6
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
@@ -271,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
+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
+19
View File
@@ -11,6 +11,7 @@ import (
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
@@ -214,6 +215,24 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
return
}
// The address is checked where it is typed, as well as when it is dialled.
//
// safedial refuses a private destination at connect time and is the actual
// wall; this is so the person entering it hears about it now rather than
// discovering it later as a failed summary. A scheme other than http or https
// is refused outright — file:// and gopher:// are not providers.
if url := strings.TrimSpace(req.BaseURL); url != "" {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
return
}
if reason := safedial.CheckURL(url); reason != "" {
writeFieldErrors(w, map[string]string{"base_url": reason})
return
}
req.BaseURL = url
}
existing, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, r, "ai settings", err)
+42
View File
@@ -562,6 +562,48 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
}
}
// Connecting something else must not delete the AI provider.
//
// Reported from use as "the AI settings are not saved": they were saved, and then
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
// one row and each write deleted the other two. Every write returned 204.
//
// Through the handlers, because that is where it was seen. The shape is asserted
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
})
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// What the calendar and the mailbox handlers do when a handshake starts: write
// their own scope for the same person.
for _, scope := range []string{"calendar", "gmail"} {
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
map[string]any{"pkce_state": "s-" + scope}); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["provider"] != "anthropic" {
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
}
if body["model"] != "claude-sonnet-5" {
t.Errorf("the model is %v", body["model"])
}
if body["has_key"] != true {
t.Error("the key was taken with it")
}
}
func TestGmailSettings(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
+9 -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
@@ -250,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()
+164 -5
View File
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
return
}
@@ -62,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
return
}
// The archive is the same list asked the other way round, so it is the same
// endpoint with a flag rather than a second one that would have to be kept in
// step with this.
if r.URL.Query().Get("archived") != "" {
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list archived notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
return
}
@@ -74,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
}
// briefly replaces each note's body with the first of it.
//
// The list shows a title and about ninety characters. It was being sent whole
// notes to do that: with twelve hundred of them the response was 845 KB, of which
// ninety-eight per cent was body text nobody would read on that screen — fetched,
// parsed and held in memory on every visit.
//
// Body is emptied rather than shortened, and that is the important half. A field
// called `body` that holds a *piece* of the body is a trap: the editor would open
// it, the debounce would save it, and the note would be cut down to its own
// preview by nothing more than being looked at. Empty is a shape the client can
// check, and it does — see the notes page, which fetches the whole note when it
// opens one.
//
// Four hundred characters rather than ninety: the client strips Markdown before
// it shows anything, and a heading marker and a bold word are characters that
// vanish. Cheap insurance against a preview that ends up shorter than the line it
// is meant to fill.
const previewChars = 400
func briefly(notes []store.Note) []store.Note {
out := make([]store.Note, 0, len(notes))
for _, n := range notes {
body := []rune(n.Body)
if len(body) > previewChars {
body = body[:previewChars]
}
n.Preview = string(body)
n.Body = ""
out = append(out, n)
}
return out
}
// readable drops what this person may not see.
@@ -294,6 +341,14 @@ func noteFilename(title string) string {
// a few megabytes — and small enough that a wrong file is refused rather than read.
const maxNoteImportBytes = 64 << 20
// What the archive may become once it is unpacked, and how many entries it may
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
// nothing at all about what it expands to.
const (
maxNoteImportUnpacked = 2 << 30
maxNoteImportFiles = 20_000
)
// 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
@@ -332,24 +387,57 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
// 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.
// The archive as a whole, not just the upload.
//
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
// MB can become sixty-six gigabytes on the way out — written into the same
// volume the database lives on. The upload limit says nothing about that, and
// nothing else did either.
//
// Two ceilings: one per file, and one across the archive. A single enormous
// photograph and ten thousand small ones are different attacks with the same
// ending.
if len(zr.File) > maxNoteImportFiles {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that archive holds too many files")
return
}
budget := int64(maxNoteImportUnpacked)
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
}
// The header's own claim first, which costs nothing to check and rejects
// the obvious case before a byte is written. It is only a claim, so the
// reader below is limited as well.
if f.UncompressedSize64 > maxUploadBytes {
s.log.Warn("import: a file in the archive is too large", "file", name)
skipped++
continue
}
rc, err := f.Open()
if err != nil {
continue
}
hashed, size, err := s.storeUpload(rc)
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
rc.Close()
if err != nil {
s.log.Warn("import note file", "err", err, "file", name)
continue
}
if size > maxUploadBytes || size > budget {
// Over the line once it was actually read: remove what was written
// rather than keeping a truncated file that would open as nothing.
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
skipped++
continue
}
budget -= size
stored[name] = hashed
_ = size
files++
}
@@ -646,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// --- putting notes away, one or many ---------------------------------------------
type noteBulkRequest struct {
IDs []string `json:"ids"`
Archived *bool `json:"archived"`
Delete bool `json:"delete"`
}
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
// and few enough that the loop below cannot become somebody's afternoon.
const maxNoteBulk = 500
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
//
// One call rather than one per note, because the thing being asked is one thing:
// "put these away". Fifty separate requests would be fifty round trips, fifty
// chances for half of them to fail, and a list that redraws fifty times — and
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
// not the extreme one.
//
// Each note is still checked on its own. A set is a convenience for the caller
// and must never become a way to touch something they could not have touched one
// at a time.
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
var req noteBulkRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.IDs) == 0 {
writeFieldErrors(w, map[string]string{"ids": "required"})
return
}
if len(req.IDs) > maxNoteBulk {
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
return
}
done, skipped := 0, 0
for _, id := range req.IDs {
n, err := s.db.Note(r.Context(), id)
if err != nil {
s.internal(w, r, "get note", err)
return
}
// Not an error, and not a 404 for the whole call: a set can contain a note
// somebody else deleted a second ago, and the other forty-nine should still
// be put away. Counted so the answer is honest about it.
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
skipped++
continue
}
switch {
case req.Delete:
err = s.db.DeleteNote(r.Context(), id)
case req.Archived != nil:
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
default:
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
return
}
if err != nil {
s.internal(w, r, "note bulk", err)
return
}
done++
}
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
}
@@ -0,0 +1,77 @@
package httpapi
import (
"archive/zip"
"bytes"
"io"
"testing"
)
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
// på.
//
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
//
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
// færdig.
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("bombe.bin")
if err != nil {
t.Fatal(err)
}
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
// uden at prøven selv bruger en gigabyte.
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
packed := int64(buf.Len())
if packed > 64<<10 {
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
if err != nil {
t.Fatal(err)
}
f := zr.File[0]
if f.UncompressedSize64 != 4<<20 {
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
}
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
defer rc.Close()
budget := int64(1 << 20)
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
if err != nil {
t.Fatal(err)
}
if n <= budget {
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
}
}
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
if maxNoteImportUnpacked <= maxUploadBytes {
t.Error("arkivets budget er ikke større end én enkelt fil")
}
if maxNoteImportFiles < 2000 {
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
}
}
+8 -1
View File
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
}
// The list carries a preview, not the whole note — see briefly(). The body is
// fetched by id, which is what the interface does when it opens one.
_, 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)
noteID := notes[0].(map[string]any)["id"].(string)
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
}
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
body := whole["body"].(string)
// The link points at the file where it now lives, not at a path inside an
// archive nobody kept.
+23 -1
View File
@@ -2,11 +2,13 @@ package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/push"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
@@ -170,6 +172,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
return
}
// The endpoint is an address this server will POST to, on a schedule the
// caller chooses — set a reminder a minute out and the push job fetches it,
// again every minute. Unchecked, that is a way to make the instance reach
// anything it can route to, on demand.
//
// https only: a push service is on the public internet and speaks TLS. Nothing
// legitimate needs http here, and allowing it is the difference between a
// blind request and a readable one.
if !strings.HasPrefix(req.Endpoint, "https://") {
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
return
}
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
writeFieldErrors(w, map[string]string{"endpoint": reason})
return
}
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
UserID: userFrom(r.Context()).ID,
Endpoint: req.Endpoint,
@@ -189,7 +208,10 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
if err := decodeJSON(w, r, &req); err != nil {
return
}
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
// Scoped to the caller. It was not, so anybody who learned somebody else's
// endpoint could unsubscribe their device — a small thing to do to somebody
// and no reason at all to be able to.
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
s.internal(w, r, "delete push subscription", err)
return
}
+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 {
+28
View File
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
}
// handleReorderSections sets the order of the sections inside one project.
//
// Under /projects/{projectID}/ rather than beside the other section routes, which
// hang off a section id: the thing being changed here is the project's list, not
// any one section in it. That also puts it behind the same project permission
// check as everything else in this subtree, instead of resolving a section to its
// project one id at a time and hoping they all named the same one.
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// The same ceiling as the projects list, and a bound on the transaction rather
// than an opinion about how many sections a project may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
projectID := chi.URLParam(r, "projectID")
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
s.internal(w, r, "reorder sections", err)
return
}
s.activity(r, projectID, "", "section.updated", nil)
w.WriteHeader(http.StatusNoContent)
}
// sectionAccess resolves a section to its project and checks the caller may edit
// it. Sections have no permissions of their own — they belong to a project, and
// that is where access is decided.
+65
View File
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
}
}
// Sections can be dragged into another order, and the order is the project's.
//
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
// order the work happens in — and until this endpoint existed the only way to
// change it was to delete a section and make it again, which moves its tasks to
// the unsectioned area on the way past.
func TestSectionsCanBeReordered(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
id := project["id"].(string)
ids := map[string]string{}
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
for _, name := range names {
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
ids[name] = body["id"].(string)
}
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
order := make([]string, len(want))
for i, name := range want {
order[i] = ids[name]
}
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": order})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
}
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
got, _ := listed["sections"].([]any)
if len(got) != len(want) {
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
}
for i, raw := range got {
if name := raw.(map[string]any)["name"]; name != want[i] {
t.Errorf("section %d is %v, want %q", i, name, want[i])
}
}
// A section belonging to somebody else's project is not moved by naming it
// here. The ids in the body are not checked one by one — the WHERE clause is
// what refuses them — so this is the assertion that it actually does.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
otherID := other["id"].(string)
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
map[string]any{"name": "Havearbejde"})
foreignID := foreign["id"].(string)
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": []string{foreignID}})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
}
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
elsewhere, _ := stillThere["sections"].([]any)
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
t.Errorf("the other project's section did not survive: %v", elsewhere)
}
}
// A project can be created straight into a group.
//
// `group_id` was declared on the request type, documented on it, and applied by
+49 -4
View File
@@ -64,6 +64,11 @@ type Server struct {
// than the rest of the API.
loginLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
// the ordinary case — and a six-digit code is only safe while guesses are
// scarce, so the scarcity has to be counted against the thing being attacked.
totpLimiter *limiter
// Computed once from the built index.html; see csp.go.
csp string
@@ -81,6 +86,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
@@ -133,6 +142,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)
@@ -194,11 +209,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Put("/nav-order", s.handleSetNavOrder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
r.Post("/totp/confirm", s.handleTOTPConfirm)
r.Post("/totp/disable", s.handleTOTPDisable)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
// Behind requireSession, like the API tokens and for the same
// reason: a leaked token must not be able to end the sessions of
@@ -208,6 +219,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Turning the second factor on, and the codes that get you back
// in. Sessions only, and setup asks for the password.
//
// These sat outside this group, which meant the door opened with
// a token and closed only with a password: `disable` has always
// asked for the password, `setup` asked for nothing. A stolen
// token could therefore fetch the secret, load it into its own
// authenticator, confirm it, and keep the only copy of the
// recovery codes. The owner's next login would stop at a code
// they do not have, and they could not turn it off without
// logging in first. A theft becomes a tenancy — the same
// sentence written next door about passkeys, and the same rule.
r.Post("/totp/setup", s.handleTOTPSetup)
r.Post("/totp/confirm", s.handleTOTPConfirm)
r.Post("/totp/disable", s.handleTOTPDisable)
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
@@ -305,6 +333,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// 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)
// Mange på én gang. Efter en import af tolv hundrede noter er det at
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
r.Post("/bulk", s.handleNoteBulk)
})
r.Route("/mailboxes", func(r chi.Router) {
@@ -333,6 +364,19 @@ 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)
@@ -382,6 +426,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
})
r.Group(func(r chi.Router) {
+17
View File
@@ -20,7 +20,24 @@ type totpSetupResponse struct {
}
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
// The password, the same as disable has always asked for.
//
// Symmetry is the point. Turning the second factor on is at least as
// consequential as turning it off — it decides which device gets to say yes
// from now on — and a door that opens more easily than it closes is not a
// door. Together with the move behind requireSession, this is what stops a
// stolen credential from enrolling its own authenticator and keeping the only
// copy of the recovery codes.
var req totpDisableRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
writeFieldErrors(w, map[string]string{"password": "not correct"})
return
}
if user.TOTPEnabled {
writeError(w, http.StatusConflict, CodeConflict,
"two-factor authentication is already switched on")
+49
View File
@@ -41,6 +41,10 @@ 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)
@@ -74,6 +78,13 @@ 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.
@@ -342,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 {
+30
View File
@@ -30,6 +30,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
@@ -104,3 +105,32 @@ func Client(timeout time.Duration) *http.Client {
func AllowPrivate(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
}
// CheckURL reports why an address must not be fetched, or "" if it may be.
//
// A convenience for saying no at the moment somebody types an address, rather
// than later when a request quietly fails. It is NOT the protection — a name can
// resolve one way now and another way when it is dialled, which is why Control
// exists and runs every time. Anything this rejects is certainly wrong; anything
// it accepts is merely not obviously wrong.
func CheckURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "that is not a URL"
}
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
return "that address is on this machine or its private network"
}
// A bare name is looked up once so an obvious "localhost" is caught here. A
// failure to resolve is not an error: the host may simply not exist yet, and
// refusing to save an address because DNS was slow would be its own bug.
if ips, err := net.LookupIP(host); err == nil {
for _, ip := range ips {
if Blocked(ip) {
return "that address is on this machine or its private network"
}
}
}
return ""
}
+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: "[email protected]",
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 != "[email protected]" {
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: "[email protected]",
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: "[email protected]", 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: "[email protected]", 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: "[email protected]", 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)
}
}
+5 -1
View File
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
}
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
// The conflict target is (user_id, scope) since 0025 — before that the table
// held one row per person and the upsert renamed its scope, which is the bug
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
// against the schema this test is running on.
if _, err := db.ExecContext(context.Background(), `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
userID, values); err != nil {
t.Fatal(err)
}
@@ -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);
@@ -0,0 +1,23 @@
-- A note can be put away without being thrown away.
--
-- The trash already exists and is the wrong tool. It says "this was a mistake,
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
-- an archive of twelve hundred imported notes is exactly the case where most of
-- them are worth keeping and almost none are worth looking at. Without somewhere
-- to put them, the list is the whole archive forever, and finding this week's note
-- means reading past a decade.
--
-- A column rather than a folder. Verdande already refused a second hierarchy for
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
-- note is current, and it has to be answerable next to `deleted_at`, which is the
-- other bit of the same kind.
--
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
-- one: "when was this put away" answers questions a flag cannot, and it costs the
-- same byte.
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
-- The list asks "not deleted, not archived, newest first" on every load, and with
-- twelve hundred notes that is the query that has to be quick.
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
WHERE deleted_at IS NULL;
@@ -0,0 +1,37 @@
-- One row per person *and scope*, which is what the table always meant.
--
-- `user_settings` was created with `user_id` as its whole primary key, and its
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
-- and whichever scope was written last was the only one that survived: saving an
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
-- row it landed on.
--
-- This was seen coming twice. Migration 0011 gave the instance's settings their
-- own table partly for this reason and wrote the warning down in as many words;
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
-- time". The third time was the calendar, which added a `calendar` scope beside
-- `ai` and `gmail` — and walked in.
--
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
-- left to warn about.
--
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
-- rows are copied as they are: each person has at most one, and it holds whatever
-- scope won last. What the earlier writes lost is already gone and cannot be
-- recovered here — the values were overwritten, not orphaned.
CREATE TABLE user_settings_new (
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, scope)
);
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
SELECT user_id, scope, values_json, updated_at FROM user_settings;
DROP TABLE user_settings;
ALTER TABLE user_settings_new RENAME TO user_settings;
+102 -21
View File
@@ -10,15 +10,26 @@ import (
// Note is a page of Markdown belonging to a project, or to nobody.
type Note struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Preview is the first of the body, for a list that shows one line of it.
//
// It exists so a list of twelve hundred notes does not have to carry twelve
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
// characters of each. When it is set, Body is deliberately empty — see
// handleListNotes. That is not tidiness but a guard: an editor handed a
// truncated body would save the truncation, and the note would be cut down to
// its own preview.
Preview string `json:"preview,omitempty"`
// Links is what the body points at, read out of the text on the way in. Never
// set by a caller: it is derived, and a caller that set it would be describing
@@ -33,7 +44,16 @@ type NoteLink struct {
}
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at`
created_at, updated_at, deleted_at, archived_at`
// The same columns, said with the table in front of them.
//
// Needed only by the search, which joins `notes_fts` — and that table carries its
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
notes.archived_at`
// Reference syntax, and deliberately the same characters the quick-add parser
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
@@ -199,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
// 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
return db.allNotes(ctx, userID, false)
}
// ArchivedNotes is the same list, the other way round: what has been put away.
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
return db.allNotes(ctx, userID, true)
}
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
// Archived notes are left out of the ordinary list rather than mixed in and
// greyed: the point of putting something away is not seeing it.
state := "notes.archived_at IS NULL"
if archived {
state = "notes.archived_at IS NOT NULL"
}
return db.notesJoin(ctx, noteColumnsQualified, `
WHERE `+state+` AND deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
@@ -238,12 +273,27 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
if expr == "" {
return nil, nil
}
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
// on it would work and read the whole index to do it.
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
ORDER BY updated_at DESC
// Ordered by how well it matched, not by when it was last touched.
//
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
// useless at twelve hundred: every one of them was imported the same evening,
// so `updated_at` says nothing, and the limit then cut the list at fifty
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
// the note actually called "Tesla Model Y" second — which reads as a search
// that does not work, because from where the person is sitting it is one.
//
// bm25 with the title weighted ten times the body. A word in the title is what
// the note is *about*; the same word once in a long body is a mention. `fold`
// carries the same text transliterated, so it must weigh the same as the body
// or a Danish match would outrank an exact one.
//
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
// subquery throws it away.
return db.notesJoin(ctx, noteColumnsQualified, `
JOIN notes_fts ON notes_fts.rowid = notes.rowid
WHERE notes.deleted_at IS NULL
AND notes_fts MATCH ?
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
LIMIT ?`, expr, limit)
}
@@ -278,6 +328,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
return err
}
// SetNoteArchived puts a note away, or takes it back out.
//
// Separate from DeleteNote because they mean different things and the difference
// is the whole feature: the trash says "this was a mistake, and in thirty days it
// is gone", and the archive says "this is finished, and I want to keep it". A
// twelve-hundred-note import is mostly the second, and without somewhere to put
// them the list is the whole archive forever.
//
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
// sorted by when things were last written should not be reordered by somebody
// tidying up.
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
var at any
if archived {
at = time.Now().Unix()
}
_, err := db.ExecContext(ctx,
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, 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,
@@ -295,7 +366,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
}
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
return db.notesJoin(ctx, noteColumns, where, args...)
}
// notesJoin is notesWhere with the column list spelled out, for the one caller
// that joins another table and must therefore say which `id` it means.
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
if err != nil {
return nil, err
}
@@ -335,10 +412,10 @@ func scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
var deleted sql.NullInt64
var deleted, archived sql.NullInt64
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
&n.Pinned, &created, &updated, &deleted); err != nil {
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
return n, err
}
n.ProjectID = projectID.String
@@ -349,5 +426,9 @@ func scanNote(row scanner) (Note, error) {
t := time.Unix(deleted.Int64, 0)
n.DeletedAt = &t
}
if archived.Valid {
t := time.Unix(archived.Int64, 0)
n.ArchivedAt = &t
}
return n, nil
}
+36
View File
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
t.Error("a note title did not match itself")
}
}
// En søgning skal svare med det, der passer bedst, øverst.
//
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
// fra brugerens plads er præcis dét.
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
for _, body := range []string{
"# Tesla Model Y\n\nBilen selv.\n",
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
} {
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
t.Fatal(err)
}
}
found, err := db.SearchNotes(ctx, "Tesla", 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 3 {
t.Fatalf("fandt %d noter, ventede 3", len(found))
}
if found[0].Title != "Tesla Model Y" {
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
}
}
+37 -5
View File
@@ -140,11 +140,17 @@ func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) e
if sub.ID == "" {
sub.ID = NewID()
}
// The upsert no longer moves the row between accounts.
//
// `user_id = excluded.user_id` was in the DO UPDATE, so anybody who learned
// another person's endpoint could claim it by subscribing with it: the
// victim's device would then receive the claimant's notifications and none of
// its own. A re-subscribe from the same person still refreshes the keys, which
// is what the upsert is actually for.
_, err := db.ExecContext(ctx,
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
@@ -175,7 +181,21 @@ func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushS
return out, rows.Err()
}
func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error {
// DeletePushSubscription removes one of this person's devices.
//
// Scoped by user as well as by endpoint. It was endpoint alone, which meant
// anybody who learned somebody else's endpoint could silently unsubscribe their
// phone — and the endpoint is the only thing about a subscription that ever
// leaves the browser.
func (db *DB) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
_, err := db.ExecContext(ctx,
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, endpoint, userID)
return err
}
// dropPushEndpoint removes a subscription the push service has declared dead.
// Unexported so the only *request* path to a delete stays the scoped one.
func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return err
}
@@ -186,7 +206,10 @@ func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error
// silently unsubscribing somebody's phone.
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
if permanent {
return db.DeletePushSubscription(ctx, endpoint)
// Not scoped by user, and that is right here: this is the push service
// saying the endpoint is gone, not a person asking for something. There is
// no caller to check against — the caller is the delivery job.
return db.dropPushEndpoint(ctx, endpoint)
}
_, err := db.ExecContext(ctx,
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
@@ -289,6 +312,11 @@ var secretFields = map[string]bool{
"access_token": true,
"password": true,
"client_secret": true,
// The AI provider's key. Missed when this list was written, and it is exactly
// the same kind of thing as the rest: a live credential, in a settings blob,
// in a database anybody with a backup file can read. Existing rows convert
// themselves the next time the settings are saved.
"api_key": true,
}
// seal returns a copy of values with the secret fields sealed. Without a key it
@@ -363,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
if err != nil {
return err
}
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
// alone and set `scope = excluded.scope` — so writing one scope did not add a
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
// `calendar` settings each deleted the other two, and the write reported
// success every time.
_, err = db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
scope = excluded.scope,
ON CONFLICT (user_id, scope) DO UPDATE SET
values_json = excluded.values_json,
updated_at = excluded.updated_at`,
userID, scope, string(raw), time.Now().Unix())
+29
View File
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
})
}
// ReorderSections writes the order of one project's sections, top to bottom.
//
// The whole list at once, the same as ReorderProjects and for the same reasons: a
// project has a handful of sections, so writing 0, 1, 2 … is one small
// transaction that cannot run out of precision, and a list is impossible to get
// into the half-ordered state a sequence of individual moves leaves behind when
// one of them fails.
//
// Scoped to the project rather than to a user. A section has no owner of its own —
// it belongs to a project, and whether the caller may rearrange it is a question
// about that project, which the handler has already asked. Ids belonging to some
// other project are skipped by the WHERE clause rather than refused, so a stale
// list from a client that has just moved a section elsewhere cannot rearrange a
// project the caller was never looking at.
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
now := time.Now().Unix()
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE sections SET sort_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
float64(i), now, id, projectID); err != nil {
return err
}
}
return nil
})
}
// SectionByName resolves a "/name" from quick add within one project.
//
// Case-insensitive, and scoped to the project the task is landing in — a section
+29
View File
@@ -20,6 +20,13 @@ func FoldDanish(s string) string {
return r.Replace(s)
}
// The ceilings on a search. See MatchExprOver for why they exist; both are far
// above anything a person types and far below anything that costs.
const (
maxSearchInput = 512
maxSearchTerms = 16
)
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
// expression.
//
@@ -51,6 +58,23 @@ func MatchExprOver(query string, columns ...string) string {
return ""
}
// Capped, because the expression is built from the input and then run inside
// SQLite on one of a handful of pooled connections.
//
// Every term becomes roughly fifty bytes of FTS5, and every one of them is a
// prefix query — which is what makes ⌘K feel like it searches as you type, and
// also what makes each one expensive. A quarter of a megabyte pasted into the
// box became a two-megabyte MATCH expression and half a minute inside SQLite,
// holding a connection the whole time. A few of those at once and the database
// is unavailable to everybody on the instance, logins included.
//
// Sixteen terms is far past anything a person types into a search box and
// nowhere near enough to hurt. Silently, because a message about a limit
// nobody has reached on purpose is noise.
if len(terms) > maxSearchTerms {
terms = terms[:maxSearchTerms]
}
var b strings.Builder
for i, term := range terms {
if i > 0 {
@@ -70,6 +94,11 @@ func MatchExprOver(query string, columns ...string) string {
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
func tokenize(query string) []string {
// Trimmed before it is split, so a megabyte of text is not walked rune by rune
// only to have all but sixteen of the results thrown away.
if len(query) > maxSearchInput {
query = query[:maxSearchInput]
}
fields := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
+62
View File
@@ -124,3 +124,65 @@ func sealedStore(t *testing.T) (*DB, string) {
}
return db, user.ID
}
// Three integrations, one person, and none of them may delete the others.
//
// `user_settings` had `user_id` as its whole primary key and an upsert that
// rewrote `scope` on conflict, so every person had exactly one settings row and
// the last write won the lot: saving an AI provider deleted the Gmail connection,
// and connecting a calendar deleted the AI provider. The write reported success
// each time, which is why it was reported as "the AI settings are not saved"
// rather than as "connecting the calendar deleted them".
//
// Written against the store rather than the handlers, because the shape was the
// bug: every caller was correct on its own.
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
want := map[string]map[string]any{
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
}
// In this order on purpose: the AI settings are written first and read last,
// so a scope that steals the row is caught by the one written earliest.
for _, scope := range []string{"ai", "gmail", "calendar"} {
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
for _, scope := range []string{"ai", "gmail", "calendar"} {
got, err := db.UserSettings(ctx, userID, scope)
if err != nil {
t.Fatalf("read %s: %v", scope, err)
}
for key, value := range want[scope] {
if got[key] != value {
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
}
}
}
// And a second write to one scope updates it rather than adding another row —
// the upsert still has to upsert.
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
t.Fatal(err)
}
var rows int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 3 {
t.Errorf("the person has %d settings rows, want 3", rows)
}
back, err := db.UserSettings(ctx, userID, "ai")
if err != nil {
t.Fatal(err)
}
if back["provider"] != "openai" {
t.Errorf("the second write did not land: %v", back["provider"])
}
}
+20 -2
View File
@@ -129,7 +129,14 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
return nil, err
}
u.TOTPSecret = secret.String
// Opened on the way out. A row written before the key existed is returned as
// itself — see secret.Unseal — so this does not need a migration; a secret
// converts the next time it is written.
if plain, err := db.unsealValue(secret.String); err == nil {
u.TOTPSecret = plain
} else {
u.TOTPSecret = secret.String
}
u.TOTPEnabled = totpEnabled == 1
u.IsAdmin = isAdmin == 1
// A malformed value is read as "nothing folded" rather than as an error: the
@@ -433,9 +440,20 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
}
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
// Sealed, like a mailbox password and for the same reason: the copies, not the
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
// generate valid codes for every account on the instance — which turns the
// second factor from a defence into a file somebody can take home.
//
// It was missed when internal/secret was written, along with three other
// values; see SECURITY.md.
var value any
if secret != "" {
value = secret
sealed, err := db.sealValue(secret)
if err != nil {
return err
}
value = sealed
}
_, err := db.ExecContext(ctx,
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
+465 -81
View File
@@ -6,12 +6,12 @@
<title>verdande — self-hosted tasks, shared</title>
<meta
name="description"
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
/>
<meta name="theme-color" content="#111312" />
<meta property="og:title" content="verdande" />
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
<meta property="og:type" content="website" />
<meta property="og:image" content="screenshots/today.webp" />
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
@@ -76,10 +76,10 @@
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10;
/* In the bar now rather than floating over the corner. Fixed, it sat
on top of whatever happened to scroll under it — on a narrow window
that was the first screenshot's own corner. */
flex: none;
width: 2.25rem;
height: 2.25rem;
display: grid;
@@ -141,10 +141,86 @@
padding: 0 1.5rem;
}
/* --- the bar that stays -------------------------------------------
A page this long had its only way out at the very top: somebody who
read as far as "What it will not do" had to scroll back past nine
screenshots to reach the documentation. The bar is the way out from
wherever you are.
`sticky` and not `fixed`: sticky keeps its space in the flow, so
nothing below has to be pushed down by a number that then has to be
kept in step with the bar's own height.
Full width, with the wrap *inside* it. Put the other way round, the
background would end where the column ends and the page would scroll
past it on both sides. */
.topbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--ground);
border-bottom: 1px solid var(--line);
}
.bar {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.625rem 1.5rem;
}
.bar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--ink);
text-decoration: none;
font-weight: 560;
letter-spacing: -0.01em;
}
.bar-brand .rune {
color: var(--accent);
font-size: 1.125rem;
line-height: 1;
}
.bar-links {
display: flex;
align-items: center;
gap: 1.25rem;
margin-left: auto;
font-size: 0.875rem;
}
.bar-links a {
color: var(--ink-muted);
text-decoration: none;
}
.bar-links a:hover {
color: var(--ink);
}
/* The three in-page links go first on a narrow screen: they are the ones
a phone can reach by scrolling anyway. Documentation and Source cannot
be reached any other way, so they stay. */
@media (max-width: 640px) {
.bar-links .inpage {
display: none;
}
}
/* Or the bar covers the heading you just jumped to. */
:target {
scroll-margin-top: 4.5rem;
}
/* --- hero --- */
header {
padding: 6rem 0 4rem;
.hero {
padding: 5rem 0 3.5rem;
text-align: center;
}
@@ -172,7 +248,7 @@
}
.norn {
margin: 2rem auto 0;
margin: 0 auto 1.75rem;
max-width: 34rem;
font-size: 0.9375rem;
color: var(--ink-faint);
@@ -289,44 +365,104 @@
/* --- features --- */
/* A hairline between sections rather than under every heading: the page
is one column, and the line says where one subject stops. */
section {
padding: 3rem 0;
padding: 3.5rem 0;
border-top: 1px solid var(--line);
}
h2 {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 1.5rem;
font-weight: 560;
letter-spacing: -0.02em;
margin: 0 0 2rem;
}
/* A short accent rule in front of every h2. The sections had nothing to
mark them but their own words, and at a glance the page read as one
long run of paragraphs. */
h2::before {
content: '';
flex: none;
width: 1.125rem;
height: 2px;
border-radius: 1px;
background: var(--accent);
transform: translateY(-0.35em);
}
/* Three groups rather than eighteen cards in a row. Grouped, the list is
read as three answers — what it does, what it talks to, what it
promises — and any of the three can be skipped. */
.group-label {
margin: 2.5rem 0 1rem;
font-size: 0.75rem;
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-faint);
}
.group-label:first-of-type {
margin-top: 0;
}
/* Cards with their own edges, not cells in one table.
The table shared a hairline grid, which meant the last row had to be
full or it was a hole in the drawing — and it was a hole, for as long
as there were ten of them. A card that stands alone on its row is a
card; an empty cell is a mistake. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.875rem;
}
.cell {
background: var(--ground);
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.125rem 1.25rem 1.25rem;
transition: border-color 150ms ease;
}
.cell:hover {
border-color: var(--line-strong);
}
.cell h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
margin: 0 0 0.4375rem;
font-size: 0.9375rem;
font-weight: 560;
}
.cell p {
margin: 0;
font-size: 0.9375rem;
font-size: 0.875rem;
color: var(--ink-muted);
}
.cell code {
font-family: var(--font-mono);
font-size: 0.8125em;
color: var(--ink);
}
/* A card that has something to admit rather than something to offer.
It is not a footnote and it is not fine print: it is the same size as
everything else, and it says the same thing the README says. */
.cell .caveat {
margin: 0.5rem 0 0;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
font-size: 0.8125rem;
color: var(--ink-faint);
}
/* --- install --- */
pre {
@@ -374,6 +510,109 @@
color: var(--ink-muted);
}
/* Four facts under the buttons, in the place where a landing page usually
puts logos it has not earned. They are the four questions somebody
self-hosting asks before anything else. */
ul.facts {
list-style: none;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1.25rem;
margin: 2rem 0 0;
padding: 0;
font-size: 0.8125rem;
color: var(--ink-faint);
}
ul.facts li {
display: flex;
align-items: center;
gap: 1.25rem;
}
/* The separator belongs between two facts, not after the last one — so it
is drawn by the item that follows it. */
ul.facts li + li::before {
content: '';
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--line-strong);
}
/* On a phone the row wraps, and a separator drawn by the item that follows
it lands at the *start* of the new line — a dot with nothing before it.
No separators there; the wrap is the separation. */
@media (max-width: 640px) {
ul.facts {
gap: 0.375rem 1.125rem;
}
ul.facts li + li::before {
display: none;
}
}
/* Two ways to run it, side by side, because they are alternatives rather
than steps. Stacked, the second read as what you do after the first. */
.panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 0.875rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.panel h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 560;
}
/* Sunk rather than raised: inside a card, a block in --surface would be
the same colour as the card it sits in. */
.panel pre {
background: var(--ground);
margin: 1rem 0 0;
}
.panel p {
margin: 0;
font-size: 0.875rem;
color: var(--ink-muted);
}
/* The non-goals were one paragraph of seven sentences, which is the shape
nobody reads. Each is its own line now, and they can be counted. */
ul.nogo {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.5rem 1.75rem;
margin: 0;
padding: 0;
font-size: 0.9375rem;
color: var(--ink-muted);
}
ul.nogo li {
position: relative;
padding-left: 1.25rem;
}
ul.nogo li::before {
content: '—';
position: absolute;
left: 0;
color: var(--ink-faint);
}
.demo-note {
margin: 1rem 0 0;
font-family: var(--font);
@@ -524,27 +763,38 @@
</head>
<body>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
<div class="wrap">
<header>
<div class="topbar">
<div class="wrap bar">
<a class="bar-brand" href="#top">
<span class="rune" aria-hidden="true">ᚹ</span>
verdande
</a>
<nav class="bar-links" aria-label="Sections">
<a class="inpage" href="#screens">Screens</a>
<a class="inpage" href="#features">Features</a>
<a class="inpage" href="#running">Running it</a>
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
<a href="https://github.com/kristianwind/verdande">Source</a>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
</nav>
</div>
</div>
<div class="wrap" id="top">
<header class="hero">
<span class="mark" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
<p class="lede">
Self-hosted tasks and projects, shared with the people you share the work with.
One binary, SQLite inside it.
Self-hosted tasks, notes and projects, shared with the people you share the work
with. One binary, SQLite inside it.
</p>
<p class="rune-note">
Built to be deployed as a <strong>Rune</strong> in
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
</p>
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
in the middle of. Which is what a to-do list is.
</p>
<div class="actions">
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
@@ -553,6 +803,14 @@
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
</div>
<ul class="facts">
<li>One Go binary</li>
<li>SQLite inside it</li>
<li>amd64 and arm64</li>
<li>No account anywhere but yours</li>
<li>MIT</li>
</ul>
</header>
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
@@ -584,7 +842,7 @@
</div>
</div>
<section>
<section id="screens">
<h2>What it looks like</h2>
<figure class="shot only-dark">
<img
@@ -669,11 +927,43 @@
/>
<figcaption>What has been done on the server, across every project.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/delegated.webp"
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Waiting on others — grouped by who has it.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-source.webp"
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/settings.webp"
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
</figure>
</div>
</section>
<section>
<section id="features">
<h2>What it does</h2>
<p class="group-label">Doing the work</p>
<div class="grid">
<div class="cell">
<h3>Reads what you type</h3>
@@ -683,20 +973,54 @@
</p>
</div>
<div class="cell">
<h3>Shared, properly</h3>
<h3>Nothing to remember</h3>
<p>
Owner, editor and viewer roles. People outside your instance join by
invite link. Changes appear live.
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. <code>⌘K</code> goes anywhere else.
</p>
</div>
<div class="cell">
<h3>Search that reads Danish</h3>
<p>
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
consider <code>ø</code> an accented <code>o</code>, so a search that
leans on that alone is broken for the people this was built for.
</p>
</div>
<div class="cell">
<h3>Repeats, and says so in words</h3>
<p>
Recurring tasks are RRULE underneath and "every other Tuesday" on the
screen. Reminders are absolute or relative, and go out to the devices
you have allowed them on.
</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
Rich text to write in, Markdown on disk — and a source view to see it.
Images are pasted or dragged in. <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.
written about it. Archive what is done with it; the bin is for mistakes.
</p>
</div>
<div class="cell">
<h3>Waiting on others</h3>
<p>
What you have handed to somebody else, on its own page, grouped under
the person who has it. The thing a shared list stops being able to tell
you the moment it is shared.
</p>
</div>
</div>
<p class="group-label">Connected to what you already use</p>
<div class="grid">
<div class="cell">
<h3>Shared, properly</h3>
<p>
Owner, editor and viewer roles. People outside your instance join by
invite link. Changes appear live.
</p>
</div>
<div class="cell">
@@ -706,6 +1030,27 @@
here. Not a read-only feed pretending.
</p>
</div>
<div class="cell">
<h3>A calendar of its own</h3>
<p>
A month or a week of what is due, and a secret ICS address to subscribe
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
laid over the top, read-only.
</p>
<p class="caveat">
The Google half is written to Google's documentation and has not yet
been run against Google. The feed and CalDAV have.
</p>
</div>
<div class="cell">
<h3>Your mail, as an inbox</h3>
<p>
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
it becomes a task. Forward one to your own address instead and the
subject is parsed like anything else you type. One way: unstarring it
later does nothing to the task you made.
</p>
</div>
<div class="cell">
<h3>Claude, connected</h3>
<p>
@@ -714,35 +1059,39 @@
</p>
</div>
<div class="cell">
<h3>Your mail, as an inbox</h3>
<h3>AI only if you ask for it</h3>
<p>
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
a task. Forward one to your own address instead and the subject is
parsed like anything else you type. One way: unstarring it later does
nothing to the task you made.
Off by default, your own key, and your choice of Anthropic, OpenAI,
Google or something local. Nothing leaves the server until you have
filled that in, and everything works without it.
</p>
</div>
</div>
<p class="group-label">Yours, and checkable</p>
<div class="grid">
<div class="cell">
<h3>Nothing to remember</h3>
<h3>More than one way in</h3>
<p>
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. Sign in with a passkey and there is no
password to forget either.
</p>
</div>
<div class="cell">
<h3>Yours to leave</h3>
<p>
Import from Todoist, export back to it, or take everything as JSON. The
round trip is tested, not promised.
Passkeys, or a password hashed with Argon2id and a second factor with
recovery codes. Every device you are signed in on is listed, and any of
them can be signed out from here.
</p>
</div>
<div class="cell">
<h3>Keys stay behind</h3>
<p>
Mail tokens and mailbox passwords are encrypted in the database, and the
key lives in a file beside it — not inside it. So a backup you download,
or one that ends up somewhere it should not, does not open your mail.
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
encrypted in the database, and the key lives in a file beside it — not
inside it. So a backup you download, or one that ends up somewhere it
should not, does not open your mail.
</p>
</div>
<div class="cell">
<h3>Nobody signs themselves up</h3>
<p>
The first account is the administrator, and after that people arrive by
invitation. An address on the internet is not an open door.
</p>
</div>
<div class="cell">
@@ -753,50 +1102,85 @@
instance's record, not the person's.
</p>
</div>
<div class="cell">
<h3>Yours to leave</h3>
<p>
Import from Todoist, export back to it, take everything as JSON, or
hand the notes a zip of Markdown and get the same zip back out. The
round trip is tested, not promised.
</p>
</div>
<div class="cell">
<h3>It counts itself, out loud</h3>
<p>
Once a day an instance says it exists — a random id, the version, and
nothing else. No tasks, no names, no addresses. It is on to begin with,
the settings page prints the exact payload, and one switch stops it.
</p>
</div>
</div>
</section>
<section>
<section id="running">
<h2>Running it</h2>
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
<p class="muted">
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
<code>rune/verdande.yaml</code>, create a server from it, set the public address
and your mail server, and start it. No port forwarding — give it a subdomain and
the panel routes it through your Cloudflare Tunnel.
</p>
<h3 class="sub">Or with Docker, anywhere</h3>
<pre><code>docker run -d --name verdande \
<div class="panels">
<div class="panel">
<h3>As a Rune in Yggdrasil Panel</h3>
<p>
This is what it was built for. <strong>Runes → Carve a rune</strong>,
upload <code>rune/verdande.yaml</code>, create a server from it, set the
public address and your mail server, and start it. No port forwarding —
give it a subdomain and the panel routes it through your Cloudflare
Tunnel.
</p>
</div>
<div class="panel">
<h3>Or with Docker, anywhere</h3>
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
<pre><code>docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest</code></pre>
<p class="muted" style="margin-top: 1rem">
Open the address and create the first account. That one is the administrator.
</p>
</div>
</div>
<ul class="plain">
<li>No database to provision — SQLite is inside the binary.</li>
<li>Distroless image: no shell, no package manager, one static file.</li>
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
</ul>
</section>
<section>
<section id="limits">
<h2>What it will not do</h2>
<p class="muted">
No karma or points. No team workspaces. No native apps. No mail integration
beyond Gmail and the forwarding address. No languages beyond Danish and English.
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
</p>
<ul class="nogo">
<li>No karma, streaks or points.</li>
<li>No team workspaces — projects and the people in them are the whole model.</li>
<li>No native apps. It is a PWA, and it installs on a phone.</li>
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
<li>No languages beyond Danish and English.</li>
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
</ul>
</section>
<footer>
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
where it stood between somebody arriving and the buttons they came
for — and it is a closing thought, not an opening one. -->
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
in the middle of. Which is what a to-do list is.
</p>
<p>
MIT licensed ·
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
</p>
<p>
Built to run as a Rune in
+539
View File
@@ -358,6 +358,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
'aria-expanded',
'false'
);
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
// ingenting at male henover den med.
const chevron = await sidebar
.locator('.folder-head .chevron-button')
.evaluate((el) => ({
background: getComputedStyle(el).backgroundColor,
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
}));
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
// Colour, which is chosen while renaming rather than from the row. Both are
@@ -979,6 +993,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: '[email protected]',
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
@@ -1609,6 +1828,14 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
const inner = await sidebar
.locator('.scroller')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(inner, 'rulleboksen kan scrolles vandret').toBeLessThanOrEqual(1);
// And the whole page, for the same reason at a different scale.
const page_sideways = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
@@ -1622,6 +1849,58 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Mærket og de faste visninger bliver stående, når resten ruller.
*
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
*
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
*/
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.setViewportSize({ width: 1000, height: 420 });
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
const scroller = sidebar.locator('.scroller');
await expect(scroller).toBeVisible();
// Rullebjælken begynder under de faste visninger og ikke bag dem.
const geometry = await sidebar.evaluate((el) => {
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
const scroll = el.querySelector('.scroller');
return {
views: box('.views'),
scroller: box('.scroller'),
overflows: scroll.scrollHeight - scroll.clientHeight
};
});
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
0
);
expect(
geometry.scroller.top,
'rulleboksen begynder oppe i de faste visninger'
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
// Og hovedet bliver, hvor det er, hele vejen ned.
const brand = sidebar.locator('.brand');
const before = await brand.boundingBox();
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect
.poll(async () => scroller.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
const after = await brand.boundingBox();
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
expect(trouble).toEqual([]);
});
/**
* A project can have more than one section.
*
@@ -1694,6 +1973,76 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Sektioner kan trækkes op og ned.
*
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
* område på vejen.
*
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
*/
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', 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('Havehus');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await field.fill(name);
await field.press('Enter');
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
}
const order = () =>
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
// under, så positionen er skrevet ud og ikke overladt til midten.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
targetPosition: { x: 40, y: 3 }
});
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
await page.reload();
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
'Tag',
'Fundament',
'Rejsning'
]);
// Og den anden vej: sluppet i den nederste halvdel lander den under.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
targetPosition: { x: 40, y: 28 }
});
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
expect(trouble).toEqual([]);
});
/**
* Form fields on a touch device are at least 16px.
*
@@ -2499,3 +2848,193 @@ test('et billede kan indsættes og trækkes ind i en note', async ({ page }) =>
expect(trouble).toEqual([]);
});
test('en note kan oprettes inde fra et projekt og hører til det med det samme', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Tagprojekt');
await sidebar.getByLabel('Projektnavn').press('Enter');
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
await expect(page.getByRole('heading', { name: 'Tagprojekt' })).toBeVisible();
// Ruden findes, også før der er en eneste note — ellers er der ingen knap at
// trykke på det sted, hvor man opdager at der skal skrives noget ned.
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed).toBeVisible();
await ed.click();
await page.keyboard.type('Tagsten og lægter');
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
// nogen har valgt det bagefter.
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
// Og den står på projektets egen side.
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
// Titlen, ikke uddraget: begge indeholder de samme ord, fordi titlen *er*
// notens første linje.
await expect(page.locator('.notes strong', { hasText: 'Tagsten og lægter' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await page.goto('/');
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
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: '# Løs note\n\nSkal flyttes ved at trække.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
await page.evaluate((noteId) => (window.__noteId = noteId), id);
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
await expect(row).toBeVisible();
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
await page.evaluate(() => {
const dt = new DataTransfer();
dt.setData('application/x-verdande-note', window.__noteId);
const target = [...document.querySelectorAll('nav a')].find((a) =>
a.textContent.includes('Trækmål')
);
for (const type of ['dragover', 'drop']) {
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
}
});
await page.waitForTimeout(1000);
// Det, der tæller: noten ligger i projektet bagefter.
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();
}, id);
expect(body.project_id).toBeTruthy();
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
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: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const copy = ed.locator('pre .copy');
await expect(copy).toBeVisible();
await copy.click();
await expect(copy).toHaveText('Kopieret');
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toBe('docker pull verdande');
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
// fælde, den her prøve findes for.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
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('docker pull verdande');
expect(body).not.toContain('Kopiér');
expect(body).not.toContain('Kopieret');
expect(trouble).toEqual([]);
});
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
await page.evaluate(async (t) => {
await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
});
}, title);
}
await page.reload();
const rows = page.locator('.notes button.row');
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
// hører med i markeringen — ellers ville det andet klik markere én og glemme
// den, man stod på.
await rows.filter({ hasText: 'Alfa' }).click();
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
await rows.filter({ hasText: 'Alfa' }).hover();
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
expect(trouble).toEqual([]);
});
+23 -1
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);
@@ -196,7 +203,7 @@ export const api = {
changePassword: (current_password, new_password) =>
post('/auth/password/change', { current_password, new_password }),
totpSetup: () => post('/auth/totp/setup'),
totpSetup: (password) => post('/auth/totp/setup', { password }),
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
@@ -276,6 +283,10 @@ export const api = {
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
deleteSection: (id) => del(`/sections/${id}`),
// The whole list, the same as projects and groups: a project has a handful of
// sections, and an order that cannot land half-applied is worth more than the
// bytes a single move would save.
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
@@ -338,6 +349,7 @@ export const api = {
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
noteBulk: (body) => post('/notes/bulk', body),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
@@ -365,6 +377,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 = {}) => {
+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);
}
+118 -2
View File
@@ -121,12 +121,71 @@
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
* man læser den.
*/
/**
* En kopiér-knap på hver kodeblok.
*
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
* den findes for øjet og musen, ikke for dokumentet.
*
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
*/
function addCopyButtons() {
if (!editor) return;
for (const pre of editor.querySelectorAll('pre')) {
if (pre.querySelector('.copy')) continue;
const btn = document.createElement('button');
btn.className = 'copy';
btn.type = 'button';
btn.contentEditable = 'false';
btn.textContent = t('notes.copyCode');
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeTextOf(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
} catch {
app.toast(t('notes.copyFailed'));
}
});
pre.appendChild(btn);
}
}
/**
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
*
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
*/
function codeTextOf(pre) {
const from = pre.querySelector('code') ?? pre;
return [...from.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
}
function colourCode() {
if (!editor) return;
const inside = currentBlock();
for (const pre of editor.querySelectorAll('pre')) {
if (pre === inside || pre.contains(inside)) continue;
const code = pre.textContent ?? '';
// Blokkens tekst uden det, vi selv har lagt oven på den.
//
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
// spiste den kode, den var sat der for at kopiere.
const code = codeTextOf(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -135,6 +194,7 @@
const target = pre.querySelector('code') ?? pre;
if (target.innerHTML !== painted) target.innerHTML = painted;
}
addCopyButtons();
}
function changed() {
@@ -814,8 +874,30 @@
*
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
* blive i den. */
max-width: 46rem;
/* Bredden vokser med ruden, men ikke i det uendelige.
*
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
* bredde, blev klemt sammen uden grund.
*
* clamp frem for et tal: en smal rude får det hele, en bred får en
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
* der afgør det.
*
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
* håndflade tom rude til højre for sig og svævede midt i arket. Med
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
*
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
* polstringen. Uden det ville det her give en vandret rullebjælke. */
width: 100%;
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
}
.hidden {
@@ -915,6 +997,40 @@
color: var(--ink-muted);
}
/* Kopiér-knappen, øverst til højre i blokken.
*
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
* opmærksomhed fra koden.
*
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
.page :global(pre) {
position: relative;
}
.page :global(pre .copy) {
position: absolute;
z-index: 1;
top: 6px;
right: 6px;
font-family: var(--font);
font-size: var(--text-xs);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.55);
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.page :global(pre:hover .copy),
.page :global(pre .copy:hover),
.page :global(pre .copy:focus-visible) {
background: rgb(255 255 255 / 0.2);
color: #fff;
}
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
+7
View File
@@ -221,6 +221,13 @@
gap: var(--s3);
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
/* Luft under stregen.
*
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
* sad den første sektion klods op ad den — så den læste som en kant om
* sektionen frem for som en afslutning på feltet over. En streg skal have
* plads på begge sider for at kunne ses som en streg. */
margin-bottom: var(--s4);
}
.plus {
+382 -286
View File
@@ -145,7 +145,16 @@
// 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' }
{ 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)
);
@@ -354,6 +363,15 @@
overProject = project.id;
}
/**
* Et slip på et projekt, uanset hvad der bliver sluppet.
*
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
* virke, helt indtil man slap.
*/
async function onTaskDrop(event, project) {
event.preventDefault();
overProject = null;
@@ -362,9 +380,12 @@
// 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);
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
if (noteIDs.length) {
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
return;
}
@@ -488,6 +509,26 @@
/>
<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}
@@ -540,14 +581,17 @@
ondragstart={(e) => sortable && onDragStart(e, project)}
ondragend={clearDrag}
ondragover={(e) => {
if (carries(e, TASK)) onTaskDragOver(e, project);
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
else if (sortable) onDragOver(e, project);
}}
ondragleave={() => {
overId = null;
overProject = null;
}}
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
ondrop={(e) =>
carries(e, TASK) || carries(e, NOTE)
? onTaskDrop(e, project)
: sortable && onDrop(e, project)}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{projectName(project)}
@@ -595,301 +639,313 @@
</h2>
{/snippet}
<div class="group">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
accept(e);
overGroup = '';
overId = null;
}}
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
onclick={() => {
addingGroup = !addingGroup;
adding = false;
newGroupName = '';
}}
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
<!-- Alt herunder ruller; mærket og de faste visninger gør ikke.
Hele sidebjælken rullede før, så runen og I dag forsvandt op over kanten,
så snart der var projekter nok — og de er dem, man er på vej hen til
oftest. En rulleboks indeni frem for `position: sticky` udenom, fordi et
klæbende hoved efterlader rullebjælken i fuld højde ved siden af sig: den
skal begynde under hovedet, ikke bag det.
{#if adding}
<form onsubmit={createProject}>
<input
bind:value={newName}
use:focusOnMount
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{/if}
{#if addingGroup}
<form onsubmit={createGroup}>
<input
bind:value={newGroupName}
use:focusOnMount
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#if !app.sectionCollapsed('projects')}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if own.length === 0 && !adding}
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
{#each groups as group (group.id)}
{@const inside = inGroup(group.id)}
<div class="group folder">
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
the pointer from selecting text in the field it contains. -->
Snippet-erklæringerne bliver liggende udenfor. De tegner ikke noget selv,
men de kaldes oppe fra .views, og en snippet erklæret herinde hører kun
til herinde. -->
<div class="scroller">
<div class="group">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head folder-head"
class:over={overGroup === group.id}
class:drop-above={overId === group.id && !overBelow}
class:drop-below={overId === group.id && overBelow}
class:dragging={draggingGroup === group.id}
draggable={renamingGroup !== group.id}
ondragstart={(e) => onGroupDragStart(e, group)}
ondragend={clearDrag}
ondragover={(e) => onGroupDragOver(e, group)}
ondragleave={() => {
overGroup = null;
class="group-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
accept(e);
overGroup = '';
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
The dot used to be a button in the row, which made the heading
carry four controls — dot, name, Omdøb, Slet — on a line narrow
enough that they crowded the name. Both are edits to the same
thing, so both belong in the same moment: you open the group to
change it, and change what you came to change.
`focusout` rather than `blur` closes it, and only when focus has
left the form altogether — clicking a swatch is still inside it.
The swatches also refuse focus on mousedown, because Safari and
Firefox do not focus a button on click, so `relatedTarget` would
be null and the form would shut under the pointer. -->
<form
class="renaming"
onsubmit={(e) => renameGroup(e, group)}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
}}
>
<input
bind:value={groupName}
use:focusOnMount
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(group.color ?? 'graphite') === color.id}
onmousedown={(e) => e.preventDefault()}
onclick={() => app.setGroupColor(group.id, color.id)}
></button>
{/each}
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
></span>
{group.name}
</a>
</h2>
<span class="count">{inside.length}</span>
{/if}
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
onclick={() => {
addingGroup = !addingGroup;
adding = false;
newGroupName = '';
}}
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
{#if !group.collapsed}
{#each inside as project (project.id)}
{#if adding}
<form onsubmit={createProject}>
<input
bind:value={newName}
use:focusOnMount
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{/if}
{#if addingGroup}
<form onsubmit={createGroup}>
<input
bind:value={newGroupName}
use:focusOnMount
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#if !app.sectionCollapsed('projects')}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">{t('sidebar.emptyGroup')}</p>
{#if own.length === 0 && !adding}
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
{/each}
{#if shared.length}
<div class="group">
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
{/if}
</div>
{/if}
{#each groups as group (group.id)}
{@const inside = inGroup(group.id)}
<div class="group folder">
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
the pointer from selecting text in the field it contains. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head folder-head"
class:over={overGroup === group.id}
class:drop-above={overId === group.id && !overBelow}
class:drop-below={overId === group.id && overBelow}
class:dragging={draggingGroup === group.id}
draggable={renamingGroup !== group.id}
ondragstart={(e) => onGroupDragStart(e, group)}
ondragend={clearDrag}
ondragover={(e) => onGroupDragOver(e, group)}
ondragleave={() => {
overGroup = null;
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
The dot used to be a button in the row, which made the heading
carry four controls — dot, name, Omdøb, Slet — on a line narrow
enough that they crowded the name. Both are edits to the same
thing, so both belong in the same moment: you open the group to
change it, and change what you came to change.
{#if filters.length}
<div class="group">
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
{/if}
</div>
{/if}
`focusout` rather than `blur` closes it, and only when focus has
left the form altogether — clicking a swatch is still inside it.
The swatches also refuse focus on mousedown, because Safari and
Firefox do not focus a button on click, so `relatedTarget` would
be null and the form would shut under the pointer. -->
<form
class="renaming"
onsubmit={(e) => renameGroup(e, group)}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
}}
>
<input
bind:value={groupName}
use:focusOnMount
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(group.color ?? 'graphite') === color.id}
onmousedown={(e) => e.preventDefault()}
onclick={() => app.setGroupColor(group.id, color.id)}
></button>
{/each}
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
></span>
{group.name}
</a>
</h2>
<span class="count">{inside.length}</span>
{/if}
</div>
{#if labels.length}
<div class="group">
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
{#if !group.collapsed}
{#each inside as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">{t('sidebar.emptyGroup')}</p>
{/if}
{/if}
</div>
{/each}
<div class="foot">
<a
href="/indstillinger"
class="settings"
class:active={current.startsWith('/indstillinger')}
onclick={onnavigate}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{t('nav.settings')}
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{#if shared.length}
<div class="group">
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
{/if}
</div>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
{#if filters.length}
<div class="group">
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
{/if}
</div>
{/if}
{#if labels.length}
<div class="group">
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
<div class="foot">
<a
href="/indstillinger"
class="settings"
class:active={current.startsWith('/indstillinger')}
onclick={onnavigate}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{t('nav.settings')}
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
</div>
</div>
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
A drag handle with no keyboard equivalent is a setting some people simply
@@ -926,11 +982,31 @@
padding: var(--s4) var(--s3);
background: var(--surface-sunken);
border-right: 1px solid var(--line);
overflow-y: auto;
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
ingenting, fordi den stadig er den yderste kant: løber en række over,
skal den klippes her og ikke skubbe kolonnen bredere. */
overflow: hidden;
padding-left: max(var(--s3), env(safe-area-inset-left));
position: relative;
}
/* `min-height: 0`, ellers nægter et flex-element at blive lavere end sit
indhold, og boksen ville vokse ned gennem bunden af skærmen i stedet for at
rulle — samme fælde som `min-width: 0` på kontonavnet længere nede.
Ikke `overflow-x: hidden` her: `overflow-y: auto` gør den anden akse til
`auto` af sig selv, og en vandret rullebjælke under menuen er en fejl i en
række, ikke i boksen. At klippe den ville skjule fejlen; prøven måler begge
bokse. */
.scroller {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
the border; this only widens what the pointer hits.
@@ -1169,6 +1245,11 @@
The dot is the resting state; the chevron takes over on hover or focus, and
stays put while the group is folded, so a folded group still shows the way
back open. */
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
skifte, uden noget at male henover den med. */
.chevron-button {
position: absolute;
left: calc(var(--s2) - 5px);
@@ -1179,7 +1260,13 @@
border-radius: var(--radius-sm);
color: inherit;
opacity: 0;
background: var(--surface);
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
under 1 laver en stakkontekst og maler elementet, som var det placeret
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
z-index: 1;
}
.folder-head:hover .chevron-button,
@@ -1188,9 +1275,18 @@
opacity: 1;
}
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
pilen kom frem. */
.folder-head:hover .group-dot,
.folder-head:has(.chevron-button:focus-visible) .group-dot,
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
opacity: 0;
}
.chevron-button:hover {
color: var(--ink);
background: var(--surface);
}
.fold a,
+1
View File
@@ -18,6 +18,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';
export const SECTION = 'application/x-verdande-section';
/** Starts a drag carrying one id of the given kind. */
export function startDrag(event, type, id) {
+59 -1
View File
@@ -44,6 +44,21 @@ export const da = {
'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.newHere': 'Ny note',
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
'notes.clearSearch': 'Ryd søgningen',
'notes.found': '{count} noter',
'notes.picked': '{count} markeret',
'notes.archive': 'Arkivér',
'notes.unarchive': 'Tag frem igen',
'notes.clearPick': 'Ryd markering',
'notes.showArchive': 'Vis arkivet',
'notes.deleteMany': 'Slet {count} noter?',
'notes.archiveOne': 'Arkivér noten',
'notes.emptyArchive': 'Arkivet er tomt.',
'notes.copyCode': 'Kopiér',
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
@@ -80,6 +95,7 @@ export const da = {
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
'nav.inbox': 'Indbakke',
'nav.calendar': 'Kalender',
'nav.settings': 'Indstillinger',
'nav.signOut': 'Log ud',
'nav.main': 'Hovedmenu',
@@ -593,7 +609,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',
@@ -633,6 +649,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.',
@@ -647,6 +689,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',
@@ -789,6 +843,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}',
+57 -1
View File
@@ -40,6 +40,21 @@ export const en = {
'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.newHere': 'New note',
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
'notes.clearSearch': 'Clear the search',
'notes.found': '{count} notes',
'notes.picked': '{count} selected',
'notes.archive': 'Archive',
'notes.unarchive': 'Bring back',
'notes.clearPick': 'Clear selection',
'notes.showArchive': 'Show the archive',
'notes.deleteMany': 'Delete {count} notes?',
'notes.archiveOne': 'Archive this note',
'notes.emptyArchive': 'The archive is empty.',
'notes.copyCode': 'Copy',
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.source': 'Note source',
@@ -76,6 +91,7 @@ export const en = {
'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',
@@ -589,7 +605,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',
@@ -629,6 +645,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.',
@@ -642,6 +683,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',
@@ -784,6 +836,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}',
+9 -1
View File
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
emit('```');
+37
View File
@@ -835,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.
*
-1
View File
@@ -437,7 +437,6 @@
padding-left: max(var(--s3), env(safe-area-inset-left));
padding-right: var(--s3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
}
</style>
+27 -6
View File
@@ -143,6 +143,7 @@
let recoveryCodes = $state([]);
let remaining = $state(null);
let disablePassword = $state('');
let enablePassword = $state('');
$effect(() => {
if (app.user?.totp_enabled) {
@@ -150,13 +151,21 @@
}
});
async function beginTOTP() {
async function beginTOTP(event) {
event?.preventDefault?.();
totpErrors = {};
try {
const r = await api.totpSetup();
// Adgangskoden, ligesom når man slår den fra. At slå den til er mindst
// lige så indgribende som at slå den fra — det afgør, hvilken enhed der
// fra nu af må sige ja — og en dør, der åbnes lettere end den lukkes, er
// ikke en dør.
const r = await api.totpSetup(enablePassword);
enablePassword = '';
totpSecret = r.secret;
totpURI = r.uri;
} catch (e) {
app.toast(humanMessage(e));
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
}
}
@@ -475,9 +484,21 @@
</div>
</form>
{:else}
<div class="row">
<button class="primary" onclick={beginTOTP}>{t('account.totpOn')}</button>
</div>
<form onsubmit={beginTOTP}>
<div class="field">
<label for="enable-pw">{t('account.password')}</label>
<input
id="enable-pw"
type="password"
autocomplete="current-password"
bind:value={enablePassword}
/>
{#if totpErrors.password}<p class="error">{totpErrors.password}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit">{t('account.totpOn')}</button>
</div>
</form>
{/if}
</section>
@@ -1,9 +1,9 @@
<script>
/** Gmail, the calendar feed, and the mail-to-task address. */
/** Gmail, Google Calendar, the calendar feed, and the mail-to-task address. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { t, plural } from '$lib/i18n.svelte.js';
import { t, plural, tag } from '$lib/i18n.svelte.js';
// --- Gmail ------------------------------------------------------------------
@@ -136,6 +136,120 @@
}
}
// --- Google Calendar -----------------------------------------------------------
// Its own panel rather than a second mode inside the Gmail one. They sign in
// through the same registration and are otherwise nothing alike: one turns mail
// into work you own, the other shows a copy of somebody else's calendar and
// never writes to it.
let calendar = $state(null);
let savingCalendars = $state(false);
let calendarsSaved = $state(false);
let syncingCalendar = $state(false);
/** The ids ticked in the form, which is not what is stored until Save. */
let shownCalendars = $state([]);
const CALENDAR_CALLBACK = {
connected: { tone: 'ok', text: t('int.calendarConnected') },
state: { tone: 'bad', text: t('int.calendarState') },
expired: { tone: 'bad', text: t('int.calendarExpired') },
invalid: { tone: 'bad', text: t('int.calendarInvalid') },
failed: { tone: 'bad', text: t('int.calendarFailed') },
norefresh: { tone: 'bad', text: t('int.calendarNoRefresh') },
access_denied: { tone: 'bad', text: t('int.calendarDenied') },
// The one worth naming. An OAuth app registered as Internal accepts only
// accounts inside the organisation, and Google's own screen does not say
// that in words anybody could act on.
org_internal: { tone: 'bad', text: t('int.calendarOrgInternal') }
};
let calendarCallback = $derived.by(() => {
const value = $page.url.searchParams.get('calendar');
if (!value) return null;
return CALENDAR_CALLBACK[value] ?? { tone: 'bad', text: t('int.calendarOther', { what: value }) };
});
$effect(() => {
loadCalendar();
});
async function loadCalendar() {
try {
calendar = await api.getCalendar();
shownCalendars = calendar.calendars.filter((c) => c.shown).map((c) => c.id);
} catch (e) {
app.toast(humanMessage(e));
}
}
async function connectCalendar() {
try {
const { url } = await api.authorizeCalendar();
location.href = url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function saveCalendars(event) {
event.preventDefault();
savingCalendars = true;
calendarsSaved = false;
try {
await api.setCalendars(shownCalendars);
calendarsSaved = true;
// And fetch, without being asked again.
//
// Ticking a calendar is somebody saying "show me this one". Saving the
// choice and leaving the grid empty until the sweep comes round a quarter
// of an hour later is a calendar that says a fortnight is clear — and the
// person is on this page, watching, with the button under their hand.
// Also what refreshes the panel: syncCalendarNow reloads it when it is done.
if (shownCalendars.length) {
await syncCalendarNow();
} else {
await loadCalendar();
}
} catch (e) {
app.toast(humanMessage(e));
} finally {
savingCalendars = false;
}
}
async function syncCalendarNow() {
syncingCalendar = true;
try {
const result = await api.syncCalendar();
app.toast(
result?.events
? plural(result.events, 'int.calendarFetchedOne', 'int.calendarFetchedMany')
: t('int.calendarNothingNew')
);
await loadCalendar();
} catch (e) {
app.toast(humanMessage(e));
} finally {
syncingCalendar = false;
}
}
async function disconnectCalendar() {
if (!confirm(t('int.disconnectCalendarQuestion'))) return;
try {
await api.disconnectCalendar();
await loadCalendar();
} catch (e) {
app.toast(humanMessage(e));
}
}
function toggleCalendar(id) {
shownCalendars = shownCalendars.includes(id)
? shownCalendars.filter((c) => c !== id)
: [...shownCalendars, id];
}
// --- calendar feed -------------------------------------------------------------
let feedURL = $state('');
@@ -355,6 +469,87 @@
{/if}
</section>
<section class="panel">
<header>
<h2>{t('int.calendar')}</h2>
<p class="hint">{t('int.calendarHint')}</p>
</header>
{#if calendarCallback}
<p class="callback" data-tone={calendarCallback.tone}>{calendarCallback.text}</p>
{/if}
{#if calendar === null}
<p class="empty">…</p>
{:else if !calendar.connected}
<div class="row">
<button class="primary" onclick={connectCalendar} disabled={!calendar.has_client}>
{t('int.connectCalendar')}
</button>
</div>
<!-- The registration is the Gmail panel's — one client, two features — so
this only spells out the second redirect URI, which has to be added to
it. It is the single most likely thing to be wrong, and the error Google
gives for it names neither value. -->
{#if app.user?.is_admin}
<div class="field">
<label for="calendar-redirect">{t('int.redirectURI')}</label>
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
<p class="hint">{t('int.calendarSameClient')}</p>
</div>
{:else if !calendar.has_client}
<p class="hint">{t('int.noClient')}</p>
{/if}
{:else}
<p class="hint">
{t('int.calendarConnectedAs')}
<strong>{calendar.account || t('int.unknownAccount')}</strong>.
</p>
<form onsubmit={saveCalendars}>
{#if calendar.calendars.length}
<fieldset>
<legend>{t('int.whichCalendars')}</legend>
{#each calendar.calendars as c (c.id)}
<label class="pick">
<input
type="checkbox"
checked={shownCalendars.includes(c.id)}
onchange={() => toggleCalendar(c.id)}
/>
<!-- Google's own colour for the calendar, which is what the grid
draws its events in. Told apart by the colour the person
already knows it by, not by one of verdande's ten names. -->
<span class="swatch" style:background={c.colour || 'var(--line-strong)'}></span>
{c.name}
</label>
{/each}
</fieldset>
{:else}
<p class="hint">{t('int.noCalendars')}</p>
{/if}
<p class="hint">
{calendar.last_sync_at
? t('int.calendarLastSync', {
when: new Date(calendar.last_sync_at).toLocaleString(tag())
})
: t('int.calendarNeverSynced')}
</p>
<div class="row">
<button class="primary" type="submit" disabled={savingCalendars}>{t('int.save')}</button>
{#if calendarsSaved}<span class="saved">{t('int.saved')}</span>{/if}
<button class="secondary" onclick={syncCalendarNow} disabled={syncingCalendar}>
{syncingCalendar ? t('int.fetching') : t('int.fetchNow')}
</button>
<button class="danger" onclick={disconnectCalendar}>{t('int.disconnect')}</button>
</div>
</form>
{/if}
</section>
<section class="panel">
<header>
<h2>{t('int.feed')}</h2>
@@ -529,6 +724,38 @@
border-color: var(--danger);
}
fieldset {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--s3);
display: flex;
flex-direction: column;
gap: var(--s2);
}
legend {
font-size: var(--text-sm);
color: var(--ink-muted);
padding: 0 var(--s1);
}
.pick {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
}
/* A solid mark beside a label that carries the meaning, so it only has to be
tellable from its neighbours and visible on both near-black and white — the
same job the project colours' dots do. */
.swatch {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex: none;
}
a {
color: var(--accent);
}
+195
View File
@@ -0,0 +1,195 @@
<script>
/**
* Kalender: Google's events and verdande's own dated tasks in one grid.
*
* The same component Kommende uses, given a second list to draw. Not a second
* grid, and the reason is the part that is easy to get subtly wrong: a drop
* target has to decide from `dataTransfer.types` alone whether it accepts a
* drag, and a second copy of that is a second place for it to be almost right.
* A task dragged here reschedules exactly as it does everywhere else, because
* it is the same code doing it.
*
* A month and a week, and no list. The list is Kommende's answer — seven days
* of work, in order — and this view is the other question: what does the day
* already have in it.
*/
import { api, humanMessage } from '$lib/api.js';
import { app, calendarView } from '$lib/stores.svelte.js';
import CalendarView from '$lib/components/CalendarView.svelte';
import { t } from '$lib/i18n.svelte.js';
let events = $state([]);
/** Null until the first answer, so nothing flashes before it is known. */
let window_ = $state(null);
let connected = $state(null);
/** Whether any calendar in the connected account is ticked. */
let anyShown = $state(false);
/** The dates the grid is showing, so the notice below can compare against them. */
let showing = $state(null);
$effect(() => {
api
.getCalendar()
.then((status) => {
connected = status.connected;
anyShown = (status.calendars ?? []).some((c) => c.shown);
})
.catch(() => {
// A calendar that could not be asked about is not a reason to withhold
// the grid: the tasks in it are verdande's own and do not depend on
// Google being reachable.
connected = false;
});
});
/**
* Loads the dates a grid is showing, whichever grid it is.
*
* Both halves at once, because they answer the same question about the same
* cells. The limit on the tasks is raised well above the default for the reason
* Kommende raises it: a busy month across every project is more than two hundred
* rows, and a silently truncated grid is a calendar that quietly lies about a
* day being clear.
*/
function loadRange({ from, to }) {
showing = { from, to };
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
api
.calendarEvents(from, to)
.then((data) => {
events = data.events;
window_ = { from: data.from, to: data.to };
})
.catch((e) => {
// The grid still draws, with the tasks in it. Saying so once beats a
// view that renders half of itself and explains nothing.
events = [];
app.toast(humanMessage(e));
});
}
// Whether the grid has been paged past the window verdande keeps a copy of.
//
// Said out loud rather than left as an empty month. A calendar showing no events
// looks exactly like a calendar with no events in it, and the difference between
// "nothing is booked" and "I have not looked that far" is the whole reason
// somebody opened this.
let beyond = $derived(
connected &&
window_ &&
showing &&
(showing.from < window_.from || showing.to > window_.to)
);
</script>
<div class="view">
<header>
<h1>{t('cal.title')}</h1>
<div class="views" role="group" aria-label={t('view.mode')}>
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
<button
class:active={calendarView.mode === value}
onclick={() => calendarView.set(value)}
aria-pressed={calendarView.mode === value}>{label}</button
>
{/each}
</div>
</header>
{#if connected === false}
<!-- Shown once, and quietly. The grid underneath is already useful without a
Google account in it — it is verdande's own calendar, which is what this
view was before anything was laid over it. -->
<p class="hint">
{t('cal.notConnected')}
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
</p>
{/if}
<!-- Connected, and nothing ticked. Worth its own sentence rather than an empty
grid: "no calendar is chosen" and "nothing is booked" look identical, and
only one of them is something to do about. -->
{#if connected && !anyShown}
<p class="hint">
{t('cal.noneChosen')}
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
</p>
{/if}
{#if beyond}
<p class="hint">{t('cal.beyondWindow', { from: window_.from, to: window_.to })}</p>
{/if}
{#if calendarView.mode === 'week'}
<CalendarView span="week" {events} onrange={loadRange} />
{:else}
<CalendarView {events} onrange={loadRange} />
{/if}
</div>
<style>
/* A month grid needs the width a reading column deliberately withholds — the
same allowance Kommende and the project page make for theirs. */
.view {
max-width: 1400px;
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
/* Wraps, for the same reason the project header does: a title and a switcher
are more than a phone's width, and the switcher belongs underneath rather
than squeezing the heading. */
header {
display: flex;
align-items: center;
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
flex: 1 1 8ch;
min-width: 8ch;
}
.views {
display: flex;
gap: 1px;
flex: none;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.views button {
padding: var(--s1) var(--s3);
font-size: var(--text-sm);
background: var(--ground);
color: var(--ink-muted);
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.views button:hover {
color: var(--ink);
}
.views button.active {
background: var(--surface-raised);
color: var(--ink);
font-weight: 500;
}
.hint {
margin: 0 0 var(--s4);
font-size: var(--text-sm);
color: var(--ink-muted);
}
.hint a {
color: var(--accent);
}
</style>
+438 -37
View File
@@ -53,7 +53,16 @@
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
let ordered = $derived(
let ordered = $derived.by(() => {
// En søgning har allerede en rækkefølge, og den er bedre end nogen af de
// tre herunder: serveren har sorteret efter hvor godt hver note matchede.
// Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en
// søgning i tolv hundrede noter brugbar.
if (query.trim()) return notes;
return sortedByChoice();
});
const sortedByChoice = () =>
[...notes].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (order === 'title') {
@@ -61,10 +70,106 @@
}
const field = order === 'created' ? 'created_at' : 'updated_at';
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
})
);
});
let selected = $state(null);
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
let selectedId = $state(null);
/**
* Flere ad gangen.
*
* Et sæt frem for et flag på noten, fordi markeringen hører til skærmen og ikke
* til noten: den forsvinder, når man går et andet sted hen, og den skal ikke
* gemmes nogen steder.
*
* `anchor` er den, et skift-klik måler fra. Uden den ville et interval skulle
* gættes ud fra "den valgte", og den flytter sig, hver gang man klikker.
*/
let picked = $state(new Set());
let anchor = $state(null);
let showArchive = $state(false);
function rowClick(event, note) {
const many = event.metaKey || event.ctrlKey;
const range = event.shiftKey;
if (!many && !range) {
picked = new Set();
anchor = note.id;
open(note);
return;
}
const next = new Set(picked);
if (range && anchor) {
// Fra ankeret til her, i den rækkefølge listen faktisk står i — ikke i
// notens egen, som er en anden, når der er sorteret eller søgt.
const ids = ordered.map((n) => n.id);
const from = ids.indexOf(anchor);
const to = ids.indexOf(note.id);
if (from !== -1 && to !== -1) {
for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
}
} else {
// Den, der lige er åbnet, hører med i markeringen: ellers ville et
// ⌘-klik nummer to markere to noter og glemme den, man stod på.
if (!next.size && selectedId) next.add(selectedId);
next.has(note.id) ? next.delete(note.id) : next.add(note.id);
anchor = note.id;
}
picked = next;
}
/** Arkivér, tag frem igen, eller slet — for det, der er markeret. */
async function bulk(change) {
const ids = [...picked];
if (!ids.length) return;
if (change.delete && !confirm(t('notes.deleteMany', { count: ids.length }))) return;
try {
await api.noteBulk({ ids, ...change });
picked = new Set();
if (ids.includes(selectedId)) open(null);
await load(query);
} catch (e) {
app.toast(humanMessage(e));
}
}
/** Én note lagt væk, uden at markere den først. */
async function archiveOne(note) {
try {
await api.noteBulk({ ids: [note.id], archived: !note.archived_at });
if (selectedId === note.id) open(null);
await load(query);
} catch (e) {
app.toast(humanMessage(e));
}
}
let query = $state('');
/**
* Det, der står i feltet, og det, der er søgt efter.
*
* To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert
* tastetryk,
* så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi
* det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger
* gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom
* tilbage — og svarene kunne lande i den forkerte rækkefølge.
*/
let draftQuery = $state('');
let typing = $state(false);
let searchTimer;
function search(next) {
draftQuery = next;
typing = next.trim() !== '' && next !== query;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
query = draftQuery;
typing = false;
}, 250);
}
let status = $state('loading');
let saving = $state(false);
@@ -74,6 +179,7 @@
let timer;
$effect(() => {
showArchive;
load(query);
});
@@ -81,7 +187,7 @@
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
$effect(() => {
if (!asked || selected?.id === asked) return;
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
@@ -89,19 +195,60 @@
async function load(q) {
try {
notes = (await api.notes(q ? { q } : {})).notes ?? [];
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).notes ?? [];
status = 'ready';
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
status = 'failed';
app.toast(humanMessage(e));
}
}
function open(note) {
/**
* Åbner en note — og henter den hel først, hvis listen kun har uddraget.
*
* Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter
* ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville
* gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag
* af ingenting andet end at være blevet kigget på.
*
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
* at vente; teksten kommer, når den er der.
*/
async function open(note) {
clearTimeout(timer);
selected = note;
draft = note?.body ?? '';
if (!note) {
selectedId = null;
selected = null;
draft = '';
return;
}
// Rækken fremhæves med det samme; editoren får først noten, når den er hel.
//
// De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id*
// skifter — og hvis den fik uddraget først og hele noten bagefter, ville
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
// sidde med en tom note, der lige havde haft en titel.
selectedId = note.id;
if (note.body) {
selected = note;
draft = note.body;
return;
}
selected = null;
try {
const full = await api.note(note.id);
// Kun hvis den stadig er den, der er valgt: skifter man note, mens
// hentningen er undervejs, må den forrige ikke lande oven i den nye.
if (selectedId !== note.id) return;
selected = full;
draft = full.body ?? '';
} catch (e) {
app.toast(humanMessage(e));
}
}
/**
@@ -151,6 +298,14 @@
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Hvilken dato rækken viser: den, der sorteres efter. */
const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
/** Begge datoer, til den, der holder musen stille. */
const stamp = (note) =>
`${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` +
`${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`;
function projectName(id) {
return app.projects.find((p) => p.id === id)?.name ?? '';
}
@@ -161,7 +316,11 @@
function onDragStart(event, note) {
dragging = note.id;
startDrag(event, NOTE, note.id);
// Trækker man en note, der er markeret, følger hele markeringen med. Trækker
// man en, der ikke er, er det den ene — ellers ville et træk i en tilfældig
// række flytte halvtreds noter, man havde glemt var markeret.
const ids = picked.has(note.id) ? [...picked] : [note.id];
startDrag(event, NOTE, ids.join(' '));
}
async function create() {
@@ -194,7 +353,10 @@
saving = true;
try {
const saved = await api.updateNote(id, { body });
notes = notes.map((n) => (n.id === id ? saved : n));
// Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers
// står linjen tom, indtil siden hentes igen.
const forList = { ...saved, preview: saved.body ?? '' };
notes = notes.map((n) => (n.id === id ? forList : n));
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
@@ -295,22 +457,63 @@
<option value={o.key}>{t(o.label)}</option>
{/each}
</select>
<button
class="archive-toggle"
class:on={showArchive}
onclick={() => { showArchive = !showArchive; picked = new Set(); }}
aria-pressed={showArchive}
aria-label={t('notes.showArchive')}
title={t('notes.showArchive')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
</div>
</div>
<input
class="search"
type="search"
bind:value={query}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
programmet, der ikke fulgte temaet, og det kunne ses. -->
<div class="searchbox" class:busy={typing}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M20 20l-3.5-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input
type="search"
value={draftQuery}
oninput={(e) => search(e.currentTarget.value)}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
{#if draftQuery}
<button class="clear" onclick={() => search('')} aria-label={t('notes.clearSearch')}>×</button>
{/if}
</div>
{#if picked.size}
<!-- Kun mens der er markeret noget. En bjælke, der altid står der, er en
bjælke, der tager plads fra listen hver dag for at gøre gavn sjældent. -->
<div class="picked-bar">
<span>{t('notes.picked', { count: picked.size })}</span>
<button onclick={() => bulk({ archived: !showArchive })}>
{showArchive ? t('notes.unarchive') : t('notes.archive')}
</button>
<button class="danger" onclick={() => bulk({ delete: true })}>{t('notes.delete')}</button>
<button onclick={() => (picked = new Set())}>{t('notes.clearPick')}</button>
</div>
{:else if query && status === 'ready'}
<p class="found">{t('notes.found', { count: notes.length })}</p>
{/if}
{#if status === 'loading'}
<p class="hint">{t('history.loading')}</p>
{:else if !notes.length}
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
<p class="hint">
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
</p>
{:else}
<ul>
{#each ordered as note (note.id)}
@@ -318,8 +521,9 @@
<div class="rowline">
<button
class="row"
class:on={selected?.id === note.id}
onclick={() => open(note)}
class:on={selectedId === note.id}
class:picked={picked.has(note.id)}
onclick={(e) => rowClick(e, note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
ondragend={() => (dragging = null)}
@@ -329,13 +533,36 @@
linjer pr. note frem for tre, og datoen er dét, man skanner
efter, når man leder efter noget, man skrev i tirsdags. -->
<span class="under">
<span class="when">{when(note.updated_at)}</span>
<span class="preview">{plain(note.body)}</span>
<!-- Datoen, der svarer til den rækkefølge, listen står i.
Den viste altid `updated_at`, og for tolv hundrede
importerede noter er det i aftes — så en note fra 2017
stod som "i går", og den dato, man sorterede efter, var
ingen steder at se. -->
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
<span class="preview">{plain(note.preview ?? note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</button>
<!-- De to knapper står oven på hinanden i en smal søjle.
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
arkivet er det vejen tilbage. -->
<button
class="archive-one"
onclick={() => archiveOne(note)}
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button
class="star"
class:on={note.pinned}
@@ -346,6 +573,7 @@
>
{note.pinned ? '★' : '☆'}
</button>
</div>
</div>
</li>
{/each}
@@ -474,21 +702,164 @@
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
udgave, og den ser ens ud hele tiden. */
.search {
width: 100%;
appearance: none;
-webkit-appearance: none;
padding-left: 30px;
border-radius: 999px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 9px center;
background-size: 15px 15px;
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
farve skrevet ind: den gamle var #8b918d uanset tema. */
.searchbox {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-faint);
transition: border-color var(--fast) var(--ease);
}
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
.search::-webkit-search-decoration,
.search::-webkit-search-cancel-button {
.searchbox:focus-within {
border-color: var(--line-strong);
}
/* Mens det tastede endnu ikke er søgt efter. Et hint om at der kommer noget,
frem for en liste, der ser forkert ud i en kvart sekund. */
.searchbox.busy {
border-color: var(--accent);
}
.searchbox svg {
width: 15px;
height: 15px;
flex: none;
}
.searchbox input {
flex: 1;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
color: var(--ink);
font-size: var(--text-sm);
outline: none;
}
.clear {
flex: none;
width: 18px;
height: 18px;
border-radius: var(--radius-full);
color: var(--ink-faint);
font-size: 15px;
line-height: 1;
}
.clear:hover {
background: var(--surface-sunken);
color: var(--ink);
}
/* Bjælken over listen, mens der er markeret noget. Den erstatter antallet af
fundne frem for at lægge sig oven i det: kun ét af de to er relevant ad
gangen. */
.picked-bar {
display: flex;
align-items: center;
gap: var(--s2);
flex-wrap: wrap;
padding: var(--s2) var(--s1);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.picked-bar button {
font-size: var(--text-xs);
color: var(--ink);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.picked-bar button:hover {
background: var(--surface);
}
.picked-bar button.danger {
color: var(--danger);
border-color: var(--danger-sunken);
}
/* En markeret række.
*
* En kant og en tone, ikke en tone alene: `--accent-sunken` er meget lys i lyst
* tema, og tre markerede rækker var næsten ikke til at skelne fra tre, der ikke
* var — hvilket er en dårlig ting at være i tvivl om, lige inden man trykker
* Slet. Kanten kan ses uanset tema og uanset hvor lys tonen er. */
.row.picked {
background: var(--accent-sunken);
box-shadow: inset 3px 0 0 var(--accent);
}
/* Vises som stjernen: usynlig, indtil rækken er under musen, så listen er
rolig at skanne. */
.archive-one {
flex: none;
width: 22px;
height: 22px;
color: var(--ink-faint);
border-radius: var(--radius-sm);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.archive-one svg {
width: 14px;
height: 14px;
}
.rowline:hover .archive-one,
.archive-one:focus-visible {
opacity: 1;
}
.archive-one:hover {
color: var(--ink);
background: var(--surface);
}
.archive-toggle {
width: 24px;
height: 24px;
color: var(--ink-faint);
border-radius: var(--radius-sm);
}
.archive-toggle svg {
width: 15px;
height: 15px;
}
.archive-toggle:hover {
color: var(--ink);
background: var(--surface);
}
.archive-toggle.on {
color: var(--accent);
background: var(--accent-sunken);
}
.found {
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s1);
}
/* Safaris egen ryd-knap ville sidde oven i vores egen. */
.searchbox input::-webkit-search-decoration,
.searchbox input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
@@ -500,15 +871,45 @@
min-height: 0;
}
/* Rækker uden for skærmen koster ingenting.
*
* Listen tegner alle noterne. Med ti var det ligegyldigt; med tolv hundrede tog
* første tegning to sekunder, og browseren gik i knæ — for hver eneste række
* blev der lavet layout og malet, også de tusind, ingen kan se.
*
* `content-visibility: auto` lader browseren springe netop det over for det,
* der er uden for billedet, og tage det, når man ruller derhen. Ingen JavaScript,
* ingen rullematematik, ingen liste, der kan komme ud af trit med sig selv —
* hvilket er hele grunden til at prøve det her før en vinduesvisning.
*
* `contain-intrinsic-size` er gættet på, hvor høj en uset række er. Uden det
* ville rullebjælken tro, at listen er tom, og hoppe, mens man ruller. Tallet er
* målt på en rigtig række: titel plus én linje uddrag. */
li {
content-visibility: auto;
contain-intrinsic-size: auto 54px;
}
.rowline {
display: flex;
align-items: center;
gap: var(--s1);
}
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
hinanden er toogfyrre pixels taget fra en liste på tre hundrede — og de er
det, man rører en gang imellem, mens titlen er det, man læser hele tiden. */
.rowactions {
display: flex;
flex-direction: column;
gap: 1px;
flex: none;
}
.star {
flex: none;
width: 24px;
width: 22px;
height: 22px;
color: var(--ink-faint);
opacity: 0;
font-size: var(--text-sm);
+203 -10
View File
@@ -6,7 +6,7 @@
import { app, completedView, projectName } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
@@ -134,6 +134,27 @@
}
}
/**
* En ny note, der allerede hører til dette projekt.
*
* Noten oprettes med `project_id` sat, hvilket er præcis det, deling betyder
* her: en note i et projekt kan læses af alle, der kan læse projektet. Så det
* er én handling og ikke to — man skulle før oprette noten under Noter og
* bagefter finde projektet frem i "Del i".
*
* Tom, og så videre til den. Der er ikke noget at spørge om: titlen er notens
* første linje, så et navnefelt ville være et felt, der lover noget, programmet
* ikke holder.
*/
async function createNote() {
try {
const note = await api.createNote({ body: '', project_id: id });
goto(`/noter?note=${note.id}`);
} catch (e) {
app.toast(humanMessage(e));
}
}
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
let isOwner = $derived(project?.role === 'owner');
@@ -365,6 +386,80 @@
}
}
// --- moving a section ---------------------------------------------------------
//
// The order of the sections is the order of the plan — "Planlægning" stands
// before "I gang" because that is when the work happens — and until this it
// could only be changed by deleting a section and making it again, which drops
// its tasks into the unsectioned area on the way past.
//
// The whole heading is the handle, not a grip beside it. A section heading
// holds nothing that is itself dragged, so there is no gesture to steal — which
// is the reason the sidebar's rows needed one, and the reason these do not.
let draggingSection = $state(null);
/** The heading whose gap is lit up, and which side of it. */
let overSectionEdge = $state(null);
let overSectionBelow = $state(false);
function clearSectionDrag() {
draggingSection = null;
overSectionEdge = null;
}
function onSectionDragStart(event, section) {
draggingSection = section.id;
startDrag(event, SECTION, section.id);
}
function onHeadDragOver(event, section) {
if (!canEdit || !carries(event, SECTION) || draggingSection === section.id) return;
accept(event);
const box = event.currentTarget.getBoundingClientRect();
overSectionEdge = section.id;
overSectionBelow = event.clientY > box.top + box.height / 2;
}
/**
* Optimistic, and the whole list at once.
*
* The array is what the page renders from, so moving the entry moves the
* section; `sort_order` is set alongside it only so a later read of this list
* agrees with what is on screen. If the write fails the previous array goes
* back — a half-moved section is worse than one that did not move.
*/
async function onHeadDrop(event, target) {
if (!carries(event, SECTION)) return;
event.preventDefault();
event.stopPropagation();
const id = dragged(event, SECTION) || draggingSection;
const below = overSectionBelow;
clearSectionDrag();
if (!id || id === target.id || !canEdit) return;
const moved = sections.find((s) => s.id === id);
if (!moved) return;
const without = sections.filter((s) => s.id !== id);
const at = without.findIndex((s) => s.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, moved);
const previous = sections;
sections = ordered.map((s, i) => ({ ...s, sort_order: i }));
try {
await api.reorderSections(
project.id,
ordered.map((s) => s.id)
);
} catch (e) {
sections = previous;
app.toast(humanMessage(e));
}
}
async function removeSection(section) {
const count = open.filter((t) => t.section_id === section.id).length;
const warning = count
@@ -718,7 +813,22 @@
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, section.id)}
>
<div class="section-head">
<!-- Draggable, except while it is being renamed: a draggable element
will not let the pointer select text in the field it contains.
The sidebar's groups learned this first. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="section-head"
class:dragging={draggingSection === section.id}
class:drop-above={overSectionEdge === section.id && !overSectionBelow}
class:drop-below={overSectionEdge === section.id && overSectionBelow}
draggable={canEdit && renamingSection !== section.id}
ondragstart={(e) => onSectionDragStart(e, section)}
ondragend={clearSectionDrag}
ondragover={(e) => onHeadDragOver(e, section)}
ondragleave={() => (overSectionEdge = null)}
ondrop={(e) => onHeadDrop(e, section)}
>
{#if renamingSection === section.id}
<form onsubmit={(e) => renameSection(e, section)}>
<input
@@ -826,15 +936,30 @@
<!-- What has been written about this project, which is the payoff for a #tag
in a note being the same tag a task carries: nobody files anything here,
it turns up because somebody mentioned the project while writing. -->
{#if notes.length}
{#if notes.length || canEdit}
<section class="notes">
<h2>{t('notes.title')}</h2>
<div class="noteshead">
<h2>{t('notes.title')}</h2>
{#if canEdit}
<!-- En note skrevet herfra hører til projektet med det samme.
At ligge i et projekt *er* måden en note deles på, så det er
den samme handling som at oprette den og dele den — og det er
her, man står, når man opdager at der skal skrives noget ned
om netop dette projekt. -->
<button class="newnote" onclick={createNote}>{t('notes.newHere')}</button>
{/if}
</div>
{#if !notes.length}
<p class="hint">{t('notes.noneInProject')}</p>
{/if}
<ul>
{#each notes as note (note.id)}
<li>
<a href="/noter?note={note.id}">
<strong>{note.title || t('notes.untitled')}</strong>
<span>{note.body.slice(0, 90)}</span>
<!-- `preview` når listen bærer det, `body` når noten kom
fra et sted, der sender den hel. -->
<span>{(note.preview ?? note.body ?? '').slice(0, 90)}</span>
</a>
</li>
{/each}
@@ -863,6 +988,25 @@
border-top: 1px solid var(--line);
}
.noteshead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s2);
}
.newnote {
font-size: var(--text-sm);
color: var(--ink-muted);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
}
.newnote:hover {
background: var(--surface);
color: var(--ink);
}
.notes h2 {
font-size: var(--text-xs);
text-transform: uppercase;
@@ -1386,12 +1530,61 @@
gap: var(--s3);
background: var(--surface);
border-radius: var(--radius);
padding: var(--s2) var(--s3);
margin-bottom: var(--s1);
/* Pulled out to the padding of the rows below, so the band lines up with the
text it heads rather than with the rows' own inset. */
margin-left: calc(var(--s2) * -1);
margin-right: calc(var(--s2) * -1);
/* The same inset as the rows, and the same width.
*
* It used to be pulled out by `--s2` and padded by `--s3`, which is the same
* idea done with two different numbers: the band ended up eight pixels wider
* than the list on each side, and its text four pixels *outside* the column
* the rows keep. On the left that was invisible. On the right it was Omdøb
* and Slet standing four pixels past everything above and below them, which
* is exactly far enough to look like a mistake and not far enough to see why.
*
* A row is `.row` with `padding: var(--s2)` inside a box with none, so the
* column the eye follows starts one `--s2` in. The band matches that by
* having the same padding and no margin at all — no arithmetic, and nothing
* to keep in step if the rows are ever re-inset.
*/
padding: var(--s2);
/* For the drop line, which is drawn inside the band's own box. */
position: relative;
}
/* Grabbable, and only where it can actually be grabbed: the rename field is
not draggable, and a heading somebody may only read is not either. */
.section-head[draggable='true'] {
cursor: grab;
}
.section-head.dragging {
opacity: 0.4;
cursor: grabbing;
}
/* A line in the gap rather than a box around the heading. The gap is where it
will land, and a lit heading says "into this one" — which is what a task
dragged onto a section means, and this is not that. */
.section-head::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 1px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
.section-head.drop-above::after {
top: -1px;
opacity: 1;
}
.section-head.drop-below::after {
bottom: -1px;
opacity: 1;
}
.section-head h2 {