Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 6154e6b247 feat(udgaver): urd — noter-udgaven siger sit eget navn
Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.

Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.

Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.

Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.

Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.

Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.

Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 01:11:25 +02:00
Kristian Wind 7e3d300183 Merge pull request #95 from kristianwind/fladen-foelger-udgaven
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 15m10s
CI / Go (push) Failing after 16m56s
CI / Docker image builds (push) Successful in 2m40s
feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
2026-10-07 00:23:16 +02:00
kristianwindandClaude Opus 5 3cee9bfb6e feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
Trin 2. Serveren kunne allerede lade være med at montere opgaveruterne;
fladen tilbød dem stadig, så en noter-instans havde links til sider, der
ikke fandtes.

--- hvordan fladen får det at vide ---

`/auth/setup` bærer nu `edition`. Dér og ikke på `/auth/me`: udgaven er en
egenskab ved instansen, ikke ved den, der er logget ind, og fladen skal
kende den, før nogen er. Det er i forvejen kaldet, der spørger "hvad er
det her for en instans", så den følger med uden en ekstra tur.

Butikken henter formen ved siden af kontoen og tier, hvis den fejler: en
401 fra `me` må ikke tage udgaven med sig — login-skærmen er også en flade
i en udgave. Afventet før `loading` slippes, så fladen aldrig tegnes, før
den ved hvilket program den er; tegnet først og rettet bagefter ville vise
opgaver i et sekund på en instans, der ikke har nogen.

`full` som udgangspunkt, fordi det er det, programmet altid har været.

--- hvad der forsvinder ---

Menuens opgaveindgange er udeladt, ikke gråtonet. Forsiden er noterne. En
gemt adresse til /upcoming, /kalender, /uddelegeret, /faerdige, /opgave,
/filter eller /etiket fører til noterne frem for en blindgyde — der er
ikke sket noget forkert, adressen hører bare til et andet program end det,
der kører her. Listen står ét sted i layoutet frem for som en vagt pr.
rute: listen ER beslutningen, og en vagt pr. fil er syv steder at glemme
den ottende.

--- et hul, prøven fandt ---

/search er monteret i begge udgaver, fordi noter og notesbøger skal kunne
findes — og den returnerede opgaver. Ikke teoretisk: en instans lavet om
fra en fuld har opgaverne liggende i databasen endnu, så ⌘K ville være den
ene dør, der stod åben ind til den halvdel, resten af programmet lige
havde lukket. Lukket på serveren, hvor det hører hjemme, og ⌘K spørger
også selv — en tom liste af den rigtige grund og en tom liste ved et
tilfælde ser ens ud.

Prøven har en kontrol: den fulde udgave SKAL finde opgaven, ellers beviser
nullet i noter-udgaven ingenting.

--- prøverne ---

Et nyt Playwright-projekt med sin egen server, sin egen port og sin egen
database. Ingen lånt session: den instans har aldrig set den konto, den
fulde udgaves opsætning lavede. En skakt, der kun er prøvet ved at kalde en
funktion, er prøvet på det, nogen mente — det her måler programmet, som en
bruger møder det.

Seks prøver, heriblandt forskellen på skjult og fraværende målt udefra:
/api/v1/tasks, /labels, /upcoming og /filters skal svare 404, og
/api/v1/notes skal svare 200 — ellers ville de fire bestå på en server,
der bare var væk.

`testIgnore` på chromium-projektet, fordi filen ellers også kørte dér, mod
den fulde udgave, hvor den fejler med beskeder der læser som om skakten
ikke virker. Den rigtige fejl stod i projektnavnet, ikke i påstanden.

Go-suiten grøn. chromium + notes-edition: 110 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 23:14:00 +02:00
Kristian Wind 9b067f7057 Merge pull request #94 from kristianwind/to-udgaver-af-samme-program
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 2m36s
CI / Go (push) Failing after 16m59s
feat(udgaver): VERDANDE_EDITION afgør, om instansen er et opgaveprogram
2026-10-06 22:54:07 +02:00
kristianwindandClaude Opus 5 0c45080dc2 feat(udgaver): VERDANDE_EDITION afgør, om instansen er et opgaveprogram
Første skridt mod noter som et selvstændigt produkt. Kun routeren: fladen,
navnet og runen kommer bagefter.

--- hvorfor én kodebase og ikke en fork ---

Målt før noget blev skrevet. Noternes Go-kode er 2.386 af 49.407 linjer,
og den nævner `Task` nul gange, `quickadd` nul, `sections` nul. Den anden
vej er også nul. De to halvdele rører hinanden to steder: en `note_links`-
række, hvis `kind` tilfældigvis er "task", og rettigheder, hvor en note i
et projekt arver projektets rolle.

Delingen findes allerede uden projekter. `note_shares` er en tildeling pr.
note — note, person, rolle — med del, fjern, rolleopslag, publikum og
`SyncLinkedShares`. Og adgangsreglen har allerede grenen for en note uden
projekt: `WHERE project_id IS NULL AND created_by = ?`. Et selvstændigt
noteprogram er de regler, der allerede er der, med projektgrenen fjernet.

Det, der IKKE kan skilles ad, er fundamentet: konti, sessioner, 2FA,
invitationer, beskeder, vedhæftninger, søgning, sprog, PWA, runen,
beaconen, OpenAPI-dokumentet, e2e-stilladset. Begge programmer skal bruge
det hele. To kopier af det er den fejl, der står flest gange i vores egen
playbook — de syv plugins med et kopieret ci/-kit, den frysede delte fil.

Projekter bliver derfor stående i begge udgaver. I noter-udgaven er et
projekt en notesbog: samme kolonne, samme deling, andet ord. En anden
grupperingsmodel ved siden af `note_shares` ville være én ting mere at
holde i trit, for en forskel ingen kan se.

--- skakten ---

Opgaveruterne er ikke skjult i fladen. De er fraværende fra routeren, så
en instans, der ikke er et opgaveprogram, heller ikke kan bedes om at være
det gennem sit API.

En ukendt værdi stopper programmet ved opstart. "note", "Notes", "none" —
alle afvist. Det er den stille retning af fejlen: ellers ville en instans,
der blev sat op uden opgaver, servere hver eneste opgaverute, og den
eneste måde at opdage det på ville være at bemærke, at noget virker, som
ikke burde.

--- prøven ---

Den tjekker ikke, at der STÅR en skakt. Den vandrer begge routere og
sammenligner, hvad de faktisk serverer: 200 ruter mod 171, 29
tilbageholdt. De 29 er skrevet ned enkeltvis frem for som en præfiks-
regel — et præfiks beskriver, hvad nogen mente, og det her skal beskrive,
hvad der er. En rute tilføjet senere, der er forskellig mellem udgaverne,
fejler i begge retninger, til nogen siger hvilken halvdel den hører til.

Og den anden halvdel: noter-udgaven skal STADIG servere noter, eksport,
søgning, projekter og /auth/me. En skakt, der også slukker for det, den er
til for, er en sletning.

Efterprøvet ved at plante fejlen: /labels-skakten åbnet, prøven fejlede og
navngav alle seks ruter. Tallet står også på det grønne — "udgaverne er
forskellige" over nul tilbageholdte ruter og over 29 er det samme pass.

Go-suiten grøn. Fuld chromium-suite: 104 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 22:44:35 +02:00
Kristian Wind bba2dab209 Merge pull request #93 from kristianwind/eksport-af-det-hele
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 6m16s
CI / Docker image builds (push) Successful in 3m14s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 16m34s
feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
2026-10-06 12:41:15 +02:00
kristianwindandClaude Opus 5 438095e252 fix(noter): ⌘B gør teksten fed i stedet for at folde sidebjælken
Meldt ind: "command+b viser og skjuler menuen - ikke bold".

Fladen tog ⌘B til sidebjælken, og begrundelsen stod i koden: "works while
typing as well — it moves nothing in the field". Det passer for et
`<input>`, hvor ⌘B ikke gør noget. I en contenteditable ER ⌘B fed tekst,
og `preventDefault()` stoppede browseren i at gøre den.

Sætningen var sand, da den blev skrevet. Så kom en rig editor til, og
begrundelsen holdt op med at passe uden at nogen rørte linjen — samme form
som flere af fælderne i den her kodebase.

Feltet, markøren står i, har førsteret til en tast, det selv bruger. Uden
for et skrivefelt folder genvejen stadig sidebjælken, og prøven måler
begge halvdele: en rettelse, der bare holdt op med at folde, ville være
halvt færdig.

Efterprøvet mod den gamle kode — ordet blev aldrig fedt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:28:23 +02:00
kristianwindandClaude Opus 5 3132bba7e5 feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
To ønsker, meldt sammen.

--- eksport af det hele ---

Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip,
og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun
kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten
downloads for at flytte sine egne opgaver, hvilket er en eksport, der
teknisk set findes, og som ingen bruger.

`GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred
fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så
det er den form, der importeres i den anden ende — og den form, man kan
hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som
noteeksporten, der er en mappe med Markdown og ikke én lang fil.

`GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og
ikke en zip, fordi en kalender er lavet til at rumme mange ting, og
projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato
er en linje, de fleste læsere taber på gulvet.

Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender
uden VTODO'er er begge 200 med en troværdig Content-Type, og det er
præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en
løkke der kun eksporterer det sidste ikke består.

--- en indsat liste bliver til opgaver ---

Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk
ved indsætning i et `<input>` — så en kopieret liste blev til én opgave
med punkterne mast sammen.

Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem
for at være gættet til to eller fire: en liste fra Apple Noter rykker ind
med ét antal mellemrum, en fra et Markdown-dokument med et andet, og
begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den
notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste,
der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den
for.

Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses
af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét
sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add`
nu et `parent_id`: en indrykket linje skal parses som enhver anden.

En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket
linje honoreres derfor ikke, og det siges højt som `unknown_project` frem
for at ordet bare forsvinder fra titlen.

Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks
dér. Det er "enten opgaver eller undertasks" afgjort af hvor man
indsætter, uden at nogen skal vælge noget i en menu.

Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke
noget, man skal opdage bagefter — og visningen er også svaret på "forstod
den indrykningen", stillet mens der stadig er en Fortryd-knap.

--- rettet undervejs ---

Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra
ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på
forælderen og regnes ud ved hentning, og serveren sender ikke forælderen
igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun
den nye.

Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:27:46 +02:00
Kristian Wind 44b28f1cd9 Merge pull request #92 from kristianwind/monotype-over-hele-markeringen
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 6m4s
CI / Docker image builds (push) Successful in 3m54s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 17m31s
fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
2026-10-02 23:07:05 +02:00
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

Monotype går uden om execCommand, fordi formatBlock pakker et <p> ind i
<pre>'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den <br>-fejl, der
blev rettet i #91: `textContent` på et <br> er den tomme streng.

Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i
blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet
sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i
filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode:
"echo enecho to".

Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét
sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev
skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted,
eksporteret og brugt begge steder.

Begge prøver er efterprøvet mod den gamle kode.

Fuld chromium-suite kørt to gange: 94 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:58:08 +02:00
Kristian Wind dba99b7ecc Merge pull request #91 from kristianwind/noter-lister-blokke-og-soegning
Release / Build and publish image (push) Failing after 6s
fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
2026-09-22 14:10:13 +02:00
kristianwindandClaude Opus 5 f34690f41a fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
Tre ting meldt ind sammen. De to første manglede; den tredje var i stykker.

--- man kunne søge og ikke vælge ---

Regression fra #84. Siden holdt op med at SKRIVE adressen, men blev ved med
at læse den ved hver ændring:

    let asked = $derived($page.url.searchParams.get('note'));
    $effect(() => { if (!asked || selectedId === asked) return; … });

Kom man fra ⌘K med `?note=<id1>`, stod `asked` fast på id1 for altid.
Hvert klik på en anden række satte `selectedId`, effekten så uenigheden og
åbnede id1 igen. Man blev trukket tilbage til den første træffer.

En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
Den læses nu ved ankomsten — og der er TO ankomster, hvilket den første
udgave af rettelsen ikke tog højde for: layoutet tegner ingenting, mens
kontoen hentes, så siden monteres efter den første navigation er forbi, og
en lytter registreret der hører den aldrig. `/noter?note=<id>` tastet eller
fulgt udefra åbnede så ingenting. `onMount` og `afterNavigate`, samme
funktion, med `id === selectedId` som værn mod at begge fyrer.

--- en blok kunne ikke gemme flere linjer ---

Shift+retur bliver i blokken, men det browseren lægger ind er et <br>, og
`textContent` på et <br> er den tomme streng. Blokken blev skrevet til
Markdown som én linje: "first linesecond line". Usynligt indtil en
genindlæsning, for på skærmen står linjerne rigtigt hele tiden.

`codeText()` læser blokken rekursivt og gør <br> til et linjeskift.
Rekursivt, fordi indholdet ligger i et <code> inde i <pre>: en filtrering
over <pre>'ens egne børn spørger det ene element om en textContent, der
allerede har tabt linjeskiftet.

--- lister havde ingen vej til niveau to ---

Tab var ikke bundet til noget uden for forslagslisten, så den gjorde det,
Tab gør: flyttede fokus ud af noten. Den rykker nu ind og shift+Tab ud, og
kun inde i en liste — ellers er noten en boks, man ikke kan tabbe ud af.

Og skriveren ville have tabt resultatet: `list()` læste kun en underliste,
der ligger INDE i sit punkt. Lægger browseren den som søskende, og de gør
det uens, blev grenen sprunget over uden lyd. At binde Tab uden den her
halvdel ville have lavet en fejl, hvor indrykkede punkter forsvandt ved
gemningen.

Begge nye prøver måler EFTER en genindlæsning. Før den ser begge dele
rigtige ud på skærmen; det er turen gennem Markdown, der afgør, om det
blev skrevet ned.

Fuld chromium-suite kørt to gange: 92 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:01:15 +02:00
Kristian Wind 1bb2c9cda6 Merge pull request #90 from kristianwind/klokken-paa-telefon
fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
2026-09-17 19:06:03 +02:00
kristianwindandClaude Opus 5 cac6c14a23 fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
Panelet hang under klokken med `right: 0` og en fast bredde på 340 px.
Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en
kantafstand til højre for den — så der er kun omkring 330 px tilbage, og
resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved
en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder"
klæbede til kanten uden luft, hvilket er det synlige tegn.

På en telefon hører panelet nu til skærmen i stedet for til klokken: det
spænder fra kant til kant med en margen i hver side, under toplinjen. Samme
knæk ved 820 px som toplinjens egen menuknap, så de to skifter form
samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i
listen ikke trækker siden bagved med.

Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE
trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og
den næste knap i toplinjen ville lave fejlen om igen uden at sige noget.

--- og fire ustabile prøver mere ---

Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man
kan se, er altså sandt, før serveren har hørt om det, og en prøve, der
genindlæser eller klikker videre i det hul, måler den gamle tilstand.

`en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning.
Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil
CI er travl — begge fejlede i en fuld kørsel her.

`en gruppe er en side med sine projekter` er den mest lærerige: trækket
virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket
videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på
et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem.

Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den
første var lukket: den klikkede "Ny note" og skrev med det samme. `create()`
venter på serverens svar, og imens står den forrige notes editor stadig på
skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny
hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen
har samme mønster; de er ikke skrevet om, fordi de består, og en samlet
omskrivning ville bytte kendte prøver for ukendte.

Det hul findes også for et menneske: klikker man Ny note og skriver straks,
lander de første anslag i den forrige note. Det er millisekunder lokalt og
sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR
svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her.

`noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og
projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem
lige før en genindlæsning eller en navigation.

Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:55:45 +02:00
Kristian Wind f632fb8643 Merge pull request #89 from kristianwind/to-vaern-mere
test(e2e): to genindlæsninger mere venter på gemningen
2026-09-17 13:12:07 +02:00
kristianwindandClaude Opus 5 eabb00ecc2 test(e2e): to genindlæsninger mere venter på gemningen
Samme form som de fire "Gemt"-ventetider, to andre steder — begge lige før
en `page.reload()`, hvilket er der, hvor det koster.

`en note kan skrives fed, og den kommer hel tilbage` havde `blur()` direkte
efterfulgt af en genindlæsning uden noget værn overhovedet. Nåede PATCH'en
ikke frem først, målte prøven en note, der aldrig blev skrevet — og den
prøve findes netop for at fange en omformer, der er uenig med sig selv.

`[[henvisning]]`-prøven havde `waitForTimeout(900)`. Tallet var gættet til
at overleve tastepausen på 700 ms; et gæt om tid måler maskinen frem for
programmet. Det er den konstruktion, den her fil allerede advarer imod.

Begge venter nu på PATCH-svaret gennem noteGemt().

Der står 24 faste ventetider tilbage i filen. De er ikke rørt: flere af dem
venter med vilje (at skrive i et menneskeligt tempo er en rigtig grund), og
at skrive dem om samlet ville bytte kendte prøver for ukendte.

Fuld chromium-suite: 89 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:02:31 +02:00
Kristian Wind d2b5821e57 Merge pull request #88 from kristianwind/vaern-der-venter-paa-noget
test(e2e): værn, der kan vente på noget
2026-09-17 12:59:26 +02:00
kristianwindandClaude Opus 5 4874f7bbd8 test(e2e): værn, der kan vente på noget
Fire steder ventede prøverne på teksten "Gemt" i notefoden. Den er
hvilestanden: feltet siger "Gemmer" mens en gemning kører og "Gemt"
ellers — altså også før den første gemning overhovedet er begyndt. Alle
fire ventetider var grønne med det samme og ventede på ingenting.

To af dem dækkede over et rigtigt kapløb:

- `en delt note tager de noter med, den peger på` skriver i en note og
  navigerer væk umiddelbart efter. Nåede PATCH'en ikke frem først, var
  teksten væk, da siden kom igen.

- `en gruppe er en side med sine projekter` har samme form et andet sted:
  beskrivelsen tegnes optimistisk — siden sætter teksten på gruppen straks
  og sender PATCH'en bagefter — så kontrollen var grøn, før serveren havde
  hørt om den, og `page.reload()` lige efter hentede den gamle gruppe
  tilbage. Prøven fejlede altså på noget, der ikke var i stykker.

De to sidste ventede på ingenting uden at skade noget, men var stadig
vildledende at læse.

Der er nu én hjælper, `noteGemt(side)`, der venter på selve PATCH-svaret,
med forklaringen på hvorfor "Gemt" ikke duer skrevet ét sted.
Gruppeprøven venter tilsvarende på sin egen PATCH. Hvor rækken i listen
allerede var det rigtige værn, er den overflødige linje væk.

Samme form som reglen i CLAUDE.md om at læse, hvad en kontrol skrev, da
den BESTOD: "venter på gemning" og "matchede den tekst, der altid står
der", er samme grønne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:15 +02:00
Kristian Wind 8bf4f8fbe4 Merge pull request #87 from kristianwind/et-udfald-siger-det
feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
2026-09-17 12:38:19 +02:00
51 changed files with 3112 additions and 189 deletions
+5
View File
@@ -44,6 +44,11 @@ node_modules.nosync/
# End-to-end runs: the throwaway instance the suite starts, the saved session it
# signs in with, and whatever a failure left behind.
/web/.playwright-data/
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
# af det samme.
/web/.playwright-data-notes/
/web/e2e/.auth/
/web/test-results/
/web/playwright-report/
+16
View File
@@ -15,6 +15,22 @@
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.
## Two editions: verdande and urd
This repository builds one program with two faces. Start it with
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
routes are not mounted, the sidebar has no way to them, and the program calls
itself urd in its tab, its wordmark and its web manifest.
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
wood. Verdande is what is becoming; urd is what has become and was kept.
Not a fork, because the two share everything that is hard — accounts, sharing,
passkeys, the realtime socket, search, backups, migrations — and notes are about a
twentieth of the Go here. A test walks both routers and fails in either direction,
so "the task routes are not there" is measured rather than promised. See
[Two editions](docs/editions.md).
## Status
All six phases are built. See [What works today](#what-works-today) for what is
+6 -1
View File
@@ -26,6 +26,9 @@ var version = "dev"
func main() {
if err := run(); err != nil {
// Not the edition's name: this fires before the configuration is read, so
// the edition is not known yet — and one of the things it reports is a
// VERDANDE_EDITION the program refused.
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
os.Exit(1)
}
@@ -37,7 +40,9 @@ func run() error {
return err
}
log := httpapi.NewLogger(cfg.Dev)
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
// The edition's own name, so an urd instance's log says urd. The full edition
// still logs "starting verdande", which is what the release check reads.
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
// The data volume is the only state that matters; create the whole layout up
// front so a failure here is a startup error rather than a surprise at the
+4 -2
View File
@@ -1,6 +1,7 @@
# The install count
verdande reports that it exists. Two values, once a day:
verdande reports that it exists — and so does urd, which is the same program in
its [notes edition](editions.md). Two values, once a day:
```json
{
@@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day:
That is the whole message. No IP is stored by the collector, no hostname, no
domain, no account, no counts of anything you have made, nothing about what you
use it for. The payload is small enough to be printed in full on the settings page,
use it for — and not which of the two editions is running, so the count does not
tell the two apart either. The payload is small enough to be printed in full on the settings page,
and it is printed there — a promise about telemetry is worth exactly as much as
your ability to check it.
+1
View File
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
## Behind a proxy
+69
View File
@@ -0,0 +1,69 @@
# Two editions: verdande and urd
One program, built once, with two faces.
| | **verdande** | **urd** |
|---|---|---|
| What it is | Tasks, projects and notes | Notes |
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
| Routes served | 200 | 171 |
| Mark | ᚹ | ᚢ |
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
mounted, the sidebar has no way to them, search answers with notes only, and the
program calls itself urd in its tab, its wordmark and its web manifest.
## Why one codebase and not a fork
The two share everything that is hard: accounts, sessions, passkeys, sharing,
invitations, the realtime socket, search, the backup format, the migrations. Notes
are about a twentieth of the Go in this repository and reference no task type;
tasks reference no note type. A fork would have duplicated the other nineteen
twentieths in order to separate the one — and then every security fix would have
needed applying twice, by hand, forever.
So the split is a configuration value read in one place, and the thing that keeps
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
walks both routers, compares them against a written-down list of the 29 withheld
routes, and fails in **both** directions — a route that appears in the notes
edition and a route that stops being withheld are both a failure.
## An unknown value refuses to start
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
rather than being treated as "not notes".
The silent direction is the dangerous one here. An operator who sets up an instance
for notes and mistypes the value would otherwise get every task route served on it,
with no error anywhere and no way to tell from the outside.
## What urd does not have
The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the
upcoming and delegated views, and the task half of search and export. Everything
else is the same program:
- accounts, invitations, passkeys and two-factor
- projects — which in urd are what a note belongs to
- per-note sharing with other people, independent of projects
- search, over notes
- export, of notes
- the mail, calendar and MCP surfaces, minus their task routes
## The name
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
cutting marks into wood — the act of writing something down. Verdande is what is
becoming; Urd is what has become and was kept.
An instance started without the task routes is a different product to the person
using it, so it says a different name. That name is read from the configuration in
one place (`Config.ProductName`), which is why there is no build-time flag and no
second frontend: the frontend is byte-identical in both editions, and the server
renames the two files a browser reads before any JavaScript runs — the HTML shell's
title and the web manifest.
!!! note "The beacon counts them separately"
Both editions report the same two values once a day — instance id and version
— and nothing else; see [The install count](beacon.md). The edition is not in
the payload, so the count does not distinguish them.
+59 -1
View File
@@ -53,7 +53,7 @@ paths:
/api/v1/auth/setup:
get:
tags: [auth]
summary: Whether this instance still needs its first account.
summary: What this instance is, and whether it still needs its first account.
security: []
responses:
"200":
@@ -64,6 +64,16 @@ paths:
type: object
properties:
needs_setup: { type: boolean }
edition:
type: string
enum: [full, notes]
description: >
`full` is tasks, projects and notes together. `notes` is
the notes half: the task routes are not mounted at all, so
an instance that is not a task manager cannot be asked to
be one through its API. The interface reads this before
anybody is signed in, because it decides what there is to
show rather than what this person may see.
post:
tags: [auth]
summary: Create the first account, which becomes the administrator.
@@ -848,6 +858,20 @@ paths:
project_id:
type: string
description: Where it goes when the text names no project.
section_id:
type: string
description: >
The section the box was opened in. A "/section" in the text
wins, because typing it is a choice and standing there is a
circumstance.
parent_id:
type: string
description: >
Makes it a sub-task. A sub-task lives in its parent's
project, so a "#project" on the same line is not honoured —
it comes back as `unknown_project` rather than being
silently dropped. Dates, labels and priority are read the
same way as for any other line.
responses:
"201":
description: Created
@@ -3143,6 +3167,40 @@ paths:
to: { type: string, format: date }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/export/projects.zip:
get:
tags: [projects]
summary: Every project's tasks as a CSV, in a zip.
description: >
One CSV per project, in the column layout Todoist writes — so the files
import into Todoist and into anything else that reads that shape. A zip
rather than one wide file because that is the shape on the other side,
and because a single project can be lifted out of it without opening a
spreadsheet. The per-project link does the same for one project; this is
the whole account in one download.
responses:
"200":
description: A zip archive.
content:
application/zip:
schema: { type: string, format: binary }
/api/v1/export/tasks.ics:
get:
tags: [projects]
summary: Every dated task as a calendar of VTODOs.
description: >
One file, not a zip: a calendar is made to hold many things, and each
task carries its project with it, so nothing is lost by putting them
together. Tasks without a due date are left out — a VTODO with no date
is a line most readers drop on the floor.
responses:
"200":
description: An iCalendar file.
content:
text/calendar:
schema: { type: string }
/api/v1/export/notes.zip:
get:
tags: [notes]
+36
View File
@@ -38,6 +38,28 @@ type Config struct {
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// Edition decides which half of the program this instance serves.
//
// "full" is tasks, projects and notes together — what Verdande has always
// been. "notes" mounts the notes half and the base underneath it, and leaves
// the task routes unmounted: not hidden in the interface, absent from the
// router, so an instance that is not a task manager cannot be asked to be one
// through its API.
//
// Notes already stand nearly alone — they reference no task type anywhere in
// the store, and the one link between the halves is a `note_links` row whose
// kind happens to be "task". What is NOT separable is everything underneath
// both: accounts, sessions, invites, sharing, notifications, attachments,
// search. Hence one program with two faces rather than two programs: the
// alternative is two copies of the base, and two copies of a thing is how the
// copies start to disagree.
//
// Projects stay mounted in both. In the notes edition a project is a
// notebook — the same column, the same sharing, a different word — because a
// second grouping concept beside `note_shares` would be a second thing to keep
// in step for a difference nobody can see.
Edition string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
Edition: env("VERDANDE_EDITION", EditionFull),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
// som ikke burde.
switch c.Edition {
case EditionFull, EditionNotes:
case "":
c.Edition = EditionFull
default:
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
}
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
return nil, err
+46
View File
@@ -0,0 +1,46 @@
package config
// The two faces of the program. Strings rather than a bool, because a bool named
// `NotesOnly` answers one question and this one will be asked again — and a
// third edition would mean changing every reading of it rather than adding a case.
const (
EditionFull = "full"
EditionNotes = "notes"
)
// NotesOnly is the one question the router asks. Written as a method so the
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
// call sites is twenty chances to typo a string the compiler does not check.
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
// ProductName and ProductSlug are the two forms of the name the running instance
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
// manifest.
//
// Two editions of one program, two names — which is the whole reason the name is
// read from the configuration rather than written into the templates. Verdande is
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
// and kept. An instance started without the task routes is a different product to
// whoever uses it, and a product that calls itself by its sibling's name is not a
// product, it is a flag somebody set.
//
// The lowercase form is deliberate and not a styling accident: the wordmark, the
// repository and the window title are all lowercase, so the slug is the name and
// ProductName is the one that gets a capital because it stands in prose.
func (c *Config) ProductName() string {
if c.NotesOnly() {
return "Urd"
}
return "Verdande"
}
// ProductSlug is ProductName lowercased, kept as its own method rather than as
// strings.ToLower at the call sites: the two are the same transformation today and
// a name that does not lowercase cleanly would otherwise be a bug in every caller
// at once.
func (c *Config) ProductSlug() string {
if c.NotesOnly() {
return "urd"
}
return "verdande"
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"testing"
)
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
// edition. That is the silent direction of this fault: an instance set up to be a
// notes program would serve every task route, and the only way to notice would be
// to spot that something works which should not.
func TestUnknownEditionIsRefused(t *testing.T) {
for _, tc := range []struct {
value string
want string // the edition we expect, or "" when Load must fail
}{
{"", EditionFull}, // unset is the full program, as it has always been
{EditionFull, EditionFull},
{EditionNotes, EditionNotes},
{"note", ""}, // a letter short
{"Notes", ""}, // the right word, the wrong case
{"none", ""}, // plausible and meaning nothing
} {
t.Setenv("VERDANDE_EDITION", tc.value)
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
got, err := Load()
if tc.want == "" {
if err == nil {
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
}
continue
}
if err != nil {
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
continue
}
if got.Edition != tc.want {
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
}
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
}
}
}
+8 -1
View File
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "count users", err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
// instans", så den følger med uden en ekstra tur.
writeJSON(w, http.StatusOK, struct {
NeedsSetup bool `json:"needs_setup"`
Edition string `json:"edition"`
}{n == 0, s.edition()})
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
+11
View File
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
if err != nil {
return nil
}
return scriptHashesIn(raw)
}
// scriptHashesIn is the same thing over bytes that are already in hand.
//
// It exists because the notes edition does not serve the index.html that is on
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
// describe the bytes that actually leave the server. Hashing the file while
// serving something else is the shape of mistake that cannot be noticed locally:
// the policy is not enforced for a page opened from a file, so the page is blank
// in production and nowhere else.
func scriptHashesIn(raw []byte) []string {
var out []string
seen := map[string]bool{}
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
+16
View File
@@ -0,0 +1,16 @@
package httpapi
import "github.com/kristianwind/verdande/internal/config"
// edition is what this instance tells the world it is.
//
// Never the empty string, even when the config was built by hand in a test and
// never went through config.Load: a flade that reads "" cannot tell "this is the
// full program" from "this build forgot to say", and the first of those is the
// answer that has always been true.
func (s *Server) edition() string {
if s.cfg.NotesOnly() {
return config.EditionNotes
}
return config.EditionFull
}
+230
View File
@@ -0,0 +1,230 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// The notes edition is a different program, and this is what makes that true.
//
// A gate in a route tree is the easiest thing in the world to believe and the
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
// exists, and nothing ever asks whether it covers the routes it was written for.
// So this does not check that a gate is *present*. It walks both routers and
// compares the two sets of routes they actually serve.
//
// The difference is written down below. A route added later that differs between
// the editions — in either direction — fails here until somebody says which half
// it belongs to. That is the point: the list is the decision, and the walk is
// what keeps the decision honest.
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
full := routesOf(t, config.EditionFull)
notes := routesOf(t, config.EditionNotes)
// Every route the full edition has and the notes edition does not. Written as
// the whole list rather than a prefix rule: a prefix is a description of what
// somebody meant, and this has to be a description of what is there.
withheld := map[string]bool{}
for _, r := range []string{
"DELETE /api/v1/filters/{filterID}",
"DELETE /api/v1/labels/{labelID}",
"DELETE /api/v1/reminders/{reminderID}",
"DELETE /api/v1/tasks/{taskID}",
"GET /api/v1/delegated",
"GET /api/v1/filters",
"GET /api/v1/filters/preview",
"GET /api/v1/filters/{filterID}/tasks",
"GET /api/v1/labels",
"GET /api/v1/tasks",
"GET /api/v1/tasks/quick-add/preview",
"GET /api/v1/tasks/{taskID}",
"GET /api/v1/tasks/{taskID}/comments",
"GET /api/v1/tasks/{taskID}/reminders",
"GET /api/v1/upcoming",
"PATCH /api/v1/filters/{filterID}",
"PATCH /api/v1/labels/{labelID}",
"PATCH /api/v1/tasks/{taskID}",
"POST /api/v1/filters",
"POST /api/v1/labels",
"POST /api/v1/tasks",
"POST /api/v1/tasks/quick-add",
"POST /api/v1/tasks/{taskID}/attachments",
"POST /api/v1/tasks/{taskID}/comments",
"POST /api/v1/tasks/{taskID}/complete",
"POST /api/v1/tasks/{taskID}/move",
"POST /api/v1/tasks/{taskID}/reminders",
"POST /api/v1/tasks/{taskID}/reopen",
"POST /api/v1/tasks/{taskID}/snooze",
} {
withheld[r] = true
}
var unexpected, stillThere []string
for r := range full {
if notes[r] {
if withheld[r] {
stillThere = append(stillThere, r)
}
continue
}
if !withheld[r] {
unexpected = append(unexpected, r)
}
}
// And the other direction: the notes edition must not grow a route the full
// one lacks. A gate that adds is not a gate either.
var onlyInNotes []string
for r := range notes {
if !full[r] {
onlyInNotes = append(onlyInNotes, r)
}
}
sort.Strings(unexpected)
sort.Strings(stillThere)
sort.Strings(onlyInNotes)
for _, r := range unexpected {
t.Errorf("withheld from the notes edition but not written down here: %s", r)
}
for _, r := range stillThere {
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
}
for _, r := range onlyInNotes {
t.Errorf("served only by the notes edition: %s", r)
}
// Printed on the green as well as the red. "The editions differ correctly" over
// zero withheld routes and over twenty-nine are the same pass, and only one of
// them means anything — so the number is on screen either way.
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
// The half that stops this being a deletion rather than a gate: what the notes
// edition is FOR has to still be there.
for _, must := range []string{
"GET /api/v1/notes",
"POST /api/v1/notes",
"GET /api/v1/export/notes.zip",
"GET /api/v1/projects", // a notebook, in this edition
"GET /api/v1/search",
"GET /api/v1/auth/me",
} {
if !notes[must] {
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
}
}
}
// routesOf builds a server at one edition and returns every route it serves.
func routesOf(t *testing.T, edition string) map[string]bool {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
srv := New(&config.Config{
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
out := map[string]bool{}
err = chi.Walk(srv.router,
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if route != "/" {
route = strings.TrimSuffix(route, "/")
}
out[method+" "+route] = true
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
if len(out) < 50 {
// A walk that found almost nothing would make every comparison below pass.
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
}
return out
}
// The interface cannot gate itself on something it is not told, and it has to be
// told before anybody is signed in — an instance's shape is not a permission.
func TestSetupStateSaysWhichEdition(t *testing.T) {
for _, tc := range []struct{ edition, want string }{
{config.EditionFull, "full"},
{config.EditionNotes, "notes"},
{"", "full"}, // a config built by hand is the program as it has always been
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
if got := body["edition"]; got != tc.want {
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
}
// And the field it has always had is still there: adding one must not
// quietly replace the answer the sign-in screen depends on.
if _, ok := body["needs_setup"]; !ok {
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
}
}
}
// Search is mounted in both editions — notes and notebooks have to be findable —
// so it is the one door that could still open onto the half the router just shut.
//
// Not a theoretical case. An instance converted from a full one still has the
// tasks in its database, and ⌘K would be the only place they turned up.
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
for _, tc := range []struct {
edition string
want int
}{
{config.EditionFull, 1},
{config.EditionNotes, 0},
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
ts.bootstrap(t)
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
userID, _ := me["id"].(string)
if userID == "" {
t.Fatalf("no user: %v", me)
}
// Created through the store rather than the API: in the notes edition there
// is no route to create one, and the row this is about is the row that is
// already there.
inbox, err := ts.db.InboxID(context.Background(), userID)
if err != nil {
t.Fatal(err)
}
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
t.Fatal(err)
}
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
found, _ := body["tasks"].([]any)
if len(found) != tc.want {
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
}
// The full edition's number is the control: a search that finds nothing at
// all would satisfy the notes case for the wrong reason.
if tc.edition == config.EditionFull && len(found) == 0 {
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
}
}
}
+106
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"archive/zip"
"encoding/json"
"fmt"
"net/http"
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
}
}
// handleExportAllCSV is every project at once, one CSV per project in a zip.
//
// A zip rather than one wide CSV with a project column: Todoist's own export is a
// file per project, so that is the shape an importer on the other side expects —
// and the shape somebody can pick a single project out of without opening a
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
// rather than one long file.
//
// It exists because the per-project links are per project: fifteen projects meant
// fifteen downloads to move your own tasks somewhere else, which is an export that
// technically works and nobody uses.
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
s.internal(w, r, "export projects", err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
// Two projects can share a name — they are still two projects, and a zip with
// one entry written twice loses one of them. Same guard as the notes export.
used := map[string]int{}
for _, project := range projects {
exported, err := s.buildExport(r, user, &project)
if err != nil {
// The response is already streaming, so there is no status left to send.
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
// better than a complete-looking file with a project missing from it.
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
used[name]++
if used[name] > 1 {
name = fmt.Sprintf("%s (%d)", name, used[name])
}
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
if err != nil {
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
}
}
// handleExportAllICS is every dated task in one calendar.
//
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
// many things and every reader takes a file with hundreds of VTODOs. The project
// travels with each task as its own field, so nothing is lost by merging them.
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
ctx := r.Context()
projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil {
s.internal(w, r, "export ics", err)
return
}
names := map[string]string{}
for _, p := range projects {
names[p.ID] = p.Name
}
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, r, "export ics", err)
return
}
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
for _, t := range tasks {
if t.DueDate == "" {
continue
}
cal.Tasks = append(cal.Tasks, ics.Task{
ID: t.ID, Content: t.Content, Description: t.Description,
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
Completed: t.Completed(), CompletedAt: t.CompletedAt,
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
})
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
w.Write([]byte(ics.Render(cal)))
}
// buildExport turns a stored project back into the neutral shape the CSV writer
// takes, rebuilding the task tree from parent ids.
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
+108 -40
View File
@@ -3,6 +3,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
@@ -77,6 +78,11 @@ type Server struct {
// Computed once from the built index.html; see csp.go.
csp string
// The shell and the manifest with this edition's name in them, or nil to serve
// the built files unchanged. Only the notes edition needs them; see shell.go.
shell []byte
manifest []byte
router chi.Router
}
@@ -86,7 +92,7 @@ type Server struct {
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
s := &Server{
cfg: cfg, db: db, log: log, web: web,
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
// Generous on purpose: the caller is the mail server, one address delivering
@@ -101,8 +107,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// 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 {
// Before the CSP, deliberately: the policy has to describe the bytes that go
// out, and in the notes edition those are the renamed ones.
//
// A failure here is logged rather than fatal, and that is a judgement about
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
// refuses to start, because serving task routes on an instance set up without
// them is a different kind of wrong. A shell whose title says the sibling's
// name is cosmetic — it must be loud, and it must not take the instance down.
// What makes it loud is not this line: the end-to-end suite reads the served
// title, so an anchor that falls out of app.html goes red there.
if web != nil && cfg.NotesOnly() {
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
if err != nil {
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
"edition", cfg.Edition, "err", err)
} else {
s.shell, s.manifest = shell, manifest
}
}
if s.shell != nil {
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
} else {
s.csp = contentSecurityPolicy(scriptHashes(web))
}
// The second argument is WebAuthn's display name, not its relying party id —
// the id comes from BaseURL. So this is what the operating system's passkey
// prompt calls the site, and the slug keeps it identical to what every existing
// verdande instance already registered.
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
@@ -285,6 +319,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Everything past this point needs a complete login.
r.Group(func(r chi.Router) {
// Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i
// fladen — fraværende fra routeren, så en instans, der ikke er et
// opgaveprogram, heller ikke kan bedes om at være det gennem sit API.
full := !s.cfg.NotesOnly()
r.Use(s.requireAuth)
r.Get("/ws", s.handleWebSocket)
@@ -294,15 +332,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/mcp", s.handleMCP)
r.Get("/today", s.handleToday)
r.Get("/filters/preview", s.handlePreviewFilter)
if full {
r.Get("/filters/preview", s.handlePreviewFilter)
}
r.Route("/filters", func(r chi.Router) {
r.Get("/", s.handleListFilters)
r.Post("/", s.handleCreateFilter)
r.Get("/{filterID}/tasks", s.handleRunFilter)
r.Patch("/{filterID}", s.handleUpdateFilter)
r.Delete("/{filterID}", s.handleDeleteFilter)
})
if full {
r.Route("/filters", func(r chi.Router) {
r.Get("/", s.handleListFilters)
r.Post("/", s.handleCreateFilter)
r.Get("/{filterID}/tasks", s.handleRunFilter)
r.Patch("/{filterID}", s.handleUpdateFilter)
r.Delete("/{filterID}", s.handleDeleteFilter)
})
}
r.Get("/feed", s.handleGetFeed)
@@ -456,6 +498,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
@@ -466,16 +510,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
if full {
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
}
r.Route("/labels", func(r chi.Router) {
r.Get("/", s.handleListLabels)
r.Post("/", s.handleCreateLabel)
r.Patch("/{labelID}", s.handleUpdateLabel)
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
if full {
r.Route("/labels", func(r chi.Router) {
r.Get("/", s.handleListLabels)
r.Post("/", s.handleCreateLabel)
r.Patch("/{labelID}", s.handleUpdateLabel)
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
}
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
@@ -581,27 +629,29 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/", s.handleDeleteSection)
})
r.Route("/tasks", func(r chi.Router) {
r.Get("/", s.handleListTasks)
r.Post("/", s.handleCreateTask)
r.Post("/quick-add", s.handleQuickAdd)
r.Get("/quick-add/preview", s.handleQuickAddPreview)
if full {
r.Route("/tasks", func(r chi.Router) {
r.Get("/", s.handleListTasks)
r.Post("/", s.handleCreateTask)
r.Post("/quick-add", s.handleQuickAdd)
r.Get("/quick-add/preview", s.handleQuickAddPreview)
r.Route("/{taskID}", func(r chi.Router) {
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
r.Post("/snooze", s.handleSnoozeTask)
r.Route("/{taskID}", func(r chi.Router) {
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
r.Post("/snooze", s.handleSnoozeTask)
})
})
})
}
})
})
@@ -669,7 +719,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
if s.web == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
return
}
@@ -690,6 +740,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
}
defer f.Close()
// Only here is `name` settled, and that matters: every client-side route —
// /noter and all the rest — arrives as a path with no file behind it and is
// resolved to index.html by the fallback above. Asking before the fallback
// would have renamed the shell for a request to "/" and handed back the built
// one for every other route in the app.
//
// These two assets never reach the filesystem: they are the built files with
// this edition's name in them, held in memory since startup. See shell.go.
if b := s.renamed(name); b != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
// A zero modification time tells ServeContent to send no Last-Modified and
// to answer no conditional request — right for a shell that must never be
// cached, and the same thing the built shell gets below.
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
return
}
info, err := f.Stat()
if err != nil || info.IsDir() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"bytes"
"fmt"
"io/fs"
)
// The product name is in the built frontend as a literal in four places, because
// all four are read before any JavaScript runs: the window title and the meta
// description in the HTML shell, and the two names in the web manifest that decide
// what an installed PWA is called on a home screen.
//
// They cannot be filled in by the app at runtime. The shell is parsed before the
// first script executes, the manifest is fetched by the browser rather than by us,
// and the edition is not known until /auth/setup-state has answered — so a frontend
// that renamed itself after loading would show the sibling's name first and correct
// itself a moment later, on every load, in the tab and on the install prompt.
//
// So the server does it, once at startup, on the way out. One frontend build, two
// names: the build is identical in both editions and the bytes served differ.
//
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
// matched nothing is the whole failure mode here — it leaves an Urd instance
// calling itself verdande, which is cosmetic enough that nobody files it and
// nothing else notices.
type rebrand struct {
old, new string
}
// shellRebrands returns the replacements for index.html.
//
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
// neighbours in the inline theme script. Those keys are storage, not a name — the
// rest of the app reads the same three, so renaming them here and nowhere else
// would set the theme from one key and read it from another, and the only symptom
// is the white flash that script exists to prevent. This is why the replacements
// are written out as whole anchored strings rather than as a replace of the word
// "verdande".
func shellRebrands(slug, description string) []rebrand {
return []rebrand{
{"<title>verdande</title>", "<title>" + slug + "</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + description + `" />`,
},
}
}
// manifestRebrands returns the replacements for manifest.webmanifest. The
// manifest's own description is a sentence rather than the shell's "name — sentence"
// form, so it is passed separately rather than derived.
func manifestRebrands(slug, description string) []rebrand {
return []rebrand{
{`"name": "verdande"`, `"name": "` + slug + `"`},
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
}
}
// applyRebrands replaces every pair exactly once and fails on the first anchor it
// cannot find, naming it. Failing loudly rather than returning what it managed is
// the point: a partial rename is a page whose tab and whose install prompt disagree
// about which program this is.
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
out := raw
for _, p := range pairs {
old := []byte(p.old)
if bytes.Count(out, old) != 1 {
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
}
out = bytes.Replace(out, old, []byte(p.new), 1)
}
return out, nil
}
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
// frontend and returns them renamed for this edition.
//
// Both or neither: an instance serving a renamed shell beside the sibling's
// manifest installs under the wrong name on a phone, which is the half nobody looks
// at until it is on their home screen.
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
raw, err := fs.ReadFile(web, "index.html")
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
raw, err = fs.ReadFile(web, "manifest.webmanifest")
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
return shell, manifest, nil
}
// The descriptions for the notes edition. Kept beside the replacements they feed
// rather than in the locale files, because these two strings are read by a browser
// and a home screen before any locale has been chosen — the shell is in Danish for
// the same reason the built app.html is.
const (
notesShellDescription = "urd — noter, delt."
notesManifestDescription = "Noter, delt."
)
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
//
// Nil for everything in the full edition, which is what keeps that path exactly as
// it was: one comparison per request against a nil slice.
func (s *Server) renamed(name string) []byte {
switch name {
case "index.html":
return s.shell
case "manifest.webmanifest":
return s.manifest
}
return nil
}
// contentTypeFor names the type for the two assets served from memory.
//
// http.ServeContent sniffs from the extension, and it gets .html right and
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
// is ignored by the browser without an error anywhere: the install prompt simply
// does not appear.
func contentTypeFor(name string) string {
if name == "manifest.webmanifest" {
return "application/manifest+json"
}
return "text/html; charset=utf-8"
}
+235
View File
@@ -0,0 +1,235 @@
package httpapi
import (
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// serverWithWeb builds the real router around a frontend, which newTestServerWith
// cannot do — it passes nil for the web filesystem, because every other test here
// is about the API. The renaming only happens when there is something to rename.
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
Edition: edition,
}
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
}
// The shell as the frontend build produces it, cut down to the parts that matter
// here: the two anchors that get renamed, and the inline theme script that must
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
// honest against the real file.
const builtShell = `<!doctype html>
<html lang="da" data-theme="dark">
<head>
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<title>verdande</title>
<script>
try {
const stored = localStorage.getItem('verdande:theme');
const look = localStorage.getItem('verdande:look');
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
} catch (e) {}
</script>
</head>
<body></body>
</html>
`
const builtManifest = `{
"name": "verdande",
"short_name": "verdande",
"description": "Opgaver og projekter, delt.",
"start_url": "/"
}
`
func builtWeb() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte(builtShell)},
"manifest.webmanifest": {Data: []byte(builtManifest)},
"_app/immutable/app.js": {Data: []byte("// app")},
}
}
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
//
// The product name and the localStorage prefix are the same word in app.html, and
// they are not the same thing: the prefix is read back by the app itself, so
// renaming it here and nowhere else sets the theme from one key and reads it from
// another. The symptom is the white flash on load that the inline script exists to
// prevent — which is to say, no error, nothing in a log, and nothing a test that
// merely checked the title would see.
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
if err != nil {
t.Fatalf("rebrandedAssets: %v", err)
}
got := string(shell)
for _, want := range []string{
"<title>urd</title>",
`content="urd — noter, delt."`,
} {
if !strings.Contains(got, want) {
t.Errorf("the shell does not contain %q", want)
}
}
// Named values rather than "no occurrences of verdande": the three keys are
// what has to survive, so the assertion says the three keys.
for _, want := range []string{
`localStorage.getItem('verdande:theme')`,
`localStorage.getItem('verdande:look')`,
`look !== 'verdande'`,
} {
if !strings.Contains(got, want) {
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
}
}
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
}
gotManifest := string(manifest)
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
if !strings.Contains(gotManifest, want) {
t.Errorf("the manifest does not contain %q", want)
}
}
}
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
// files that is easy to break and impossible to notice: the policy names a hash of
// the inline script, and the renamed shell is what gets served. If a replacement
// ever reached inside the <script>, the hash would change, the browser would refuse
// to run it, and the page would be blank — in production only, because the policy
// is not enforced for a page opened from a file.
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
if err != nil {
t.Fatalf("rebrandedAssets: %v", err)
}
before := scriptHashesIn([]byte(builtShell))
after := scriptHashesIn(shell)
if len(before) != 1 {
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
}
if len(after) != len(before) || after[0] != before[0] {
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
}
}
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
// happy path. A replacement that silently matched nothing leaves an Urd instance
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
// rewrite has to say so rather than return what it managed.
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
cases := map[string]fstest.MapFS{
"no title": {
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no description": {
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no manifest name": {
"index.html": {Data: []byte(builtShell)},
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
},
"no index.html at all": {
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no manifest at all": {
"index.html": {Data: []byte(builtShell)},
},
}
for name, web := range cases {
t.Run(name, func(t *testing.T) {
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
t.Error("renaming reported success over a shell it could not rename")
}
})
}
}
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
// frozen copy of a file that has moved on.
//
// The anchors are exact strings in web/src/app.html, and that file is edited for
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
// title line or rewrites the description breaks the rename and nothing in Go would
// know, because the Go build graph has never heard of app.html.
//
// Note the caching trap that comes with reading a non-Go file from a test: `go
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
// both workflows; a local run needs it typed.
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
raw, err := os.ReadFile("../../web/src/app.html")
if err != nil {
t.Skipf("app.html is not reachable from here: %v", err)
}
src := string(raw)
for _, pair := range shellRebrands("urd", "d") {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
"the notes edition renames the shell by replacing it", n, pair.old)
}
}
// And the keys the rename must not touch, asserted here too: app.html is where
// somebody would rename them, and if they are renamed there they must be
// renamed in stores.svelte.js in the same commit.
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
}
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
}
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
// is about the renamed path; this says the other edition does not go through it.
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
for _, edition := range []string{"full", "notes"} {
t.Run(edition, func(t *testing.T) {
srv := serverWithWeb(t, edition, builtWeb())
if edition == "full" {
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
t.Error("the full edition renamed the shell")
}
return
}
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
t.Fatal("the notes edition did not rename the shell")
}
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
t.Error("the notes edition's shell does not say urd")
}
if srv.renamed("_app/immutable/app.js") != nil {
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
}
})
}
}
+35
View File
@@ -501,6 +501,18 @@ type quickAddRequest struct {
// the foot of one. Same rule as the project: a "/section" in the text wins,
// because typing it is a choice and standing there is a circumstance.
SectionID string `json:"section_id,omitempty"`
// ParentID makes the new task a sub-task of an existing one. It is what a
// pasted list uses for its indented lines, and what the sub-task field at the
// foot of a task uses — both want the same parsing of dates, #project and
// @labels that a top-level task gets, so they come through here rather than
// through a second, dumber path.
//
// A sub-task lives in its parent's project. Saying otherwise would describe a
// task whose parent is somewhere else, which the list views cannot draw and
// nobody means; a "#project" on an indented line is therefore not honoured,
// and the caller is told so the word does not just vanish.
ParentID string `json:"parent_id,omitempty"`
}
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
@@ -517,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
return
}
// A parent decides the project before anything else is read: a sub-task
// somewhere other than its parent is not a shape this program has.
parent := (*store.Task)(nil)
if req.ParentID != "" {
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
parent = found
}
projectID := req.ProjectID
// Set when the text named a project that does not exist. The task is still
// created — see below — but the caller is told, because the alternative is
@@ -532,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
unknownProject = parsed.Project
}
}
// The parent has the last word, and the word it overrules is reported the same
// way an unknown project is — the task is created either way.
if parent != nil {
if parsed.Project != "" && projectID != parent.ProjectID {
unknownProject = parsed.Project
}
projectID = parent.ProjectID
}
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
@@ -570,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence,
}
if parent != nil {
t.ParentID = parent.ID
}
if parsed.DueDate != "" {
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
if err == nil {
+173
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
}
// The whole point of import and export together: what goes in comes back out.
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
//
// The line still goes through the same parser as a top-level one — a sub-task
// with a date is an ordinary thing to paste, and a second, dumber creation path
// for indented lines would be a second place for "i morgen" to mean something
// different.
func TestQuickAddSubTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Flytte garagen",
})
parentID, _ := parent["id"].(string)
if parentID == "" {
t.Fatalf("no parent: %v", parent)
}
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Rydde hylderne p1", "parent_id": parentID,
})
if got := child["parent_id"]; got != parentID {
t.Errorf("parent_id = %v, want %v", got, parentID)
}
// The line was parsed, not stored raw: the priority marker is gone from the
// text and present as a priority.
if got := child["content"]; got != "Rydde hylderne" {
t.Errorf("content = %v, want the line without its marker", got)
}
if got := child["priority"]; got != float64(1) {
t.Errorf("priority = %v, want 1", got)
}
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
otherID, _ := other["id"].(string)
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
})
if got := moved["project_id"]; got == otherID {
t.Errorf("the sub-task left its parent's project")
}
// And it is said out loud rather than the word just disappearing from the
// title — the failure this reports is exactly the one that used to be silent.
if got := moved["unknown_project"]; got != "Andetsteds" {
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
}
})
}
// raw fetches a download — a zip or a calendar — as bytes.
//
// `do` decodes JSON, which these two are not: it would report an empty body and a
// parse error where the interesting thing is the bytes themselves.
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
t.Helper()
req, _ := http.NewRequest("GET", ts.URL+path, nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: status %d", path, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return body, resp.Header.Get("Content-Type")
}
// TestExportEverythingAtOnce covers the two whole-account exports.
//
// The per-project links worked and were unusable at any size: fifteen projects
// meant fifteen downloads to move your own tasks somewhere else. These two are
// the same data without the fifteen clicks.
//
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
// exactly the shape an export bug takes.
func TestExportEverythingAtOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Two projects, so the zip has to hold more than the one that happens to be
// first — a loop that exports only the last project passes a one-project test.
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
firstID, _ := first["id"].(string)
secondID, _ := second["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Skifte daek", "project_id": secondID,
})
t.Run("csv zip holds one file per project", func(t *testing.T) {
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
if ct != "application/zip" {
t.Fatalf("content-type %q", ct)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("not a zip: %v", err)
}
found := map[string]string{}
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
text, _ := io.ReadAll(rc)
rc.Close()
found[f.Name] = string(text)
}
if len(found) < 2 {
t.Fatalf("want a file per project, got %d: %v", len(found), found)
}
// The task has to be IN the file named after its own project. A zip with the
// right filenames and every task in the wrong one is the failure this is for.
var sawMoms, sawDaek bool
for name, text := range found {
// Filenames are lower-cased by ics.Filename, so the comparison is too —
// asserting on the stored capitalisation would be asserting on a detail
// the exporter is free to change.
lower := strings.ToLower(name)
if strings.Contains(text, "Betal moms") {
sawMoms = true
if !strings.Contains(lower, "firma") {
t.Errorf("Betal moms landed in %q, not in Firma", name)
}
}
if strings.Contains(text, "Skifte daek") {
sawDaek = true
if !strings.Contains(lower, "hjem") {
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
}
}
}
if !sawMoms || !sawDaek {
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
}
})
t.Run("ics holds every dated task across projects", func(t *testing.T) {
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
if !strings.HasPrefix(ct, "text/calendar") {
t.Fatalf("content-type %q", ct)
}
text := string(body)
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
}
if !strings.Contains(text, "Betal moms") {
t.Errorf("the dated task is missing:\n%s", text)
}
// Undated tasks are deliberately left out — a VTODO with no date is a line
// most readers drop. Asserted so the omission stays a decision.
if strings.Contains(text, "Skifte daek") {
t.Errorf("an undated task was exported:\n%s", text)
}
})
}
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
+16 -7
View File
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
// ind til den halvdel, resten af programmet lige har lukket.
var tasks []store.Task
var err error
if !s.cfg.NotesOnly() {
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
}
if err != nil {
s.internal(w, r, "search tasks", err)
return
+1 -1
View File
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
t.Cleanup(func() { db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
}
func seedUser(t *testing.T, db *store.DB) *store.User {
+19 -13
View File
@@ -24,13 +24,19 @@ import (
type Sender struct {
cfg config.SMTP
log *slog.Logger
// appName and baseURL appear in the body of every message.
// appName and baseURL appear in every message. appName is the edition's own
// name — an invite from an urd instance that said "verdande" would be a letter
// to somebody else's inbox about a program they are not being invited to.
//
// It was a field with a value and no reader until 2026-10-07: the three places
// that needed it had the word written out instead, and the comment above said
// it appeared in every message. It does now.
appName string
baseURL string
}
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
}
// Configured reports whether mail can actually be sent. Handlers use it to decide
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
what := "verdande"
what := s.appName
if projectName != "" {
what = "projektet “" + projectName + "”"
}
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
@@ -71,9 +77,9 @@ Opret din konto her:
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
— verdande
— %s
%s
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
%s
— verdande
`, name, task, link)
— %s
`, name, task, link, s.appName)
return s.send(ctx, to, subject, body)
}
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
subject := "Nulstil din adgangskode i verdande"
subject := "Nulstil din adgangskode i " + s.appName
body := fmt.Sprintf(`Hej %s
Der er bedt om at nulstille adgangskoden til din verdande-konto.
Der er bedt om at nulstille adgangskoden til din %s-konto.
Vælg en ny adgangskode her:
%s
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
— verdande
— %s
%s
`, name, link, humanDuration(ttl), s.baseURL)
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
+76
View File
@@ -0,0 +1,76 @@
package mail
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// TestEveryMessageCarriesTheEditionsOwnName.
//
// These three letters are the only thing this program sends to an address its
// operator does not control, and an invitation from an urd instance that says
// "verdande" is a letter to a stranger about a program they are not being invited
// to. The name was a field with a value and no reader until 2026-10-07 — the three
// places that needed it had the word written out instead — so the assertion that
// matters is not "it says urd" but "it does not say the other one".
//
// Measured through the unconfigured path on purpose: with no SMTP host, send()
// logs the whole message at warn level and returns, so the subject and the body
// can be read as the recipient would get them without a mail server in the test.
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
for _, tc := range []struct{ name, other string }{
{"verdande", "urd"},
{"urd", "verdande"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", tc.name,
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
ctx := context.Background()
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
t.Fatalf("SendPasswordReset: %v", err)
}
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
t.Fatalf("SendReminder: %v", err)
}
got := buf.String()
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
}
if !strings.Contains(got, tc.name) {
t.Errorf("no message mentions %q", tc.name)
}
if strings.Contains(got, tc.other) {
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
}
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
})
}
}
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
// *subject* of the sentence rather than a signature: an invitation to the instance
// itself, with no project, reads "has invited you to <program>".
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", "urd",
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if !strings.Contains(buf.String(), "inviteret dig til urd") {
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
}
}
+1
View File
@@ -44,6 +44,7 @@ extra_css:
nav:
- Home: index.md
- Setting it up:
- Two editions: editions.md
- As a Rune: rune.md
- With Docker: self-hosting.md
- Configuration: configuration.md
+181
View File
@@ -0,0 +1,181 @@
import { test, expect } from '@playwright/test';
/**
* Noter-udgaven, kørt som sit eget program.
*
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
* opsætning lavede, så den laver sin egen.
*
* Og det er hele grunden til at prøven findes som en separat server frem for som
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
* bruger møder det.
*/
const KONTO = {
email: 'noter@example.dk',
name: 'Noteskriver',
password: 'et langt kodeord til test'
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
// så den anden gang er det en almindelig login.
const opret = page.getByRole('button', { name: 'Opret konto' });
const login = page.getByRole('button', { name: 'Log ind' });
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
// tredive sekunder på en knap, der aldrig kom.
await expect(opret.or(login)).toBeVisible();
if (await opret.isVisible()) {
await page.getByLabel(/Navn/).fill(KONTO.name);
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await opret.click();
// Beaconens besked står øverst første gang; den er ikke det her prøvens
// ærinde, men den dækker for det, der er.
const besked = page.getByRole('region', { name: /melder, at den findes/ });
if (await besked.isVisible().catch(() => false)) {
await besked.getByRole('button', { name: 'Behold den' }).click();
}
} else {
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await page.getByRole('button', { name: 'Log ind' }).click();
}
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
});
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
// det at vide, før nogen er logget ind.
const svar = await page.request.get('/api/v1/auth/setup');
expect((await svar.json()).edition).toBe('notes');
});
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
await expect(menu).toBeVisible();
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
}
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
});
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/noter$/);
});
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
page
}) => {
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
// andet program end det, der kører her.
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
await page.goto(sti);
await expect(page).toHaveURL(/\/noter$/);
}
});
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type('Kvartalsregnskabet');
await felt.blur();
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
await page.getByRole('button', { name: /Søg/ }).click();
const palette = page.getByRole('dialog');
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
// Noten findes — ellers beviser det næste ingenting.
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
// Og søgningen på serveren svarer uden opgaver overhovedet.
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
expect((await svar.json()).tasks ?? []).toEqual([]);
});
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
// Det her er forskellen på skjult og fraværende, målt udefra.
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
const svar = await page.request.get(sti);
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
}
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
// ovenstående bestå på en server, der var helt væk.
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
});
/**
* Navnet.
*
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
*/
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
// som browseren læser før noget JavaScript kører og som serveren skriver om,
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
// over en fane, der sagde "Noter · verdande".
const skal = await (await page.request.get('/')).text();
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
await expect(mærke.locator('.name')).toHaveText('urd');
// ᚢ uruz, det bogstav navnet begynder med i den ældre futhark — og ikke ᚹ
// wunjo, som er Verdandes. Begge er navngivne værdier frem for "der er en
// rune": en tom span og den forkerte rune ser ens ud for en prøve, der kun
// spørger om der står noget.
await expect(mærke.locator('.rune')).toHaveText('ᚢ');
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('urd');
expect(manifest.short_name).toBe('urd');
});
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
// Symptomet er at installationsknappen bare ikke er der.
const svar = await page.request.get('/manifest.webmanifest');
expect(svar.headers()['content-type']).toContain('application/manifest+json');
});
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
// i app.html, og de er ikke samme ting. Omdøbes præfikset her og ikke i
// resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det
// eneste symptom er det hvide blink, det indlejrede script findes for at
// undgå. Målt på de bytes der blev sendt, ikke på kilden.
const skal = await (await page.request.get('/')).text();
expect(skal).toContain("localStorage.getItem('verdande:theme')");
expect(skal).toContain("localStorage.getItem('verdande:look')");
expect(skal).not.toContain('urd:theme');
});
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
// skifter navn med udgaven.
await page.goto('/indstillinger');
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import { readList } from '../src/lib/pastelist.js';
/**
* Læsningen af en indsat liste, prøvet for sig.
*
* Reglerne er små og mange, og de er billigere at stille her end gennem en
* browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
* gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
* sammen, ikke at hvert tilfælde er rigtigt.
*/
test.describe('en indsat liste læses som opgaver og underopgaver', () => {
test('mærker fjernes, teksten bliver', () => {
expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
{ depth: 0, text: 'køb mælk', done: false },
{ depth: 0, text: 'ring til Per', done: false },
{ depth: 0, text: 'hent pakken', done: false }
]);
});
test('nummererede lister er også lister', () => {
expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
{ depth: 0, text: 'først', done: false },
{ depth: 0, text: 'så', done: false },
{ depth: 0, text: 'til sidst', done: false }
]);
});
test('en afkrydsning er indhold, ikke notation', () => {
// Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
// kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
{ depth: 0, text: 'betalt', done: true },
{ depth: 0, text: 'ikke betalt', done: false },
{ depth: 0, text: 'også betalt', done: true }
]);
});
test('indrykning bærer niveauet, uanset hvor bred den er', () => {
// To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
// alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
const to = readList('a\n b\nc');
const fire = readList('a\n b\nc');
const tab = readList('a\n\tb\nc');
const want = [
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 0, text: 'c', done: false }
];
expect(to).toEqual(want);
expect(fire).toEqual(want);
expect(tab).toEqual(want);
});
test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
// Kopierer man fra et dokument, følger der ofte indrykning med på alle
// linjer. Den er ikke et niveau — der er ikke noget at være under.
expect(readList(' a\n b')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false }
]);
});
test('tomme linjer er luft i det kopierede, ikke punkter', () => {
// Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
// liste.
expect(readList('a\n\n b\n\n c')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 1, text: 'c', done: false }
]);
});
test('teksten røres ikke ud over mærket', () => {
// Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
// linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
// mange.
expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
{ depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
{ depth: 0, text: 'andet', done: false }
]);
});
});
+573 -14
View File
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
await item.click();
}
/**
* Venter på, at en gemning faktisk er nået frem til serveren.
*
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
* og fejler på noget, der ikke er i stykker.
*
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
*
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
*
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
* allerede kommet og gået.
*/
function gemt(side, sti) {
return side.waitForResponse(
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
);
}
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
/**
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
*
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
* findes den titel, prøven leder efter, slet ikke.
*
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
*
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
*/
async function nyNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
const flyttet = projektGemt(page);
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await flyttet;
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
const about = page.getByLabel('Om gruppen');
await expect(about).toBeVisible();
await about.fill('Alt der larmer og støver.');
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
// der ikke er i stykker. Svaret er det, der kan ventes på.
const gemt = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' &&
new URL(r.url()).pathname.includes('/project-groups/') &&
r.ok()
);
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await gemt;
// And it survives a reload, which is the difference between the page keeping up
// and the server taking it.
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
const rundtur = noteGemt(page);
await page_.blur();
await rundtur;
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
const gemt = noteGemt(gæst);
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gemt;
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
// på den er grøn med det samme og venter på ingenting.
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await felt.blur();
const titel = text.split('\n')[0];
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
// gik i begge:
//
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
// delstreng og uden hensyn til store og små bogstaver — den fandt også
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
// af hele suiten.
//
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
// tekst med den titel i, så et opslag på teksten fandt tre.
//
// `button.row strong` er rækkens titelelement, og en forankret regex er både
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
await expect(
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
).toHaveCount(1);
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
const kildeGemt = noteGemt(page);
await body.blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await kildeGemt;
await page.getByRole('button', { name: /Vis kilden/ }).click();
await expect(page.locator('textarea.source')).toHaveValue(/`/);
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
const linketGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(900);
await linketGemt;
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
const ed = await nyNote(page);
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(1200);
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await page.waitForTimeout(400);
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
@@ -4694,6 +4797,24 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
// tilbage til den første. Man kunne søge og ikke vælge.
await list.getByText('Fyrreskov syd').click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
// kunne så ikke komme tilbage til den note, man kom fra.
await page.getByRole('button', { name: /Søg/ }).click();
await palette.getByRole('textbox').fill('Fyrreskov nord');
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
@@ -4782,6 +4903,412 @@ test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
});
/**
* Klokkens panel bliver inden for skærmen på en telefon.
*
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
* kant end det — der er en temaknap og en kantafstand til højre for den — så
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
* den.
*
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
* det stikke ud til højre, er ikke en rettelse.
*/
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByRole('button', { name: /^Beskeder/ }).click();
const panel = page.getByRole('dialog', { name: 'Beskeder' });
await expect(panel).toBeVisible();
const kasse = await panel.boundingBox();
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
// ud på telefonen.
const overskrift = panel.getByText('Beskeder', { exact: true });
const tekst = await overskrift.boundingBox();
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
expect(trouble).toEqual([]);
});
/**
* En punktliste kan have niveauer, og de overlever gemningen.
*
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
*
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
*
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
*/
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Indkøb');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
await page.keyboard.type('Frugt');
await page.keyboard.press('Enter');
await page.keyboard.type('Aebler');
await page.keyboard.press('Tab');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Niveauet er der med det samme: en liste inde i en liste.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Indkøb/ }).click();
// Og efter turen gennem Markdown står det der stadig.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
await ed.locator('ul ul li').click();
await page.keyboard.press('Shift+Tab');
await expect(ed.locator('ul ul li')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt.
*
* Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i
* den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien
* brugte `textContent` — hvor et <br> er den tomme streng. Så en blok skrevet med
* shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På
* skærmen, ikke kun i filen, og uden at man havde rørt teksten.
*
* Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme
* svar". Der var to, og de var uenige om netop det tegn.
*/
test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Farvelagt');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('echo en');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('echo to');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Ud af blokken, så farvelægningen tager fat i den.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('End');
await ed.getByText('Farvelagt', { exact: true }).click();
// Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så
// den skal ud, før teksten læses — præcis det, koden selv gør.
const tekst = await ed.locator('pre').evaluate((el) => {
const kopi = el.cloneNode(true);
kopi.querySelectorAll('.copy').forEach((b) => b.remove());
return kopi.innerText.trim();
});
expect(tekst).toBe('echo en\necho to');
expect(trouble).toEqual([]);
});
/**
* ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
*
* Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
* feltet". Det passer for et <input>, hvor ⌘B ikke gør noget. I en contenteditable
* ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
* noterne foldede menuen sig, hver gang man ville fremhæve et ord.
*
* Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
* halvt færdig: ordet skal også blive fedt.
*/
test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const foldet = () => page.locator('.shell.sidebar-collapsed').count();
expect(await foldet()).toBe(0);
await nyNote(page);
await page.keyboard.type('Fremhævet');
await page.keyboard.press('Enter');
await page.keyboard.type('ordet');
await page.keyboard.press('ControlOrMeta+a');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await page.keyboard.press('ControlOrMeta+b');
// Enten <b> eller <strong>: browserens egen fed er <b>, omformeren skriver
// <strong>, og begge læses som fed på vejen tilbage.
await expect(ed.locator('b, strong')).toContainText('ordet');
expect(await foldet()).toBe(0);
// Og uden for et skrivefelt gør den stadig det, den er sat til.
await page.getByRole('heading', { name: 'Noter' }).click();
await page.keyboard.press('ControlOrMeta+b');
expect(await foldet()).toBe(1);
await page.keyboard.press('ControlOrMeta+b');
expect(trouble).toEqual([]);
});
/**
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
*
* Monotype går uden om `execCommand`, fordi formatBlock pakker et <p> ind i
* <pre>'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
* altså det ene blok markøren står i — så af en markering over ti linjers
* terminaludskrift blev den første linje til kode og resten stod tilbage som
* brødtekst.
*
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
* hegn i filen.
*/
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Udskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('linje et');
await page.keyboard.press('Enter');
await page.keyboard.type('linje to');
await page.keyboard.press('Enter');
await page.keyboard.type('linje tre');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('p')).toHaveCount(3);
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
// af den første til enden af den sidste.
await ed.getByText('linje et', { exact: true }).click();
await page.keyboard.press('Home');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+End');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
await expect(ed.locator('p')).toHaveCount(0);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Udskrift/ }).click();
await expect(ed.locator('pre')).toHaveCount(1);
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
'linje et\nlinje to\nlinje tre'
);
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
*
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
* det er præcis den form, en fejl i en eksport har.
*/
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
await page.goto('/indstillinger/data');
const hent = async (navn) => {
const venter = page.waitForEvent('download');
await page.getByRole('link', { name: navn }).click();
const fil = await venter;
const sti = await fil.path();
return { sti, navn: fil.suggestedFilename() };
};
const zip = await hent('Alle opgaver (CSV)');
expect(zip.navn).toMatch(/\.zip$/);
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
expect(size).toBeGreaterThan(100);
const kal = await hent('Alle opgaver (kalender)');
expect(kal.navn).toMatch(/\.ics$/);
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
expect(tekst).toContain('BEGIN:VTODO');
expect(tekst).toContain('Betal moms');
expect(trouble).toEqual([]);
});
/**
* En indsat liste bliver til opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen.
*
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
* billigere at stille.
*/
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
'\n'
);
await page.getByLabel('Ny opgave').click();
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('[data-quickadd]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, liste);
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
// man skal opdage bagefter.
const visning = page.getByRole('group', { name: 'Indsat liste' });
await expect(visning.locator('li')).toHaveCount(4);
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
await expect(visning).toHaveCount(0);
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
// Og de indrykkede hænger under den linje, de stod under — ikke under den
// første, den bedste. Det ses i opgavens egen rude.
await page.getByText('Flytte garagen', { exact: true }).click();
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
// er det, serveren har gemt, stregen er, hvordan den tegnes.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
// undertasks under den — uden at nogen skal vælge noget.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('input[aria-label="Ny undertask"]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, '- Købe maling\n- Låne stige');
const flere = page.getByRole('group', { name: 'Indsat liste' });
await expect(flere.locator('li')).toHaveCount(2);
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
// To mere under den samme opgave: fire undertasks, én af dem lukket.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
@@ -4937,7 +5464,7 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
@@ -4982,3 +5509,35 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
/**
* The control for the notes edition's renaming, and it belongs here rather than
* beside it: the whole point is that the full edition does NOT go through that
* path, and that can only be measured on an instance which is not the notes one.
*
* A gate asserted in one direction is half a gate. edition.spec.js proves the
* notes edition calls itself urd; without this, a rename that fired in both
* editions would pass there and change the name of this program.
*/
test('the full edition still calls itself verdande', async ({ page }) => {
await page.goto('/');
// Both titles, for the same reason edition.spec.js checks both: the shell's,
// which the browser reads before any script runs, and the route's own, set in
// <svelte:head> once the page draws. Today has no title of its own, so the
// shell's is what stands here — and /noter has one, so it is asked separately.
const shell = await (await page.request.get('/')).text();
expect(shell).toContain('<title>verdande</title>');
await expect(page).toHaveTitle('verdande');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ᚢ uruz.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
await expect(page).toHaveTitle(/· verdande$/);
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
});
+83 -36
View File
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const dataDir = join(here, '.playwright-data');
const notesDataDir = join(here, '.playwright-data-notes');
// A clean instance every run: the first test creates the first account, which only
// works on an empty database.
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
if (process.env.TEST_WORKER_INDEX === undefined) {
rmSync(dataDir, { recursive: true, force: true });
rmSync(notesDataDir, { recursive: true, force: true });
}
const PORT = 8097;
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
const NOTES_PORT = 8098;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
@@ -72,6 +78,10 @@ export default defineConfig({
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
testIgnore: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
@@ -95,6 +105,21 @@ export default defineConfig({
*
* WebKit here is the same engine Safari is, which is the whole point.
*/
/**
* Noter-udgaven, som sit eget program.
*
* Egen server, egen port, egen database, og ingen `storageState`: den her
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
* at låne dens cookie ville prøve to instanser som var de én.
*
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
*/
{
name: 'notes-edition',
testMatch: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
},
{
name: 'webkit-forms',
grep: /@forms/,
@@ -103,42 +128,64 @@ export default defineConfig({
}
],
webServer: {
// The whole chain, every run.
//
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
webServer: [
{
// The whole chain, every run.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
}
},
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
// det her koster en opstart og ikke en oversættelse.
{
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
cwd: here,
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${NOTES_PORT}`,
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
VERDANDE_DATA_DIR: notesDataDir,
VERDANDE_EDITION: 'notes',
VERDANDE_UPDATE_CHECK: 'false'
}
}
}
]
});
+9 -2
View File
@@ -365,8 +365,13 @@ export const api = {
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAdd: (text, projectId, sectionId, parentId) =>
post('/tasks/quick-add', {
text,
project_id: projectId,
section_id: sectionId,
parent_id: parentId
}),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
@@ -511,6 +516,8 @@ export const api = {
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
exportAllCSVURL: () => '/api/v1/export/projects.zip',
exportAllICSURL: () => '/api/v1/export/tasks.ics',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
+1 -1
View File
@@ -64,7 +64,7 @@
{#if notice}
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
<h2>{t('beacon.noticeTitle')}</h2>
<p>{t('beacon.noticeBody')}</p>
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
+5 -1
View File
@@ -85,7 +85,11 @@
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
// skal ⌘K ikke være stedet, det viser sig.
...(app.notesOnly ? [] : tasks).map((t) => ({
kind: 'task',
id: t.id,
label: t.content,
+1 -1
View File
@@ -38,7 +38,7 @@
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
+106 -21
View File
@@ -13,7 +13,7 @@
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
@@ -133,20 +133,78 @@
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
// out of the block from the menu. Do any conversion that touches a <pre> by
// hand, replacing the element rather than reformatting inside it.
const block = currentBlock();
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
replaceBlock(block, style.tag);
//
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
const blocks = selectedBlocks();
if (!blocks.length) return apply('formatBlock', style.tag);
if (style.tag === 'pre') {
mergeToMono(blocks);
return;
}
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
const pres = blocks.filter((b) => b.tagName === 'PRE');
for (const b of pres) swapTag(b, style.tag);
if (pres.length === blocks.length) {
readState();
changed();
return;
}
apply('formatBlock', style.tag);
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
*
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
* som før.
*/
function replaceBlock(block, tag) {
function selectedBlocks() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount || !editor) return [];
const range = selection.getRangeAt(0);
return [...editor.children].filter((b) => range.intersectsNode(b));
}
/**
* Lægger flere blokke sammen til én monotypeblok.
*
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
* de er.
*/
function mergeToMono(blocks) {
const pre = document.createElement('pre');
// blockText og ikke textContent: et <br> har den tomme streng som
// textContent, og det er netop <br>, der står for et linjeskift inde i en
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
if (!pre.textContent) pre.appendChild(document.createElement('br'));
blocks[0].replaceWith(pre);
for (const b of blocks.slice(1)) b.remove();
const range = document.createRange();
range.selectNodeContents(pre);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
function swapTag(block, tag) {
// The copy button lives inside the <pre> so it can sit in its corner; it is
// not text and must not travel into a paragraph. addCopyButtons puts it back
// on the next render for any <pre> that still needs one.
@@ -156,6 +214,16 @@
while (block.firstChild) el.appendChild(block.firstChild);
if (!el.firstChild) el.appendChild(document.createElement('br'));
block.replaceWith(el);
return el;
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
*/
function replaceBlock(block, tag) {
const el = swapTag(block, tag);
const range = document.createRange();
range.selectNodeContents(el);
@@ -380,7 +448,7 @@
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeTextOf(pre);
const text = blockText(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
@@ -394,19 +462,16 @@
}
/**
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
*
* É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.
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
* væk fra den. På skærmen, ikke kun i filen.
*/
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;
@@ -419,7 +484,7 @@
// 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 code = blockText(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -788,6 +853,17 @@
});
}
/**
* Står markøren i en punktliste eller en nummereret liste?
*
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
* ikke blive uenige om, hvad der er en liste.
*/
const inList = () =>
document.queryCommandState('insertUnorderedList') ||
document.queryCommandState('insertOrderedList');
function onkeydown(event) {
// The suggestion list owns the arrows and return while it is open, the way
// every other completion does. Escape closes it without choosing.
@@ -816,6 +892,15 @@
}
}
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
if (event.key === 'Tab' && inList()) {
event.preventDefault();
apply(event.shiftKey ? 'outdent' : 'indent');
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
// A code block is the one place the browser keeps the caret inside on
// Enter rather than starting a new block. Step out of it ourselves, or a
+43 -2
View File
@@ -20,6 +20,31 @@
let open = $state(false);
let wrap = $state(null);
/**
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
*
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
* klippet. Der stod tre en halv linje tekst uden for skærmen.
*
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
* kantafstand til højre for klokken — men så ville den her fil kende sine
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
* skærmen med en kant i hver side.
*/
let below = $state(0);
function place() {
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
}
function toggle() {
open = !open;
if (open) place();
}
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
@@ -72,6 +97,7 @@
<svelte:window
onclick={outside}
onresize={() => open && place()}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
@@ -81,7 +107,7 @@
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
onclick={toggle}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
@@ -101,7 +127,7 @@
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
@@ -193,6 +219,21 @@
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
toplinjens egen menuknap, så de to skifter form samtidig. */
@media (max-width: 820px) {
.panel {
position: fixed;
top: calc(var(--below) + 6px);
left: var(--s3);
right: var(--s3);
width: auto;
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
overscroll-behavior: contain;
}
}
.panel header {
display: flex;
align-items: center;
+126 -1
View File
@@ -15,6 +15,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -97,6 +98,60 @@
return out;
});
/**
* En indsat liste bliver til opgaver frem for til én opgave med det hele i.
*
* Browseren smider linjeskiftene væk, når man indsætter i et `<input>`, så uden
* det her blev en kopieret liste til én opgave med punkterne mast sammen. En
* enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
* som altid.
*
* Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
* opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
* stillet mens der stadig er en Fortryd-knap.
*/
let pending = $state([]);
function onpaste(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pending = rows;
}
async function createPending() {
if (submitting) return;
submitting = true;
// Den seneste opgave på hvert niveau, så en indrykket linje havner under den
// linje, den stod under — og ikke under den første, den bedste.
const parents = [];
let made = 0;
for (const row of pending) {
const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
const task = await app.quickAdd(row.text, projectId, undefined, parentId);
// Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
// resten af listen ind uden den, de hang under, og det er værre end at
// standse et sted, man kan se.
if (!task) break;
parents[row.depth] = task.id;
parents.length = row.depth + 1;
if (row.done) await app.complete(task.id);
onadded?.(task);
made++;
}
submitting = false;
// Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
// den kom og prøve resten igen.
pending = pending.slice(made);
input?.focus();
}
async function submit(event) {
event?.preventDefault();
const value = text.trim();
@@ -164,13 +219,32 @@
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
{onpaste}
/>
</div>
{#if text.trim()}
{#if text.trim() && !pending.length}
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
{#if pending.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pending as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="actions">
<button type="button" class="submit" disabled={submitting} onclick={createPending}>
{t('task.pastedAdd', { n: String(pending.length) })}
</button>
<button type="button" class="cancel" onclick={() => (pending = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
<!-- What the parser understands, spelled out while somebody is typing into it.
It was in the placeholder before, which is the one place a hint cannot be:
the placeholder disappears at the first keystroke, so the help was gone
@@ -190,6 +264,57 @@
</form>
<style>
/* Den indsatte liste, før den bliver til opgaver. Ligger i flowet og ikke
svævende: den skal kunne klikkes på, og den er noget, man læser færdig frem
for et vink, der forsvinder. */
.pasted {
grid-column: 1 / -1;
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.pasted li {
/* Indrykningen er hele pointen med visningen: den viser, hvad der bliver en
underopgave, mens der stadig er en vej tilbage. */
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
color: var(--ink);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted .actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.cancel {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.cancel:hover {
text-decoration: underline;
}
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
.syntax {
/* Nothing to click, and it floats over the first task in the list — which it
+14 -9
View File
@@ -155,9 +155,12 @@
// entry is a question the sidebar keeps asking and answering "no".
let navItems = $derived(
[
{ key: 'today', href: '/', label: 'nav.today' },
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
app.projects.some((p) => p.shared)
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
!app.notesOnly && app.projects.some((p) => p.shared)
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
: null,
// No href: the inbox is a project and is drawn by projectRow, which knows
@@ -185,7 +188,7 @@
// 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' }
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
].filter(Boolean)
);
@@ -472,11 +475,13 @@
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
starts with in the elder futhark. One glyph, no wordmark beside it —
the name is in the tab and everywhere else already. -->
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="name">verdande</span>
<!-- The mark: one rune, the letter the Norn's name starts with in the elder
futhark — Wunjo for Verdande, Uruz for Urd. Both come from the store
rather than from here, because the two editions of this program are two
products with two names, and a wordmark is the one place a reader
decides which one they are looking at. -->
<span class="rune" aria-hidden="true">{app.productRune}</span>
<span class="name">{app.productSlug}</span>
</div>
<div class="views">
+130 -1
View File
@@ -15,6 +15,7 @@
import { focusOnMount } from '$lib/focus.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
import { readList } from '$lib/pastelist.js';
let { task, onclose } = $props();
@@ -282,6 +283,73 @@
// --- sub-tasks ----------------------------------------------------------------
/**
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
*
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
* punkter under punkter kommer ind som den stod.
*
* Som i quick-add vises den, før den oprettes — og af samme grund.
*/
let pastedSubtasks = $state([]);
function onPasteSubtasks(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pastedSubtasks = rows;
}
async function createPastedSubtasks() {
if (addingPasted) return;
addingPasted = true;
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
// seneste linje ovenfor, som i quick-add.
const parents = [task.id];
let made = 0;
for (const row of pastedSubtasks) {
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
if (!created) break;
parents[row.depth + 1] = created.id;
parents.length = row.depth + 2;
if (row.done) await app.complete(created.id);
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
// resten hører under deres egen.
if (row.depth === 0) subtasks = [...subtasks, created];
made++;
}
addingPasted = false;
pastedSubtasks = pastedSubtasks.slice(made);
countSubtasks();
}
let addingPasted = $state(false);
/**
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
*
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
*
* Samme regel som resten af butikken: en lokal handling slår igennem med det
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
*/
function countSubtasks() {
app.upsert({
...task,
subtask_count: subtasks.length,
subtask_done: subtasks.filter((s) => s.completed).length
});
}
async function addSubtask(event) {
event.preventDefault();
const text = newSubtask.trim();
@@ -294,6 +362,7 @@
parent_id: task.id
});
subtasks = [...subtasks, created];
countSubtasks();
} catch (e) {
app.toast(humanMessage(e));
}
@@ -635,8 +704,31 @@
{/each}
<form onsubmit={addSubtask}>
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
<input
bind:value={newSubtask}
onpaste={onPasteSubtasks}
placeholder={t('detail.addSubtask')}
aria-label={t('detail.newSubtask')}
/>
</form>
{#if pastedSubtasks.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pastedSubtasks as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="pasted-actions">
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
</button>
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
</section>
<section>
@@ -741,6 +833,43 @@
</aside>
<style>
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
.pasted {
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 30vh;
overflow-y: auto;
}
.pasted li {
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted-actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.pasted-actions .plain {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.linked-note {
display: block;
padding: var(--s2);
+8 -3
View File
@@ -145,7 +145,7 @@ export const da = {
// læserens mulighed for at efterprøve det.
'beacon.noticeTitle': 'Denne installation melder, at den findes',
'beacon.noticeBody':
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'beacon.noticeTo': 'Sendes til {url}',
'beacon.noticeKeep': 'Behold den',
'beacon.noticeStop': 'Slå den fra',
@@ -238,7 +238,7 @@ export const da = {
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'net.downTitle': 'Ingen forbindelse',
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
@@ -263,6 +263,9 @@ export const da = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.pastedTitle': 'Indsat liste',
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
@@ -350,7 +353,7 @@ export const da = {
'size.default': 'Standard',
'size.large': 'Stor',
'size.xl': 'Størst',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
@@ -611,6 +614,8 @@ export const da = {
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
'data.notesImported': '{n} noter og {files} filer hentet ind.',
'data.exportNotes': 'Alle noter som Markdown-filer',
'data.exportAllCSV': 'Alle opgaver (CSV)',
'data.exportAllICS': 'Alle opgaver (kalender)',
'data.exportAccount': 'Hele kontoen som JSON',
'data.exportOne': 'Ét projekt',
'data.pickProject': 'Vælg et projekt',
+8 -3
View File
@@ -142,7 +142,7 @@ export const en = {
// the reader's ability to check it.
'beacon.noticeTitle': 'This installation reports that it exists',
'beacon.noticeBody':
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'beacon.noticeTo': 'Sent to {url}',
'beacon.noticeKeep': 'Keep it on',
'beacon.noticeStop': 'Turn it off',
@@ -235,7 +235,7 @@ export const en = {
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'net.downTitle': 'No connection',
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
@@ -260,6 +260,9 @@ export const en = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.pastedTitle': 'Pasted list',
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
@@ -347,7 +350,7 @@ export const en = {
'size.default': 'Default',
'size.large': 'Large',
'size.xl': 'Largest',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
@@ -607,6 +610,8 @@ export const en = {
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
'data.notesImported': '{n} notes and {files} files brought in.',
'data.exportNotes': 'Every note as Markdown files',
'data.exportAllCSV': 'All tasks (CSV)',
'data.exportAllICS': 'All tasks (calendar)',
'data.exportAccount': 'The whole account as JSON',
'data.exportOne': 'One project',
'data.pickProject': 'Pick a project',
+64
View File
@@ -0,0 +1,64 @@
/**
* En indsat liste, læst som opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
*
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
* faktisk er set, klarer begge uden at kende tallet på forhånd.
*
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
* uløste opgaver, har tabt det, man indsatte den for.
*
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
* forstå en opgavelinje, ikke to.
*/
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
const BOX = /^\[([ xX])\]\s*/;
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
function width(indent) {
return indent.replace(/\t/g, ' ').length;
}
export function readList(text) {
const out = [];
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
const levels = [];
for (const raw of (text ?? '').split(/\r?\n/)) {
const indent = /^[ \t]*/.exec(raw)[0];
let rest = raw.slice(indent.length).trim();
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
if (!rest) continue;
rest = rest.replace(MARKER, '');
let done = false;
const box = BOX.exec(rest);
if (box) {
done = box[1] !== ' ';
rest = rest.slice(box[0].length);
}
rest = rest.trim();
if (!rest) continue;
const w = width(indent);
while (levels.length && w < levels[levels.length - 1]) levels.pop();
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
out.push({ depth: levels.length - 1, text: rest, done });
}
return out;
}
+44 -8
View File
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i et blok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
* tiden.
*
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
export function blockText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += blockText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
// 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('');
const code = blockText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
+67 -2
View File
@@ -43,6 +43,56 @@ class AppState {
*/
unreachable = $state(false);
/**
* Hvilken udgave af programmet den her instans er.
*
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
* det er en knap, der fører et sted hen, hvor der ikke er noget.
*
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
*
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
* program.
*/
edition = $state('full');
get notesOnly() {
return this.edition === 'notes';
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
*
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
* noget JavaScript kører, så serveren skriver dem om på vejen ud
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
get productName() {
return this.notesOnly ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.notesOnly ? 'urd' : 'verdande';
}
/**
* Mærket: én rune, det bogstav navnet begynder med i den ældre futhark.
* ᚹ wunjo for Verdande, ᚢ uruz for Urd.
*/
get productRune() {
return this.notesOnly ? '\u16A2' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -108,6 +158,17 @@ class AppState {
async load() {
this.loading = true;
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
const shape = api
.setupState()
.then((state) => {
if (state?.edition) this.edition = state.edition;
})
.catch(() => {});
try {
this.user = await api.me();
this.unreachable = false;
@@ -131,6 +192,10 @@ class AppState {
// arriving. A 401 is the server answering, and that is a real sign-out.
this.unreachable = e?.code === 'offline';
} finally {
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
// en instans, der ikke har nogen.
await shape;
this.loading = false;
}
}
@@ -524,9 +589,9 @@ class AppState {
* request is fast and the box clears immediately, which is where the
* responsiveness actually needs to be.
*/
async quickAdd(text, projectId) {
async quickAdd(text, projectId, sectionId, parentId) {
try {
const task = await api.quickAdd(text, projectId);
const task = await api.quickAdd(text, projectId, sectionId, parentId);
this.upsert(task);
return task;
} catch (e) {
+47 -2
View File
@@ -57,6 +57,39 @@
app.load();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
*
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
* Adressen hører bare til et andet program end det, der kører her — og nogen,
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
* mening frem for i en blindgyde.
*
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
* væk fra, og derfra tilbage igen.
*/
const TASK_PAGES = [
'/',
'/upcoming',
'/uddelegeret',
'/kalender',
'/faerdige',
'/opgave',
'/filter',
'/etiket'
];
$effect(() => {
if (!app.notesOnly) return;
const here = $page.url.pathname;
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
if (taskPage) goto('/noter', { replaceState: true });
});
/**
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
*
@@ -130,8 +163,20 @@
// Folding the sidebar had a button and nothing else, and a rectangle with a
// line down it is not a word: somebody who wanted the menu out of the way
// asked for the feature that was already there. ⌘B is where an editor puts
// it. Works while typing as well — it moves nothing in the field.
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
// it.
//
// Men ikke i en rig editor. Her stod der "works while typing as well — it
// moves nothing in the field", og det passer for et <input>, hvor ⌘B ikke
// gør noget. I en contenteditable ER ⌘B fed tekst, og `preventDefault()`
// nedenfor stoppede browseren i at gøre den — så i noterne foldede
// sidebjælken sig i stedet for at gøre ordet fedt. Feltet, markøren står i,
// har førsteret til en tast, det selv bruger.
const writing = el?.isContentEditable || el?.closest?.('[contenteditable="true"]');
if (
(event.metaKey || event.ctrlKey) &&
(event.key === 'b' || event.key === 'B') &&
!writing
) {
event.preventDefault();
sidebar.toggle();
return;
+1 -1
View File
@@ -75,7 +75,7 @@
}
</script>
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
<div class="view">
<header>
+1 -1
View File
@@ -126,7 +126,7 @@
}
</script>
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
<div class="view">
{#if status === 'denied'}
+4 -1
View File
@@ -379,7 +379,10 @@
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<!-- The product name is passed to every look, and only one of them has a
place for it: the look named after the program. The other three
carry no placeholder, so the parameter is ignored. -->
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
@@ -393,6 +393,8 @@
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
</div>
<!-- The way back in, and from anywhere else: a folder of Markdown is what
@@ -410,6 +412,8 @@
<p class="hint">{t('data.importNotesHint')}</p>
</div>
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
projekt, og det er en anden opgave end at flytte hele kontoen. -->
<div class="field">
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>
+5 -1
View File
@@ -12,8 +12,12 @@
* and it reads the token out of the URL itself.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
<SignIn />
+39 -11
View File
@@ -14,8 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { afterNavigate, goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -511,15 +511,43 @@
query = inURL;
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// 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 || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
/**
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
* den igen.
*
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
* sprang tilbage til den første.
*
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
* ved hver ændring.
*/
function openFromURL(url) {
const id = url?.searchParams.get('note');
if (!id || id === selectedId) return;
const found = notes.find((n) => n.id === id);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
});
else api.note(id).then(open).catch(() => {});
}
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
// den anden.
//
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
// eller fulgt udefra åbnede så ingenting.
//
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
// programmet, monteres siden ikke om.
onMount(() => openFromURL($page.url));
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
/**
* Hvilken hentning der gælder.
@@ -905,7 +933,7 @@
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
<svelte:window
onclick={onShareOutside}
+5 -1
View File
@@ -8,8 +8,12 @@
* fill in. Same shape as the invite link, and found the same way.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
<SignIn />