Compare commits

..
88 Commits
Author SHA1 Message Date
Kristian Wind ec5cf8f9f9 Merge pull request #108 from kristianwind/app-shell
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 16m4s
CI / Docker image builds (push) Successful in 3m1s
CI / Go (push) Failing after 26m23s
feat(app): iOS-skallen — Capacitor, Xcode-projektet og et CI-job, der bygger den
2026-10-23 14:33:23 +02:00
Kristian Wind 4ddc41632b Merge pull request #107 from kristianwind/app-base
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 16m41s
CI / Docker image builds (push) Successful in 2m50s
CI / Go (push) Failing after 26m54s
feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
2026-10-23 14:28:20 +02:00
Kristian Wind 2e9a92996e Merge pull request #106 from kristianwind/app-cors
CI / OpenAPI is valid (push) Successful in 13s
CI / Go (push) Failing after 26m32s
CI / End-to-end (push) Successful in 15m7s
CI / Docker image builds (push) Successful in 2m30s
feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
2026-10-23 14:23:48 +02:00
kristianwindandClaude Opus 5.5 ce1e12417b feat(app): iOS-skallen — Capacitor, Xcode-projektet og et CI-job, der bygger den
Femte trin mod iOS-appen. Fladen fra trin 3 pakket ind i en rigtig app.

- `app/`: Capacitor 8 med SPM (ingen CocoaPods). `npm run sync` bygger web
  med VERDANDE_TARGET=app og kopierer det ind i Xcode-projektet; `npm run
  open` åbner det på en Mac. Bundle-id `dk.nolimit.verdande`.
- `app/ios/` er committet. Webbygget og den genererede konfiguration i det er
  ikke — sync lægger dem der.
- `NSAllowsLocalNetworking`: en hjemmeserver uden certifikat kan nås med http
  på det lokale net; alt på internettet skal stadig være https.
- Ikon og opstartsbillede tegnes fra web/static/icon.svg
  (scripts/generate-ios-assets.js). Kvadratisk og uden gennemsigtighed, fordi
  iOS runder hjørnerne selv.
- CI-jobbet "iOS app shell" bygger og synker på Linux og tjekker, at
  app-bygget ikke registrerer en service worker. Ingen Xcode i CI; det dækker
  alt op til den.
- docs/ios.md: hvordan appen adskiller sig fra en browser, hvordan den bygges,
  hvad der er ejerens skridt (Apple-konto, signering, TestFlight), og hvad der
  mangler — notifikationer via APNs venter på en beslutning om en relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:21:44 +02:00
kristianwindandClaude Opus 5.5 b9028e8f7a feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver
sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i
appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind.
Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er
præcis, hvad det var før.

- `apiURL()` og `authorize()`: relative stier og cookie i en browser; den
  valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`.
- Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra
  telefonen, også når serveren ikke svarede.
- Live-synk i appen beder om en billet og åbner socket'en på serveren med den.
- Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen
  prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router
  eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af
  første konto vises ikke i appen; begge hører til serverens eget domæne.
- Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob,
  i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og
  `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse.
- Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem.

Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes
for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme
server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger
server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en,
viser et billede fra en blob og logger ud.

Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer,
socket uden billet, billeder ikke hentet, token efterladt ved log ud.

Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:33 +02:00
Kristian Wind 3867467f24 Merge pull request #105 from kristianwind/app-login
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m32s
CI / Go (push) Failing after 26m58s
feat(login): appen kan logge ind og bære sin session som bearer
2026-10-23 14:08:41 +02:00
kristianwindandClaude Opus 5.5 a71af41d9e feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
Andet trin mod iOS-appen. Appen henter sin flade fra capacitor://localhost og
taler med den server, ejeren har skrevet ind, så hver anmodning er cross-origin.

- CORS på /api/v1 for appens origin og ingen andres. Ingen
  Allow-Credentials: appen bærer sin session som bearer, aldrig som cookie.
  Alle andre origins får ingen headere, præcis som før.
- POST /ws-ticket giver en engangsbillet, der lever 30 sekunder, og
  /ws?ticket=… åbner socket'en med den. En browser kan ikke sætte en header
  på et WebSocket-håndtryk, og tokenet selv i URL'en ville ligge i hver
  proxy-log i månedsvis. En socket med billet springer origin-tjekket over,
  fordi den ikke bærer nogen cookie, som en fremmed side kunne låne.
- Et håndtryk fra appens origin uden billet afvises. Prøven fandt hvorfor:
  biblioteket sammenligner kun værtsnavne, og capacitor://localhost har samme
  værtsnavn som en instans på http://localhost.
- /ping svarer med product, edition og api, så appen kan kende en Verdande
  fra en routers login-side, før nogen er logget ind. Ingen version: /version
  er bag et login med vilje.

Fem plantede fejl, hver fanget af sin egen prøve: CORS til enhver origin,
genbrugelig billet, billet uden udløb, app-origin på en cookie, og
Allow-Credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:03:01 +02:00
kristianwindandClaude Opus 5.5 a8db761a4b fix(docs): et kolon i en beskrivelse gjorde specifikationen ulæselig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:55 +02:00
kristianwindandClaude Opus 5.5 4ef050ae04 feat(login): appen kan logge ind og bære sin session som bearer
Første trin mod en iOS-app. Appen pakker den eksisterende flade ind og henter
den fra sig selv (capacitor://localhost), så den deler aldrig origin med
serveren og får aldrig cookien tilbage. Den logger derfor ind som en browser,
med adgangskode og evt. kode, men får sin session i svaret i stedet for i en
cookie og sender den som `Authorization: Bearer`.

Det er med vilje en SESSION og ikke et API-token: den står i listen over
enheder under det navn, appen giver sig selv, kan logges ud derfra, og den når
2FA og nøgler, som et token med vilje ikke gør.

- `POST /auth/login` tager `client: "app"` og `device_name`. Svaret får
  `session_token` og ingen cookie, også mens `totp_required` er sand.
- En app-session glider: hver brug flytter udløbet frem, op til
  `VERDANDE_APP_SESSION_TTL` (180 dage) ubrugt. En browser beholder sin faste
  levetid. En halvfærdig login glider ikke; den beholder sine ti minutter.
- En bearer, der ikke er et API-token, slås op som session, men kun hvis den
  blev udstedt til appen. En browsers cookie-værdi som header må ikke blive en
  anden vej forbi SameSite og CSRF-låsen.
- CSRF-låsen genkender appens origin. Det var prøven, der fandt det: selve
  login-kaldet har intet token endnu og blev afvist som cross-site. En
  hjemmeside kan ikke skrive `Origin`-headeren, og en app-login sætter ingen
  cookie, så der er ingen session for en forfalsket anmodning at ride på.

Passkeys er udenfor i første omgang: RP-ID er bundet til domænet og kræver
apple-app-site-association på hver instans.

Migration 0037: sessions.client, device_name, idle_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:46 +02:00
Kristian Wind 95e02b567b Merge pull request #104 from kristianwind/billedvisning-stoerre
CI / OpenAPI is valid (push) Successful in 18s
CI / End-to-end (push) Failing after 4m58s
CI / Go (push) Failing after 5m30s
CI / Docker image builds (push) Successful in 2m39s
fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
2026-10-18 23:46:13 +02:00
kristianwindandClaude Opus 5.5 93470259d2 fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
Billedvisningen havde kun max-width/max-height, så den kunne gøre et billede
mindre og aldrig større. Målt i den rigtige app: et 600×400-billede stod 600 px
i noten og 600 i visningen, og et højt telefon-skærmbillede stod 701 px bredt i
noten og 357 i visningen. På en telefon var der ingen forskel overhovedet.

Nu fylder billedet skærmen (også når det skal forstørres), og et klik på
billedet — eller knappen "Fuld størrelse" — viser det i egne pixels, aldrig
smallere end skærmen, med rulning hen til det sted, man klikkede. Knapperne
ligger fast nederst, så de kan nås, uanset hvor man har rullet hen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:37:10 +02:00
Kristian Wind 6a5228aed8 Merge pull request #103 from kristianwind/alarm-i-saetningen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m53s
CI / Docker image builds (push) Successful in 3m38s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 27m13s
feat(opgaver): alarmen kan tales ind, og kan være standard
2026-10-08 20:40:44 +02:00
kristianwindandClaude Opus 5 9155e799b1 fix(prøver): markør-prøverne venter på editoren, før de taster
Runde 3 og 4 af de røde End-to-end var samme årsag. Det faste ophold, jeg
fjernede i forrige commit, skjulte den bag en anden påstand.

`page.keyboard.type` kørte, før editoren var tegnet. `click()` på "Ny note"
vender tilbage, så snart klikket er sendt, mens noten først skal oprettes
på serveren og tegnes bagefter. Så gik det tastede ind i den FORRIGE note
eller ingen steder, og noten blev liggende tom:

* runde 3 fejlede på, at noten ikke fandtes efter genindlæsningen
* runde 4 fejlede på, at der ikke kom nogen PATCH at vente på

To forskellige påstande, én årsag.

Og svaret stod skrevet over `nyNote` i samme fil, før prøverne blev
skrevet:

    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 er runde 3's fejl ord for ord. Men `nyNote` KLIKKER i feltet, og det er
netop det, markør-prøverne ikke må: de måler, at markøren står der, uden at
nogen har sat den. Derfor `nyTomNote` — samme ventetid, uden klikket.

Ankret på <h1> frem for blot et tomt felt: <h1> skrives af samme effekt,
der sætter markøren, så når den er der, er markøren sat. Et tomt felt kan
være et felt, effekten ikke har rørt endnu.

Kontrolleret i begge retninger med 1500 ms plantet på POST /notes: det rå
klik fejler med runde 4's ordlyd, `nyTomNote` består.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 20:29:59 +02:00
kristianwindandClaude Opus 5 5764271579 fix(prøver): kontrollen venter på gemningen, ikke på et tal
Fjerde røde End-to-end, fjerde reelle årsag, og denne stod skrevet i
forvejen.

"en note med indhold stjæler ikke markøren" brugte `waitForTimeout(1200)`
til at vente på, at noten blev gemt. På en travl runner var 1200 ms ikke
nok: genindlæsningen smed det skrevne væk, og prøven faldt på at noten
ikke FANDTES — `waiting for getByRole({ name: /Allerede skrevet/ })` —
frem for på markøren, som er det den måler.

Kommentaren over `gemt` i toppen af den samme fil siger det ord for ord:

    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.

Jeg skrev præcis det kald, med præcis det tal, under en kommentar der
navngiver begge. Lektien var ikke ny — den var skrevet ned, i filen, lige
over den hjælper der findes for at erstatte det.

Bruger nu `noteGemt(page)`, sat op FØR der skrives: svaret på PATCH'en er
det eneste, der beviser, at serveren har hørt om det.

Kontrolleret i begge retninger med 2500 ms plantet på PATCH /notes/* —
mere end dobbelt så længe som det gamle ophold ventede. Det gamle kald
fejler med CI's ordlyd; `noteGemt` består.

Der står 24 faste ophold tilbage i filen. De er ældre end denne gren, og
nogle venter måske på noget uden et netværkskald, så de hører i deres egen
rettelse — men mønsteret er nu dokumenteret to gange i samme fil.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 20:12:07 +02:00
kristianwindandClaude Opus 5 8a6329bfc9 fix(prøver): markør-prøverne venter på markøren i stedet for at håbe
CI's End-to-end faldt på "en ny note åbner med markøren i overskriften" med
`Expected: "h1", Received: null`, på en gren hvis eneste ændring siden
forrige grønne kørsel var docs/openapi.yaml. Den kan ikke påvirke en
editor, så fejlen lå i prøven.

Den klikkede "Ny note" og læste markøren i ÉT snapshot med det samme.
`click()` vender tilbage, så snart klikket er sendt, mens markøren sættes
efter en netværksrundtur OG en gentegning. Lokalt er rundturen under et
millisekund, så snapshottet ramte altid; på en travl runner ramte det null.
main er grøn på netop den prøve, så den bestod ved held frem for ved regel
— og den havde ligget og ventet på en travl runner i en fremmed PR, hvor
den ville have lignet dén PRs fejl.

Målt frem for ræsonneret: med 900 ms plantet på POST /notes fejler den
gamle læsning med CI's ordlyd og `expect.poll` består.

Tre af de fire havde samme svaghed. Kontrollen — "en note med indhold
stjæler ikke markøren" — er bevidst IKKE lavet om: den forventer null, så
en poll ville bestå på første læsning uanset hvad koden gjorde. Den ankrer
sig på overskriftens tekst i stedet. Det står som kommentar i filen, så den
ikke bliver "rettet" tilbage.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:53:12 +02:00
kristianwindandClaude Opus 5 fb8abcaa74 fix(docs): nullable er ikke 3.1, og eksemplet hørte til nabofeltet
To fejl i samme blok, begge mine.

`nullable: true` er OpenAPI 3.0. Specen er 3.1, hvor det blev fjernet til
fordel for en type-union, og den havde allerede fortilfældet stående:
`type: [integer, "null"]`. Redocly afviste de to steder som struktur-fejl.

Og min lokale kontrol stillede et svagere spørgsmål end CI's: jeg læste
filen med `yaml.safe_load` og fik "gyldig" tilbage. Den svarede på, om det
er YAML — ikke om det er en gyldig OpenAPI. CI's egen validator kører nu
lokalt, før den kører der.

Den anden fejl fandt kun en ADVARSEL: `example: ["labels"]` tilhørte
sidebar_collapsed, og min indsættelse landede før den, så en strengliste
hang på et heltalsfelt. Ankeret dækkede ikke hele blokken, jeg skød ind
efter. CI fejler kun på fejl, så den var sluppet igennem — på to felter ad
gangen, for sidebar_collapsed stod samtidig uden sit eget eksempel.

Advarsler: 253, som før ændringen. Specen er gyldig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:34:05 +02:00
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:16:14 +02:00
Kristian Wind 661e47ebdf Merge pull request #102 from kristianwind/markoer-i-titlen
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 15m22s
CI / Go (push) Failing after 26m42s
Release / Build and publish image (push) Failing after 9s
CI / Docker image builds (push) Successful in 2m36s
fix(noter): en ny note åbner med markøren i overskriften
2026-10-08 10:17:18 +02:00
kristianwindandClaude Opus 5 6178cbbdd8 Merge branch 'main' into markoer-i-titlen
Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:06:42 +02:00
Kristian Wind 43bd405e36 Merge pull request #100 from kristianwind/tal-en-opgave-ind
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m21s
CI / Go (push) Failing after 26m57s
feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
2026-10-08 09:59:41 +02:00
Kristian Wind b73f55ad0b Merge pull request #101 from kristianwind/afsnit-efter-retur
fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
2026-10-08 09:59:22 +02:00
kristianwind 475ecbe7ef Merge branch 'afsnit-efter-retur' into markoer-i-titlen 2026-10-08 07:55:04 +02:00
kristianwind 159029675c Merge branch 'afsnit-efter-retur' into tal-en-opgave-ind 2026-10-08 07:49:10 +02:00
kristianwindandClaude Opus 5 b18d9a26e6 fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
Retur-handleren udskyder rettelsen ét hak, fordi der før delingen ikke er
nogen ny blok at rette. Men rettelsen spurgte, hvor markøren stod *nu*, og
et hak er nok: skriver man hurtigt, er næste retur nået igennem først, og så
stod markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
blok, rettelsen var ment for, stå tilbage som browserens bare <div>. En
<div> læses som brødtekst og er ikke formateret som en, så afstanden mellem
afsnit forsvandt stille, hos netop de brugere der skriver hurtigst.

Målt frem for gættet: "flere markerede afsnit bliver til én monotypeblok"
faldt på en CI-runner med alle tre linjer skrevet rigtigt, hvor "linje et"
var en <div> og "linje to" og "linje tre" var <p>.

Rettelsen er delt efter hvad der er sikkert at røre:

* Der hvor markøren står, gør execCommand det fortsat. Det er den, der
  lægger ændringen i browserens fortryd-historik, og filen vælger den
  bevidst andre steder af samme grund — ⌘Z skal tage afsnittet tilbage,
  ligesom den tager et ord tilbage.
* Den blok, delingen lige skabte, får samme regel uden markør at låne.
  Den dækker overskriften: retur for enden af en overskrift efterlader
  endnu en overskrift i de fleste browsere.
* Og hver <div> i noten bliver fejet op. En <div> er altid browserens egen
  beholder og aldrig nogens valg, så den kan rettes hvor som helst. En <h1>
  bliver IKKE fejet: menuen har en Titel-stil, så en overskrift midt i en
  note kan være en linje, nogen selv har valgt, og den er deres at beholde.

Fejning frem for et håndtag til blokken, og det er målt: når den første
udskudte rettelse bytter tagget, peger den næstes håndtag på en node uden
for dokumentet, og blokken midt i kæden bliver aldrig rettet. Prøven planter
to efterladte blokke og er kontrolleret i tre retninger — ingen rettelse: 2
efterladte <div>, håndtag til blokken: 1, fejning: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:37:49 +02:00
kristianwindandClaude Opus 5 3768cf946e fix(noter): en ny note åbner med markøren i overskriften
Oprettelse af en note satte ingen markør. Siden kaldte
`document.querySelector('.editor textarea')?.focus()` — og der er ingen
textarea; editoren blev lavet om til en contenteditable, så kaldet har ramt
ingenting lige siden, og `?.` sørgede for, at det aldrig larmede.

Rettelsen ligger i NoteEditor frem for på siden, så begge veje er dækket:
knappen på /noter og den på et projekt, der navigerer til /noter?note=<id>.
Reglen er, at en TOM note sætter markøren i sin overskrift — en note med
indhold rører den ikke, for der er markøren brugerens eget valg.

Fire prøver, og de er kontrolleret i begge retninger: uden kaldet giver den
nye note `Expected: "h1", Received: null`, og med kaldet på enhver note
giver kontrollen `Expected: null, Received: "h1"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:16:22 +02:00
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:04:27 +02:00
kristianwindandClaude Opus 5 664614b924 feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.

VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.

Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.

## Det ene, funktionen hænger på

Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.

Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.

## To kollisioner, hvor jeg kun så den ene

Skuffen lægger et felt mere på siden, og det rammer to ting:

- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
  querySelector — altså FØRSTE match. Set på forhånd og løst med en
  `marked`-prop.

- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
  `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
  et prøveproblem: to felter med samme navn læses op to gange af en
  skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
  ene er kassen på siden, det andet er det, knappen åbner — så de har hver
  sit navn nu.

## Og en fejl, skærmbilledet fandt

Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."

Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.

Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:50:56 +02:00
Kristian Wind 807aa83e58 Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Successful in 15m49s
CI / Docker image builds (push) Successful in 2m55s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 26m49s
feat(urd): den anden dør — /urd kan installeres som sin egen app
2026-10-07 10:42:20 +02:00
kristianwindandClaude Opus 5 9199709aa5 feat(urd): den anden dør — /urd kan installeres som sin egen app
Én instans, én database, ét login — og to ting, der kan installeres.

Et styresystem installerer ikke en URL, det installerer et MANIFEST, og en
installeret apps identitet er manifestets `id`. To manifester på samme
origin med forskellige id'er er to apps; to adresser, der deler ét manifest,
er én app med et bogmærke. Det er hele mekanismen.

  /                     → verdande, ᚹ, /manifest.webmanifest, id "/"
  /urd                  → urd, ð, /urd.webmanifest, id "/urd"

Skallen serveres per dør frem for at blive rettet af JavaScript bagefter,
fordi iOS læser sidens <head> ved "Føj til hjemmeskærm" — og Safari leder
efter apple-touch-icon VED NAVN og har historisk ignoreret manifestets
ikoner helt. Et <head> rettet efter indlæsning er et <head>, der måske ikke
er det, iOS læste.

urds manifest bygges AF verdandes frem for at stå ved siden af det, så de
felter, de faktisk deler — farverne, visningstilstanden, sproget — kommer ét
sted fra og ikke kan drive fra hinanden. Seks nøgler ændres: id, start_url,
scope, de to navne og ikonerne. Et ikon i det byggede manifest, som urd
ikke har en fil til, stopper serveren frem for at gå igennem: symptomet
ville ellers være, at urds installationsprompt stille viste verdandes mærke.

`scope` skrives ud, selvom "/" er hvad specifikationen ville udlede af en
start_url på "/urd". En app, hvis scope ikke dækkede hele origin, ville åbne
sine egne links i en browserfane, første gang nogen navigerede ud af /urd —
og det er en regel, ingen skal behøve at huske, mens de læser koden.

## Ansigtet er en præference, ikke en port

`urdFace` holdes skarpt adskilt fra `notesOnly`, og det er den vigtigste
linje i ændringen:

  notesOnly — serveren HAR ingen opgaveruter. En vagt: hent dem ikke, de
              svarer 404.
  urdFace   — vinduet kom ind ad urd-døren. En præference: ruterne er der,
              dataene er personens egne, kun navnet er et andet.

Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
notesbog ville holde op med at vise sine opgaver, fordi nogen valgte en
anden indgang, og så er det den separate instans i forklædning. En prøve
påstår det modsatte direkte: urd-vinduet får stadig 200 på /api/v1/tasks.

Ansigtet ligger i sessionStorage og ikke localStorage, og det er dét, der
lader de to apps være to apps: de deler origin og dermed localStorage,
cookies og service worker. sessionStorage er per vindue, og en installeret
PWA er sit eget vindue. Det overlever en genindlæsning, hvilket det skal —
efter første navigation står adressen på /noter, og ingen går gennem /urd
igen.

## Undervejs

contentTypeFor slog op på det eksakte navn "manifest.webmanifest", så den
anden dørs manifest blev serveret som HTML i fire minutter. Intet ville have
meldt det: et manifest med forkert type ignoreres af browseren uden fejl, og
det eneste symptom er en installationsknap, der ikke dukker op. Læser nu
endelsen.

Og drift-kontrollen mod app.html dækkede kun udgavens to ankre, ikke dørens
fem — hvilket er grunden til, at dørens fikstur manglede to <link>-linjer,
den rigtige fil har. Fanget alene fordi applyRebrands nægter et anker, den
ikke kan finde. Kontrollen dækker nu begge sæt.

Plantet: et manifest uden `id` giver "the urd manifest has id = <nil>, want
/urd" — altså den værdi, hele designet hviler på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:32:25 +02:00
Kristian Wind 1d15cb18c0 Merge pull request #98 from kristianwind/urd-faar-sit-maerke
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m51s
CI / Docker image builds (push) Successful in 3m16s
CI / Go (push) Failing after 27m5s
feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
2026-10-07 10:20:28 +02:00
kristianwindandClaude Opus 5 ff988d26bb feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
## Mærket

Urds oplagte mærke var ᚢ uruz — bogstavet navnet begynder med i den ældre
futhark, præcis som Verdandes er ᚹ wunjo. Det blev tegnet sådan først.

Men uruz er allerede et af husets egne produkter, og dets egen
`scripts/gen-icons.ts` siger i sin kommentar at den "draws the Uruz rune (ᚢ)
in rune-gold" med "a tall left vertical, a shoulder across the top, and a
shorter" — altså samme glyf, samme konstruktion, kun en anden farve. To af
samme persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det
sted mærket skal kunne skelnes.

ᚦ thurisaz blev forkastet et niveau nede af samme grund: en stav med en
trekant til højre, og det er ᚹ også. Ved 16px er de én form.

Så bogstavet frem for runen: ð, ethet i Urðr. Centreret ved at tælle malede
pixels frem for ud fra path-tallene, fordi en centreret kasse ikke er en
centreret glyf:

  verdande ᚹ   blæk x  53–108  midte 80.5  ·  y  18–131  midte 74.5
  urd      ð   blæk x  39–110  midte 74.5  ·  y  15–134  midte 74.5

Verdandes +5.5 i x er dens egen bevidste forskydning — en tung stav ved
siden af et let flag skal sidde til højre for midten for at se centreret ud.
ð er symmetrisk nok til at undvære den, og de to er enige i y.

Læsbarhed, målt: ð læses fra 32px og op. Ved 16px er det en klat, og det er
ᚹ også — en favicon er ikke det, der skiller to installerede apps i en dock.

`productRune` hedder nu `productMark`, fordi ð ikke er en rune.

## Determinisme

SvelteKits `kit.version.name` er som standard `Date.now()`. Millisekundet
landede i version.json, i index.html og i service-workeren, ændrede
indholdshashen på hver chunk der refererer dem, og dermed deres filnavne. To
bygninger af samme commit havde forskellige filer i sig.

Dockerfilen har i et år sagt at `-trimpath` og en tom buildid "keep the
output reproducible". Flagene gjorde deres arbejde; frontenden i binæren bar
et tidsstempel. Sætningen var falsk, og ingen læste den.

Fastgjort til VERSION, som udgivelsen allerede sender ind, og `dev` når
intet sætter den. Målt ved at bygge to gange og diffe træet, før og efter.
Prisen er at et lokalt `npm run build` ikke længere ligner en ny version for
service-workeren — hvilket kun betyder noget på en udrullet instans, hvor
udgivelsen sender det rigtige mærkat.

Det gør også CI's nye påstand mulig: de to images deler alle lag, så urd
bærer samme binær som verdande frem for en genbygning der tilfældigvis
matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:10:30 +02:00
Kristian Wind b451fd585d Merge pull request #97 from kristianwind/porten-daekker-eksporten
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m55s
CI / Docker image builds (push) Successful in 3m17s
CI / Go (push) Failing after 27m10s
fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
2026-10-07 02:11:49 +02:00
kristianwindandClaude Opus 5 8c3aa33f13 fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.

Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.

Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:

  sidebjælken      GET /filters  404 ved hver navigation, ædt af .catch()
  sidebjælken      GET /labels   404 ved hver navigation, ædt af .catch()
  notifikationer   GET /ai/plan  404, stille
  notesbogens side GET /tasks    kastede → status = 'failed'
  notesbogens side GET /sections kastede → samme fejlskærm
  indstillinger    GET /templates  toast med fejlbesked på skærmen
  integrationer    GET /feed     404, stille

Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.

Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.

Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.

Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.

watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 02:03:40 +02:00
Kristian Wind c6c7979d1a Merge pull request #96 from kristianwind/urd-faar-sit-navn
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 5m49s
CI / Docker image builds (push) Successful in 3m9s
CI / Go (push) Failing after 16m57s
feat(udgaver): urd — noter-udgaven siger sit eget navn
2026-10-07 01:33:53 +02:00
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
kristianwindandClaude Opus 5 84af63316d feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står
`user` som null — og null betyder "logget ud" alle andre steder i
programmet. Så et totalt udfald blev vist som login-skærmen: appen gav
sessionen skylden for, at nettet var nede, og tilbød et kodeordsfelt, der
umuligt kunne virke.

Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke.
Man taster kodeordet, der sker ingenting, og man konkluderer, at kontoen
er i stykker. Det skete i virkeligheden 17. september 2026, da instansens
DNS-post blev slettet: det, appen viste, var en langsom login-side.

Forskellen var der allerede i forespørgselslaget. Et netværkssvigt, der
har brugt sit forsøgsvindue op, kastes som `ApiError(0, 'offline')` —
koden laves af klienten selv, netop fordi der aldrig kom en status. En 401
er serveren, der svarer, og det er en rigtig udlogning. Butikken skelner nu
på den kode, og fladen viser NoConnection i stedet for SignIn.

Teksten siger, at data er i behold, for det er det spørgsmål, man faktisk
har, når ens noter forsvinder bag en login-boks.

Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt
forespørgsel er præcis det, en manglende DNS-post giver browseren.
Efterprøvet mod den gamle kode — den fejler på "Ingen forbindelse".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:29:57 +02:00
Kristian Wind 930522ecf6 Merge pull request #86 from kristianwind/ny-note-taber-ikke-tekst
fix(noter): en ny note taber ikke det, man skriver i den
2026-09-17 12:29:49 +02:00
kristianwindandClaude Opus 5 9ed6f49589 fix(noter): en ny note taber ikke det, man skriver i den
Serveren tømmer `body` på listerækker med vilje, så en tom krop var sidens
tegn på "det her er en række, hent noten hel". En nyoprettet note er også
tom — og hel — så den blev sendt samme vej: editoren blev pillet af
skærmen, noten hentet igen, og editoren sat op på ny.

I det hul er der ingen valgt note, og save() går tavst tilbage uden en.
Skrev man der, forsvandt teksten sporløst: ingen fejl, ingen toast, bare en
tom note. Svaret på oprettelsen var hele noten hele tiden, så opslaget var
også overflødigt.

Det er samtidig grunden til, at søgeprøven var ustabil. Dens værn var en
ventetid på "Gemt", og den tekst er hvilestanden — feltet siger "Gemmer"
mens en gemning kører og "Gemt" ellers, altså også før den første gemning
er begyndt. Prøven fik grønt med det samme og målte ingenting; i CI var
maskinen langsom nok til at ramme hullet bagefter. Den venter nu på et tomt
felt før den skriver, og på rækken i listen bagefter.

Den nye prøve tæller opslag frem for at måle et blink: et blink ville kræve
en prøve, der er hurtigere end programmet, hvilket er præcis den fejl den
lukker. Efterprøvet mod den gamle kode — den fejler på
`expect(fetched).toEqual([])`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:20:02 +02:00
kristianwindandClaude Opus 5 de673260a3 docs(site): besøgstælling på verdandetodo.com
Plausible på vores egen maskine. Ingen cookie, ingen gemt adresse, ingen
tredjepart — og derfor ingen samtykkebjælke på en side, der skal sælge noget.

`defer`, så tælleren aldrig er det, der holder siden tilbage.

Det gælder siden, ikke programmet. En installation melder det, der står under
"It counts itself, out loud", og ikke mere; de to ting deler ikke noget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:31:00 +02:00
Kristian Wind 804c81f74e Merge pull request #84 from kristianwind/soegefeltet-taber-ikke-bogstaver
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m21s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 4s
CI / Go (push) Failing after 14m17s
fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
2026-09-16 08:34:59 +02:00
kristianwindandClaude Opus 5 87054ac8ca fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
Fra en skærmoptagelse, to billeder i sekundet:

    2,5 s  feltet "verd"      listen ufiltreret
    3,0 s  feltet "nde"       listen filtreret
    6,0 s  feltet "verdande"  listen ufiltreret
    6,5 s  feltet tomt        listen filtreret

To fejl oven i hinanden, og den ene er min fra #79.

Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk, så en
søgning kunne gemmes og deles. `replaceState` opdaterer `$page.url` et øjeblik
efter, den kaldes, så to hurtige tastetryk gav en effekt, der læste den *forrige*
adresse, fandt den uenig med det skrevne, og skrev den gamle værdi tilbage i
feltet. `untrack` fjernede sløjfen mellem de to effekter; den fjernede ikke
kapløbet med browserens egen forsinkelse.

En markør, der skal holde to retninger enige, taber kapløbet et sted. Adressen
skrives derfor ikke længere af siden — kun af navigation — og der er én effekt
tilbage, som læser den. Prisen er, at et ryddet søgefelt ikke rydder `q` af
adressen. Det er en mindre løgn end at æde bogstaver.

Den anden fejl har ligget der hele tiden: `load()` havde ingen vagt mod forældede
svar. To hentninger kunne være undervejs på én gang, og den langsomste vandt — så
listen viste svaret på et spørgsmål, der ikke længere blev stillet. Pausen på
250 ms gjorde det sjældnere og kunne ikke gøre det umuligt; det stod endda i
kommentaren ved siden af. Hver hentning har nu et nummer, og kun det nyeste
skrives i listen.

Prøven skriver i et menneskeligt tempo, for det var hastigheden, der afslørede
det, og holder den ene af to hentninger tilbage for at tvinge rækkefølgen om.
Kørt mod den gamle kode fejler den med `Received: ""` — feltet var tømt helt.

Bemærket: "en gruppe er en side med sine projekter" fejlede to fulde kørsler i
træk og bestod på den tredje, og alene. Den er ikke rørt her. Sammen med "en delt
note tager de noter med" i går er der nu to prøver, der flakker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:25:10 +02:00
Kristian Wind c62dcc3d7c Merge pull request #83 from kristianwind/opgavens-tekst-kan-laeses
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m47s
CI / Docker image builds (push) Successful in 2m1s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m57s
fix(opgave): teksten kan læses færdig, også på en telefon
2026-09-15 20:22:38 +02:00
kristianwindandClaude Opus 5 0110a86858 fix(opgave): teksten kan læses færdig, også på en telefon
Rapporteret med et billede af en beskrivelse, der manglede sit første bogstav på
hver linje: "hermostat RU0910301440" og "atteries". Det er ikke manglende tegn —
det er et felt, der er rullet til højre. Og det ligner ikke rulning; det ligner,
at bogstaverne ikke er der.

To ting på én skærm.

Titlen var et <input>, og et input kan ikke ombryde. "tado°: Your Smart
Thermostat needs new batteries" er en almindelig opgavetitel, og på 390 px var
feltet 490 px bredt i en kasse på 356 — halvdelen af overskriften stod uden for
kanten. Den er nu et <textarea>, der vokser med sin tekst. Retur gemmer frem for
at lave en ny linje: en titel er én linje, uanset hvor mange den fylder på
skærmen. Trækkehåndtaget er væk, for højden sættes af teksten, og et håndtag
ville love noget, det næste bogstav retter tilbage.

Beskrivelsen er tit et klippet stykke mail med en URL i. Et <textarea> ombryder
ved mellemrum, og en URL har ingen.

`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan stå på
en linje alene, mens den anden bryder ved først givne lejlighed og delte
"GarageRisteriet" midt i en stavelse. Den lektion står allerede ved h1 på
projektsiden og er betalt én gang.

Prøven måler frem for at beskrive: et felt, der ruller vandret, har en
scrollWidth større end sin clientWidth. Kørt mod den gamle komponent fejler den
med "Opgavens tekst: 490 > 356".

Men kun den ene halvdel. Beskrivelsen løber ikke over i Chromium, heller ikke
uden reglen — det var Safari, den blev set i. Prøven måler derfor den regel frem
for dens virkning, og det står skrevet i prøven, så den næste ved, hvad den
vogter og hvad den ikke gør.

Bemærket undervejs: "en delt note tager de noter med, den peger på" fejlede én
gang ud af to fulde kørsler med "Delt med Mikkel." uden antallet. Den består
alene og i næste fulde kørsel. Den er ikke rørt her, men den flakker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:12:28 +02:00
Kristian Wind c868be8f89 Merge pull request #82 from kristianwind/hjemmesiden-er-med
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 10m19s
CI / Docker image builds (push) Successful in 2m3s
CI / Go (push) Failing after 14m45s
docs(site): landingssiden når op til v0.47 (+ en flakkende prøve)
2026-09-15 14:21:35 +02:00
kristianwindandClaude Opus 5 145fefea02 docs(site): landingssiden når op til v0.47
Den udrullede side på verdandetodo.com er fra 20.-22. august — den matcher en
commit fra før v0.42.15. Repoets egen kopi er fra 4. september. Begge stopper,
hvor "Lately" stopper, og den sagde 0.42.

Ni nye poster: billederne i noter, søgningen der bliver stående, deling af et
projekt ved navn, besked og udgang, søgning i indstillinger, dagens plan som
kort, browservalget, hele AI-laget, versionsbeskeden til administratorer, og
krogen der lader hvad som helst lægge en opgave i indbakken.

De to ældste ryger. Med ni nyere poster er 0.34 og 0.33 ikke "for nylig" længere,
og hele historien står på udgivelsessiden, som afsnittet selv henviser til.

Siden udrulles i hånden — der er ingen arbejdsgang, der gør det. Det er derfor,
den er tre en halv uge bagud, og det er værd at tage stilling til separat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 5d911d40fe test(noter): vent på at noten er gemt, før den næste laves
Prøven fra i går fejlede i CI på en PR, der kun rørte dokumentation — altså
flakkede den, og det er min.

Løkken laver tre noter og klikkede "Ny note" igen, uden at vente på at den
forrige gemning var kvitteret. Her på maskinen nåede den altid med; på en
belastet runner gjorde den ikke, og listen manglede noten. Fem sekunders
tålmodighed hjalp ikke, for det var ikke ventetid, der manglede — det var et
signal.

Der ventes nu på "Gemt". Kørt tre gange i træk grønt.

En prøve, der er hurtigere end programmet, måler den maskine, den kører på.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 9788bbaaf8 docs: siden bliver udgivet, og den siger det, der er bygget
Hjemmesiden har vist en Verdande fra før v0.43. /links/, /beacon/ og
/notifications/ svarede alle 404, måneder efter de blev skrevet.

Årsagen: docs-jobbet byggede på hvert push og udgav kun på en manuel udløsning,
med en note om at det ville køre, "once Pages is enabled". Pages BLEV slået til
— og betingelsen blev aldrig rettet. Siden er dermed bygget fyrre gange og
udgivet nul, uden at noget fejlede, for at bygge var præcis det, jobbet blev
bedt om.

Lektien er ikke den linje. Den er, at "udgiv senere" har brug for noget, der
fejler, når senere kommer. En kontakt, der er slukket og tavs, ligner en kontakt,
der er tændt.

Udgives nu fra main og ved håndsoprækning, aldrig fra en pull request — en fork
kunne ellers udgive hen over siden ved at åbne en.

Dokumentationen manglede ⌘K-ændringen fra i går: at vælge en note fra paletten nu
efterlader de andre træffere i listen ved siden af den. Den står i "Finding",
hvor de to søgefelter i forvejen er beskrevet hver for sig uden at sige, at de nu
er enige.

README'en manglede tolv ting, der er bygget siden tabellen sidst blev rørt — og
forseglingen af noter, som kun stod i docs. At en kopi af databasefilen ikke er
en kopi af noterne, er en overskrift for et selvhostet program, ikke en detalje
på side tre.

GitHub-repoet havde ingen hjemmeside og ingen emner, og beskrivelsen nævnte
hverken noter, kalender eller AI. Alle tre er sat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:00:22 +02:00
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
kristianwindandClaude Opus 5 ff0447317b feat(noter): et indsat billede kan ses helt og kopieres videre
I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
ikke ser ud som et. Der var ingen vej fra det til billedet selv.

Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link,
der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i
teksten — ellers står fokus på en knap, der ikke findes længere, og den næste
tast går ingen steder.

Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan
klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret
af klikket, og det skal det: det er sådan, det slettes.

To ting, der ikke er til at gætte:

Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er
en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred —
ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan
indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt
forbi netop den kode.

Og løftet gives videre til ClipboardItem frem for at blive ventet af først.
Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning
imellem gør klikket til fortid.

Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen
stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det
skal lægge sig over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 21:54:48 +02:00
Kristian Wind a2f6e9bda3 Merge pull request #79 from kristianwind/soegningen-foelger-med
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 10m33s
CI / Docker image builds (push) Successful in 2m1s
CI / Go (push) Failing after 14m48s
fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
2026-09-14 11:19:56 +02:00
kristianwindandClaude Opus 5 1bbd36c79b fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
lukket.

Feltet i selve notelisten har filtreret hele tiden, og det er prøvet to steder.
Men den søgning, man finder, er den store i toppen, og den er palettens. Så det
er ikke "søgning i noter virker ikke" — det er, at den søgning, man bruger, ikke
efterlod noget.

Adressen bærer nu søgningen. ⌘K sender `?note=…&q=…`, notesiden læser begge, og
listen ved siden af den åbne note er de andre træffere. Den skrives tilbage, når
feltet ændres, så adressen kan gemmes og deles — og frem for alt ikke lyver, når
feltet er tømt.

To effekter, der deler en markør, og hvorfor de læser den med `untrack`:

Den nederste sætter `settled` og kalder `replaceState`. Det vækker den øverste,
og på det tidspunkt har `$page.url` endnu ikke skiftet — så den læste den gamle
adresse, fandt en søgning, der ikke var enig med markøren, og skrev den tilbage
i feltet. Man tømte søgefeltet, og ordet kom igen. Hver effekt skal reagere på
sin egen kilde, adressen og feltet, og aldrig på den fælles markør.

Den fejl fandt jeg kun, fordi prøven fejlede på den sidste linje og
fejlbilledets tilgængelighedstræ stadig viste "Fyrreskov" i feltet. En prøve,
der kun havde tjekket adressen, havde været grøn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:10:09 +02:00
Kristian Wind 54359f8d68 Merge pull request #78 from kristianwind/tagbeskeden-naar-frem
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m46s
CI / Docker image builds (push) Successful in 1m21s
CI / Go (push) Failing after 13m54s
fix(release): tagbeskeden når frem til udgivelsen
2026-09-14 08:31:10 +02:00
kristianwindandClaude Opus 5 f400705bb7 fix(release): tagbeskeden når frem til udgivelsen
Den skulle have gjort det siden #72, og gjorde det ikke. v0.45.0 og v0.46.0 fik
begge flettecommittens overskrift som udgivelsestekst — uden at noget fejlede,
og uden at nogen kunne se det andet end på siden bagefter.

`fetch-depth: 0` er ikke nok. actions/checkout henter først alle tags korrekt og
overskriver derefter netop den ene, der udløste kørslen:

    git rev-parse refs/tags/v0.46.0
    git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0

Den henter commit-SHA'en ind i tag-refen. Den annoterede tag bliver dermed til
en letvægtstag, der peger direkte på commit'en, og `%(contents)` giver så
commitbeskeden. Kommentaren i filen sagde, at en overfladisk checkout var
problemet; det var rigtigt og utilstrækkeligt.

Tagget hentes derfor som *ref* igen, lige før det læses.

Og vagten: hver tagbesked begynder med sin egen version, så holder det ikke,
læser vi noget andet end tagget, og kørslen fejler frem for at udgive en forkert
tekst. Det er det, der manglede — fejlen var ikke svær at rette, den var svær at
opdage.

Målt i et klonet arbejdstræ i alle tre tilstande: annoteret giver den rigtige
tekst med selvlinket fjernet, letvægt giver flettecommitten og udløser vagten,
og en re-fetch af refen bringer den annoterede tilbage.

Teksten på de to udgivelser, der allerede stod forkert, er rettet i hånden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:21:27 +02:00
Kristian Wind 0dc4c2f9b8 Merge pull request #77 from kristianwind/besked-og-en-udgang
CI / OpenAPI is valid (push) Successful in 8s
CI / End-to-end (push) Successful in 8m56s
CI / Docker image builds (push) Successful in 1m39s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
feat(projekter): en deling siger til, og der er en vej ud af den
2026-09-14 08:17:53 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:07:46 +02:00
Kristian Wind 0f4048d8f2 Merge pull request #76 from kristianwind/del-projekt-med-en-der-er-her
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m53s
CI / Docker image builds (push) Successful in 1m39s
CI / Go (push) Failing after 14m7s
feat(projekter): del et projekt med en, der allerede er her
2026-09-13 18:00:04 +02:00
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:52:03 +02:00
Kristian Wind a39f655565 Merge pull request #75 from kristianwind/soeg-i-indstillinger
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m38s
CI / Docker image builds (push) Successful in 1m38s
CI / Go (push) Failing after 14m6s
Release / Build and publish image (push) Failing after 4s
feat(indstillinger): en indstilling kan findes ved at søge efter den
2026-09-12 10:04:17 +02:00
kristianwindandClaude Opus 5 7e981354c2 feat(indstillinger): en indstilling kan findes ved at søge efter den
Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et
bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver,
projekter og noter — alt det, man selv har skrevet, og intet af det,
programmet består af.

Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten.
To lister over de samme afsnit ville være to steder at glemme et nyt et.

Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets
egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af
sig selv, og søgningen kan begge sprog uden at kende nogen af dem.

`words` er derimod skrevet i indekset, og det er med vilje: det er søgeord,
ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det
ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal,
webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de
virker som søgeord.

Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i
afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig —
og hedder derfor to-faktor, kalenderfeed og caldav-server.

Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og
afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og
gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med
otte afsnit, man ikke selv rullede hen til.

Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun
under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde
fejl på det, der med rette var skjult.

Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet
noter længe, og kan nu også indstillinger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:56:00 +02:00
Kristian Wind 1f0a43ac90 Merge pull request #74 from kristianwind/kalender-url-ud-over-kanten
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m13s
CI / Docker image builds (push) Successful in 1m30s
CI / Go (push) Failing after 13m56s
fix: URL'er kan læses helt på en telefon
2026-09-12 09:43:40 +02:00
kristianwindandClaude Opus 5 61f8887319 fix(integrationer): en adresse kan læses helt, også på en telefon
Feed'et, mailadressen, krogen, curl-eksemplet, CalDAV-serveren og de to
redirect-URI'er lå i skrivebeskyttede <input>. Et input ombryder ikke — det
ruller indeni — så på en telefon stoppede adressen midt i kassen, og resten
fandtes kun ved at trække inde i feltet. Der var ikke engang et rullepanel at
se: feltet så ud som om det indeholdt det, der stod i det. Målt ved 390 px var
kassen 306 px og krogens adresse 634.

De er nu <output>. Det er stadig et felt, en <label for> kan pege på, så
navnene på felterne bliver ved med at høre sammen med dem, og det er tekst,
der ombryder. `user-select: all` beholder det ene, inputtet gjorde bedre: ét
tryk tager hele adressen.

Det samme gjaldt invitationslinket i en delt note, som vises netop når der
ikke er nogen post at sende det med — altså når det er det eneste, der findes.

Prøven nævner felterne ved navn frem for at forespørge på en klasse. Den, der
måler `.field output` og finder nul, består også den dag felterne er lavet om
til noget andet klippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:18:49 +02:00
kristianwindandClaude Opus 5 72637a153a fix(kalender): en abonnementsadresse skubber ikke siden ud over skærmen
Adressen på et kalenderabonnement blev vist i en punktliste uden noget, der
lod den ombryde. En URL er ét ord på et par hundrede tegn uden et mellemrum i,
så den kan ikke bryde af sig selv — og det bredeste element på en side
bestemmer sidens bredde. På en telefon betød det, at hele indholdsspalten fik
et vandret rullepanel, ikke bare den ene linje.

`overflow-wrap: anywhere` er det, resten af indstillingerne allerede bruger til
et token og en hemmelighed; listen her var den, der blev glemt. Samtidig får
den layout frem for at ligge som løse inline-spans: navn, adresse og knap
under hinanden, uden punkttegnets indrykning.

Prøven måler `.content` og ikke dokumentet. Skallen holder selv siden på
skærmens bredde, så en måling på documentElement består med fejlen i behold —
den blev skrevet først og bestod, hvilket er grunden til at kommentaren står
der.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:11:26 +02:00
Kristian Wind f06a25b43e Merge pull request #73 from kristianwind/dagens-plan-kan-ses
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Failing after 10m29s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
feat(plan): dagens plan står på I dag, og ikke kun i en besked
2026-09-09 10:32:28 +02:00
kristianwindandClaude Opus 5 6cf184caa6 feat(plan): dagens plan står på I dag, og ikke kun i en besked
Planen var en notifikation og ikke andet. Klokken klippede den af efter
én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen
steder hen, fordi en plan hverken er en note, en opgave eller et projekt.
Det eneste sted, den landede hel, var push-beskeden på telefonen.

Nu står den som et kort øverst på I dag, dér hvor man kigger, når man
starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver.

Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger.
En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og
vigtigere: en plan, der siger noget andet, når man kigger på den, end den
sagde, da den kom, er værre end begge dele hver for sig. Kortet siger
også, hvornår den blev lavet: en plan fra klokken syv er en anden slags
oplysning klokken fire end klokken otte.

En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan
under overskriften "i dag", er værre end et tomt kort.

"Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en
besked om noget, man står og kigger på — den slags lærer folk at ignorere
klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at
lave planen på stedet, så funktionen ikke kun findes for dem, der vil
vækkes.

Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i
stedet for at blive klippet af.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:27 +02:00
Kristian Wind 3c14906db2 Merge pull request #72 from kristianwind/dokumenter-det-der-blev-bygget
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m44s
CI / Docker image builds (push) Successful in 1m50s
CI / Go (push) Failing after 14m28s
docs: skriv det ned, der er blevet bygget
2026-09-09 09:02:58 +02:00
kristianwindandClaude Opus 5 70d7398d5a docs: skriv det ned, der er blevet bygget
API-dokumentationen har fulgt med hele vejen — der er en prøve, der fejler,
hvis en rute ikke står i openapi.yaml, og den fangede mig fire gange i går.
Brugervejledningen har ikke fulgt med, og det er den, folk læser.

Nyt: en side om beskeder — hvad der udløser en, hvorfor gentagne
rettelser bliver slået sammen, push pr. apparat og mærket på ikonet. En
om at åbne links i en anden browser, som mest af alt handler om, hvad en
webside ikke kan: hverken se hvilke browsere der findes eller vælge
mellem dem. Og en om tællingen af installationer, som ikke stod nogen
steder overhovedet — en melding, der er slået til som udgangspunkt, har
en pligt til at være skrevet ned, også når ingen har spurgt.

Rettet: AI-siden beskrev to funktioner, hvor der nu er syv, og
listen over hvad der sendes hvorhen var derfor forkert som en påstand om
noget privat. Noter-siden manglede deling med en e-mailadresse og hvad
`@Sofie` gør. Mail-siden manglede den anden vej ind i indbakken.
Konfigurationen sagde stadig, at versionstjekket kun kunne se efter.

Og tagbeskeden når nu frem. Den ER annonceringen — skrevet på produktets
eget sprog til dem, der bruger det — og udgivelsen på GitHub blev fyldt
med en autogenereret liste over commit-overskrifter i stedet, som er
skrevet til dem, der læser koden. Nu står teksten øverst og listen under
den. Linjen med linket til udgivelsen selv tages af: en side, der linker
til sig selv, hjælper ingen.

Dokumentationen bygges nu også på en PR. `mkdocs build --strict` fejler
på et link til en side, der ikke findes, og at bygge først efter merge er
at få det at vide på main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:52:54 +02:00
Kristian Wind 1cabbb7a78 Merge pull request #71 from kristianwind/hvilken-browser-aabner-linket
feat(links): vælg hvilken browser et link åbner i
2026-09-09 08:50:30 +02:00
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ønsket var en liste over de installerede browsere. Den findes ikke: en
webside kan hverken se, hvilke browsere der er på maskinen — listen er
lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på
nettet — eller bede systemet om at bruge en bestemt af dem.

Det eneste håndtag er de URL-skemaer, browserne selv registrerer:
firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve,
og man kan ikke spørge, om de findes. Så indstillingen er skrevet som
det, den er — et forsøg med et faldback — frem for som et løfte, der
ville holde på telefonen og ikke på en Mac.

Valget står i localStorage, ikke på kontoen. Samme grund som
sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved.
firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg,
der fulgte kontoen, ville være forkert det ene af de to steder hver gang.

Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i
beskrivelser, i noter og i indstillingerne, og en regel, der skulle
gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
Kun udadgående links; programmets egne ruter går gennem SvelteKit som før.

To ting, målingen rettede undervejs:

Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen
Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven,
der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet
forsøgt i produktion, og indstillingen ville have set ud til at virke,
fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet:
et ukendt skema afbryder navigationen frem for at forlade siden.

Og faldbacket troede sig blokeret hver eneste gang. window.open med
noopener returnerer altid null — det står i specifikationen — så
kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og
den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste,
der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener
ryddes bagefter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:41:21 +02:00
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:55:53 +02:00
141 changed files with 14540 additions and 581 deletions
+21 -1
View File
@@ -97,7 +97,13 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
@@ -271,3 +277,17 @@ jobs:
set -eu
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
docker buildx build --platform linux/amd64 --output type=cacheonly .
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
# Det kræver `--load` to gange, og det er et halvt minut per image på en
# runner, der deles med Yggdrasils produktionscontainere og allerede har
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
# autoritativ for de offentlige projekter.
#
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
+91 -1
View File
@@ -40,11 +40,51 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
# The iOS app's shell: the interface built the way the app carries it, and
# copied into the Xcode project. No Xcode here — that build happens on a Mac —
# but a config the CLI cannot read, a webDir that moved, or an app build that
# stopped compiling all fail at this step rather than on the day somebody sits
# down to ship.
app:
name: iOS app shell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install
run: |
(cd web && npm ci)
(cd app && npm ci)
- name: Build and sync
working-directory: app
run: npm run sync
# The app build must not register the service worker — WKWebView has none
# for the app's own scheme. The browser build registers it from index.html.
- name: No service worker in the app build
run: |
if grep -qF 'serviceWorker.register' web/build/index.html; then
echo "The app build registers a service worker in index.html."
exit 1
fi
test -f app/ios/App/App/public/index.html
echo "App build in the Xcode project: $(find app/ios/App/App/public -type f | wc -l) files."
spec:
name: OpenAPI is valid
runs-on: ubuntu-latest
@@ -117,9 +157,59 @@ jobs:
# Build only — publishing happens on a tag, in release.yml. This job exists
# so a Dockerfile that no longer builds fails on the pull request rather
# than at release time.
#
# Loaded into the local daemon rather than discarded, because the step below
# has to inspect what came out.
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: verdande:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: urd:ci
build-args: EDITION=notes
cache-from: type=gha
cache-to: type=gha,mode=max
# The claim this repository makes about urd is that it is the same program,
# not a fork — and that claim is checkable rather than a sentence in a README.
#
# Stages 1 and 2 are identical for both builds, and the edition is set with
# ENV, which is image *config* rather than a layer. So the two images must
# have the same layer digests and differ only in that one variable. A change
# that made them diverge — a second COPY, a conditional RUN, a separate build
# of the binary — would quietly turn "one source" into two, and the only
# symptom would be a bug fixed in one product and not the other.
#
# This was impossible until the frontend build became deterministic: SvelteKit
# put Date.now() in version.json, so no two builds ever matched. See the note
# in web/svelte.config.js.
- name: The two images are one build
run: |
set -eu
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
echo "The verdande and urd images do not share their layers:"
layers verdande:ci
layers urd:ci
echo "They are built from one source, so this is either a Dockerfile that"
echo "now builds the binary twice, or a stage that is no longer shared."
exit 1
fi
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
# And the half that stops the check above from passing over two identical
# images: they must actually differ in the one way they are supposed to.
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
echo "verdande: $(edition verdande:ci)"
echo "urd: $(edition urd:ci)"
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
+24 -6
View File
@@ -1,8 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -10,6 +23,11 @@ on:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
pull_request:
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
@@ -44,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+57
View File
@@ -17,6 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
# commit'en uden den.
fetch-depth: 0
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -60,7 +65,59 @@ jobs:
#
# Created after the image, so a release never announces a version that
# failed to build.
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
# commit-overskrifter, som er skrevet til dem, der læser koden.
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
- uses: softprops/action-gh-release@v2
with:
body: ${{ steps.notes.outputs.body }}
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
# ændringer det var, kan læse det samme sted.
generate_release_notes: true
make_latest: true
+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/
+35 -1
View File
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
# Passed into the frontend build, because SvelteKit's default build version is
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
# the service worker, changed the content hash of every chunk referencing them, and
# so changed those chunks' filenames. Every build of one commit produced different
# bytes. See the long note in web/svelte.config.js.
ARG VERSION=dev
ENV VERDANDE_VERSION=${VERSION}
# The frontend is built when it is present. During backend-only development it is
# not, and the build must still produce a working image rather than failing on a
# missing directory — so this stage falls back to a placeholder page. The Go stage
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
#
# "Reproducible" was false for a year and nobody read the sentence that said it:
# the flags above do their job, and the frontend embedded below carried a
# millisecond timestamp, so every build of one commit produced a different binary.
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
# match rather than trusting this comment.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-o /verdande ./cmd/verdande
# --- 3. what actually ships --------------------------------------------------
#
# Two images come out of this stage, and the only difference between them is one
# environment variable:
#
# docker build . → verdande
# docker build --build-arg EDITION=notes → urd
#
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
# the same build rather than a rebuild that happens to match. That is the point:
# urd is not a fork, and "built from the same source" is a property of the layers
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
# the two images share every layer digest and differ only in that config — which CI
# asserts on every pull request.
FROM gcr.io/distroless/static-debian12:nonroot
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
# so one above the FROM above would be invisible down here. `full` by default,
# because that is what this program has always been and an unqualified build must
# not quietly produce the other product.
ARG EDITION=full
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
# so the container needs a tz database and CA certificates for outbound SMTP.
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
# nightly backups.
VOLUME ["/data"]
ENV VERDANDE_DATA_DIR=/data \
VERDANDE_ADDR=:8080
VERDANDE_ADDR=:8080 \
VERDANDE_EDITION=${EDITION}
EXPOSE 8080
USER nonroot:nonroot
+56 -7
View File
@@ -15,12 +15,30 @@
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 — 63 routes and
five of nine MCP tools. See
[Two editions](docs/editions.md).
## Status
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
@@ -37,17 +55,36 @@ and an MCP endpoint so Claude can work your task list directly.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project;
`#project` and `[[another note]]` are links, and a task shows the notes that
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
lists, quotes, fenced code. A note is shared by filing it in a project, by
handing it to one person — including somebody who has no account yet, who gets an
invitation that becomes the share when they sign up — or by writing `@Sofie` in
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
everybody arrives through an invite link, either to a project or — from
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
by somebody else.
everybody arrives through an invite link — to a project, to a single note, or
from **Settings → Brugere** to the instance itself. Nobody's password is ever
chosen by somebody else.
- **Live sync.** A change made by somebody else appears without a refresh.
- **Notifications that are worth reading.** A bell for what other people did to what
you share — folded, so one person typing for five minutes is one line — pushed to
your devices and counted on the app's icon in the Dock.
- **AI that suggests and never writes.** Optional, off by default, your own key and
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
action the mail asks for, and answers questions about your own notes with links to
what the answer rests on. Every one of them arrives as a line you accept, edit or
refuse — a model that edits your tasks directly has to be right every time to be
worth having.
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
or a POST of one line of text from a shortcut on your phone, a script or any
service that can call a URL.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
@@ -113,6 +150,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
@@ -203,6 +251,7 @@ to put one that the operator would ever see.
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
+9
View File
@@ -0,0 +1,9 @@
{
"appId": "dk.nolimit.verdande",
"appName": "Verdande",
"webDir": "../web/build",
"ios": {
"contentInset": "never",
"scheme": "Verdande"
}
}
+13
View File
@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
+380
View File
@@ -0,0 +1,380 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = App;
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "CapApp-SPM";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
isa = XCSwiftPackageProductDependency;
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+44
View File
@@ -0,0 +1,44 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: "Default Configuration",
sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+79
View File
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CAPACITOR_DEBUG</key>
<string>$(CAPACITOR_DEBUG)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Verdande</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- A self-hosted Verdande on a home network may have no certificate:
http://192.168.1.20:8080 or http://verdande.local. This allows plain
http to local addresses only; anything on the internet must still be
https. -->
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
+24
View File
@@ -0,0 +1,24 @@
import UIKit
import Capacitor
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = CAPBridgeViewController()
window?.makeKeyAndVisible()
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
}
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+25
View File
@@ -0,0 +1,25 @@
// swift-tools-version: 5.9
import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package(
name: "CapApp-SPM",
platforms: [.iOS(.v15)],
products: [
.library(
name: "CapApp-SPM",
targets: ["CapApp-SPM"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3")
],
targets: [
.target(
name: "CapApp-SPM",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
]
)
]
)
+5
View File
@@ -0,0 +1,5 @@
# CapApp-SPM
This package is used to host SPM dependencies for your Capacitor project
Do not modify the contents of it or there may be unintended consequences.
@@ -0,0 +1 @@
public let isCapacitorApp = true
+1
View File
@@ -0,0 +1 @@
CAPACITOR_DEBUG = true
+1239
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "verdande-app",
"version": "0.1.0",
"private": true,
"description": "The iOS app: the web interface bundled into a native shell.",
"type": "module",
"scripts": {
"build": "cd ../web && VERDANDE_TARGET=app npm run build",
"sync": "npm run build && cap sync ios",
"open": "cap open ios"
},
"dependencies": {
"@capacitor/core": "^8.5.3",
"@capacitor/ios": "^8.5.3"
},
"devDependencies": {
"@capacitor/cli": "^8.5.3"
},
"engines": {
"node": ">=22.12"
}
}
+7 -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
@@ -105,6 +110,7 @@ func run() error {
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
+78 -9
View File
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
a page that repopulates a password field is one that will eventually leak it into a
screenshot.
## The shape of all of it
**The model proposes; you decide.** Nothing is written until you say yes, and the
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
p1 #Firma` — sitting in a field you can edit before you accept it.
That is not caution for its own sake. A model that edits your tasks directly has to
be right *every* time to be worth having. One that suggests only has to be right
often enough to save you some typing, and when it is wrong you have already seen it.
It is also why the suggestion is a line rather than a set of fields: there is one
reading of "i morgen" in this program, and a suggestion goes through the same
[quick add](quick-add.md) parser as anything you type — including after you have
edited it by hand.
The model is given the names of the projects that exist and may name no others. A
`#Projekt` it invented would create one by accident the first time you accepted it,
and a guess must not be able to move a task somewhere you will not look for it.
## What it can do
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
seven concrete actions and creates them as sub-tasks. Written in your language.
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
line it suggests how you would have written it if you had finished it — project,
date, priority — with one sentence on what it read out of the task. Accept, edit, or
skip, one line at a time.
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
somebody committed to or was asked to do; a heading is not a task and a topic that
was discussed is not a task. Each suggestion quotes the words it came from, so a
wrong one can be seen through rather than merely being wrong. The note itself is
never edited — the task carries `[[the note's title]]` in its description instead.
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
default; pick the hour yourself. One notification about what is due today and what
has been missed, and what to start with — and the same text as a card at the top of
**I dag**, where you are looking anyway when you start the day. The notification is
the reminder; the card is where it stays.
The card shows the plan *as it was sent*, with the time it was made, rather than a
new one each time you look: a plan that says something different when you look at it
than it said when it arrived is worse than either. **Forny planen** makes a new one
when the day has moved, and it does not send a notification about something you are
already looking at. With the morning notification off, the card offers to make one
on the spot.
The counts are done in the database and handed to the model as facts it may not
recompute — a plan that says four tasks where there are three is worse than no plan.
With no model configured the counts stand on their own, which is what you needed to
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
**Mail that becomes a task you can act on.** Where a
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
12"*, the model writes the action the mail asks for — and leaves the mail alone if
it asks for nothing. The subject stays in the description, so a mail it misread can
still be read. If the model is unavailable the task is named sender and subject as
before: a model that is down must not be able to stop the post.
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
Anders i august?"* — search finds what contains the words, this answers the
question, and the answer links to the notes and tasks it rests on. If nothing
matches, it says so; a guess would be useless here, because you are asking precisely
because you cannot remember.
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
actions, created as sub-tasks.
**A weekly summary.** A short note over what is outstanding: what looks urgent and
what appears to be slipping. At most six lines — it is a prompt to think, not a
report.
what appears to be slipping. At most six lines — a prompt to think, not a report.
## What is sent
Only what the feature needs: for a split, the task's title and notes; for a
summary, the titles, dates and priorities of your open tasks. Never comments,
attachments, or anything from a project the feature was not invoked on.
Only what the feature needs, and only when you invoke it:
Nothing is sent to anybody until you configure a provider. With a local model,
nothing leaves your machine at all.
| Feature | What leaves the instance |
| --- | --- |
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
| Tasks in a note | That one note's text, and your project names |
| Today's plan | The titles and dates of what is due or overdue today |
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
| Ask | Your question, and the notes and tasks the search matched |
| Split a task | That task's title and notes |
| Weekly summary | The titles, dates and priorities of your open tasks |
Never comments, never attachments, never a project the feature was not invoked on,
and never anything belonging to somebody else. Nothing is sent to anybody until you
configure a provider, and with a local model nothing leaves your machine at all.
## Turning it off
+4 -3
View File
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
+77
View File
@@ -0,0 +1,77 @@
# The install count
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
{
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
"version": "v0.44.1"
}
```
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 — 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.
## On by default, off in one click
The alternative that was considered first was compulsory, and the argument against
it is practical rather than principled: it cannot be enforced. The domain can be
blocked, the binary can be patched, and a fork removes it in its first commit. So
mandatory buys nothing over on-by-default while costing the trust the rest of this
program works for.
Which is why the first administrator to sign in is **told, unprompted**, with the
real id, the real version and the real address in front of them, and two buttons.
On by default is only defensible if it is said out loud without being asked; a
settings page where it is stated fully is not the same thing, because that page is
opened by people who already suspect there is something to read.
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
!!! note "This disagrees with the update check, on purpose"
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
a self-hosted app reaching out unbidden has broken the deal its operator made by
self-hosting. That is the same situation as this one, and the two defaults do
not agree. The case for treating them differently: the update check reveals an
instance to a third party (GitHub) and returns something you can act on, while
this returns nothing and goes to the project. Whether that is a distinction or
an excuse is a judgement call, and it was made deliberately.
## What the collector cannot see, and what it can
A request has a source address whether or not anybody writes it down. The collector
stores none and logs none, and the code is arranged so the address never reaches a
function that could — the table has nowhere to put one. But the request passes
through a reverse proxy and a tunnel on the way, and those keep their own logs.
"We send no IP" would be a half-truth; what is true is that nothing here records
one.
The ping is not authenticated either, so anybody can post a made-up id and inflate
the count. That is an accepted trade: a token would have to ship inside every
install anyway.
## Running your own count
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
nobody made a collector must not become a bucket anyone can post into.
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
published as a fact — a bad release, a DNS change that quietly stops the pings, or
lost rows all look like "fewer people use this now" — and a small number mostly says
how little it takes to move it.
## When it cannot reach
The reason is written down and shown on the settings page. A beacon that cannot
reach its collector otherwise fails in complete silence: it tries again tomorrow,
the page says "last sent" with a date that quietly gets older, and nobody notices.
+11 -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
@@ -36,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
Any Go duration: `720h`, `30m`, `45s`.
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
## Mail
| Variable | Default | What it does |
@@ -127,11 +131,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
With the check on, administrators are told **once per version** — in the bell, in
push and on the app's badge — rather than having to open the settings page and
look. The notification leads to the page where the restart button is. With the
check off nothing is fetched and nothing is said; see
[Notifications](notifications.md).
All three together let **Settings → Notifications** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. The request asks
+205
View File
@@ -0,0 +1,205 @@
# 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 | 137 |
| 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.
## Running the notes edition
There is one image. The edition is an environment variable on the container:
```
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
ghcr.io/kristianwind/verdande:latest
```
No second image and no rune of its own, deliberately.
The edition is for an operator who wants an instance that genuinely has no task
routes — the routes are absent from the router, not hidden in the interface. It is
**not** how urd is normally reached. That is the second door, below.
Two details that surprise people who look inside:
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
both editions. Not an oversight: it is the same binary, and renaming the file per
edition would be a migration hazard in exchange for nothing a user sees.
- **The icons for both editions ship in the one image.** The server decides which
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
and never reads the manifest, so renaming paths would have left iOS with the
wrong mark.
## The second door: two apps, one instance
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
database, one login, one set of links between notes and tasks — and two things you
can install.
| | verdande | urd |
|---|---|---|
| entry | `/` | `/urd` |
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
| manifest `id` | `/` | `/urd` |
| tab and mark | verdande, ᚹ | urd, ð |
| database, account, login | the same | the same |
An operating system does not install a URL, it installs a **manifest**, and the
identity of an installed app is that manifest's `id`. Two manifests on one origin
with different ids are two apps; two URLs sharing one manifest are one app with a
bookmark. That is the whole mechanism.
The shell is served per door rather than patched by JavaScript after the page
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
the manifest's icons altogether.
urd's manifest is built from verdande's rather than written beside it, so the
fields the two genuinely share — the colours, the display mode, the language —
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
`scope`, the two names and the icons. An icon in the built manifest that urd has no
file for stops the server rather than being passed through, because the symptom
would otherwise be urd's install prompt quietly showing Verdande's mark.
### The face is a preference, not a gate
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
tab. It is kept strictly apart from the notes edition in the code, and the
distinction is the one thing worth reading twice:
| | what it means | what follows |
|---|---|---|
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
Conflate them and the urd face starts hiding data rather than chrome: a notebook
would stop showing its tasks because somebody chose a different entrance. A test
asserts the opposite directly — the urd window still gets 200 from
`/api/v1/tasks`.
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
two apps be two apps: they share an origin, so they share localStorage, cookies and
the service worker. `sessionStorage` is per window, and an installed PWA is its own
window. It survives a reload, which it must — after the first navigation the
address is `/noter` and nobody passes through `/urd` again.
!!! note "One thing the two doors cannot have separately"
Theme, typeface and text size are shared, because they are in the localStorage
the origin shares. That was a deliberate choice: splitting them per face would
mean migrating everybody's stored settings for a difference few people want.
## Moving between the two, measured
A `/data` directory moves between the editions in **both directions, losslessly**.
Same binary, same schema, same migrations — so this is not a feature that was built,
it is a property that was checked:
| step | result |
|---|---|
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
| the same directory, back to `full` | the task is still there; so is the note |
The ownership stayed at the panel's service account through all three.
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
getting notes **into other programs** — it writes one `.md` per note and carries the
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
is the right division of labour, because the database already holds those.
!!! note "The task rows stay"
An instance run as `notes` still has its tasks in the database; they are simply
not served. Harmless on a fresh instance, dead weight on a converted one — and
worth knowing before handing a converted instance to somebody else.
## 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 63 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 63 withheld routes:
| | what |
|---|---|
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
| 2 | the Todoist and CSV imports, which create tasks |
| 4 | project templates — a template is a project plus sections plus tasks |
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
| 1 | the inbox hook, which pushes a line of text in as a task |
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
shorter. A tool a model can call is a surface, and one that answers "there are no
tasks" is worse than one that does not exist: the model tries it, gets an empty
answer, and tells the person there is nothing to do.
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 and task tools
- AI settings, `ask`, and the note actions
!!! warning "Still being decided"
Four connectors are reachable in urd today and have not been settled either
way, because each has a plausible notes reading and withholding them would be
deciding what this product is rather than closing a hole: the mail-in address,
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
receive as **tasks**, so in urd they connect and then have nowhere to put
anything. The same applies to one handler rather than a route:
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
## 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.
+22 -4
View File
@@ -26,20 +26,38 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
board and calendar views, and foldable groups over the projects in the sidebar.
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
settings themselves, by what they *do* rather than only by what they are called,
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
settings pages carry the same field at the top of their own list.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person, or file it in a project; `#project` and
`[[another note]]` are links, and a task shows what has been written about it.
single note with a person — by name, by email address, or by writing `@Sofie` in
the text — or file it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
greyed, at the foot of the list until a moment you pick — without touching when
it is due.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
answers questions about your own notes with links to what the answer rests on —
every one of them as a line you accept, edit or refuse.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
address, or a POST from a shortcut on your phone, a script, or any service that
can call a URL.
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
comes off once it has, so a starred inbox empties as the tasks are made instead
+88
View File
@@ -0,0 +1,88 @@
# The iOS app
The iOS app is the web interface bundled into a native shell with
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
Svelte build runs in both, and everything that differs between a browser and the
app lives in `web/src/lib/platform.js`.
It talks to **any** Verdande server. The first screen asks for the address, checks
it with `/api/v1/ping`, and then signs in there.
## How the app differs from a browser
| | Browser | App |
|---|---|---|
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
| Which server | the one it was loaded from | the one its owner typed in |
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
| Service worker, Web Push | yes | no — WKWebView has neither |
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
| Creating the first account | yes | no — done in a browser, then sign in from the app |
The server side of this is `client: "app"` on `/auth/login`, the
`capacitor://localhost` CORS rule, and the ticket. All three are in
`docs/openapi.yaml`.
An app session slides: every use moves its end forward, up to
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
settings with the name the app gave itself, and can be signed out from there.
## Building
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
```sh
cd app
npm ci
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
```
On a Mac with Xcode:
```sh
npm run open # opens ios/App/App.xcodeproj
```
Choose a simulator and press Run. In the app, type the address of a Verdande —
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
`Info.plist`), so a home server without a certificate works on the same network;
anything on the internet has to be `https`.
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
generated config are not — `npm run sync` puts them there.
The icon and launch image come from `web/static/icon.svg`:
```sh
cd web && node scripts/generate-ios-assets.js
```
## Putting it on a phone
These steps are the account owner's — they involve Apple's terms, a payment and
signing keys.
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
should own the app.
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
`dk.nolimit.verdande`; it can change until the first upload and never after.
3. *Product → Archive*, then *Distribute App → TestFlight*.
4. In App Store Connect, add testers to the TestFlight build.
## Not yet
- **Notifications.** Web Push does not exist in the app. Notifications on an
iPhone go through Apple's APNs, and the key that sends them is bound to the
app's bundle id — so it cannot live on every self-hosted server. That means a
small relay run by the app's publisher, which every instance with an app user
would talk to. That is a new connection from other people's servers, and it
waits on a decision before it is built.
- **The session token in the Keychain.** It is in the WebView's own storage for
now, behind one object in `platform.js` so the Keychain plugin can replace it
without anything else changing.
- **Passkeys** in the app.
- **Share sheet** (share text or a link *into* Verdande from another app).
+52
View File
@@ -0,0 +1,52 @@
# Opening links
A link in a task or a note opens in whichever browser your system opens links in.
**Settings → Links** can try to change that — and the honest description of what it
does is *try*.
## What a web page cannot do
Two things, and they set the limits of this feature:
- **It cannot see which browsers are installed.** The list is deliberately closed:
it would be a fingerprint, distinctive enough to follow somebody from site to
site.
- **It cannot ask the system to use a particular one.** A link goes to the default
browser, and there is no API that says otherwise.
The only handle that exists is the URL schemes browsers register for themselves —
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
way to ask whether one exists. You can only try it.
## So the setting is an attempt
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
link, that scheme is tried first; if nothing has happened within about half a
second, the link opens the ordinary way.
The fallback is the whole reason this is safe to turn on. Without it, a setting
that works on your phone would leave links doing nothing on your desktop.
In practice: **it works on a phone**, where browsers register these schemes, and
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
the usual exception. There is a **Prøv det** button for exactly this reason — you
cannot ask whether a scheme exists, so it is better to find out on example.dk than
on a link you meant to follow.
## Writing your own
Three placeholders, because browsers disagree about the shape they want the address
in:
| | |
| --- | --- |
| `{url}` | The address as it is — `x-safari-https://example.dk` |
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
## It belongs to the machine, not the account
The choice is kept in the browser you are sitting at, not on your account — the
same rule the sidebar's width follows. `firefox://` works on the phone and does
nothing on the Mac, so a choice that followed the account would be wrong in one of
the two places every time.
+33
View File
@@ -67,3 +67,36 @@ immediately.
!!! tip "Keep the address to yourself"
Treat it like a secret. If you publish it somewhere, rotate it.
## Pushing from another program
The address above needs a mail server. When what you want is a shortcut on your
phone, a script, or any service that can call a URL, there is a second way in:
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
POST a line of text to.
```bash
curl -X POST https://todo.example.dk/inbound/hook/<token> \
-H "Content-Type: text/plain" \
-d "Ring til Anders i morgen p1 #Firma"
```
The body is read three ways, because those are the three you meet: plain text from
a shortcut that just sends what was selected, JSON with a `text` field from a
service, or a form field from a webhook form. The first line becomes the task and
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
top of the app.
**The address is a key.** It has its own token rather than sharing the mail one:
the two are the same kind of secret but they leak separately, and a URL that has
been sitting in a shortcut on a lost phone has to be replaceable without changing
the address other people have in their address books. A token here can do one
thing — put a task in your inbox — which is why it is not an
[API token](api.md), which can do everything. Change it under the same setting; the
old one stops working at once.
**POST only.** A GET would be easier to call, and that is exactly the problem: a
browser prefetch, a link check in a chat, and a revisited history entry would all
create tasks nobody asked for.
+49
View File
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
it is a document that can run script, and a note is not a place to run somebody
else's.
**Click one to see it whole.** In the note it is scaled down to the width of the
column — a photo off a phone is four thousand pixels across — so what you are
looking at is a thumbnail that does not look like one. The full-size view has a
button that copies the picture to the clipboard, and a link that opens it in its
own tab. Escape closes it and puts the cursor back where it was.
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
little else, and a photo off a phone is a JPEG — without the conversion the copy
either fails or succeeds with something nothing will paste.
### Code blocks
A fenced block is coloured for the language it names, and guesses when it does not.
@@ -79,6 +89,37 @@ There is no folder tree. Projects, groups and labels already exist, are already
shared and already have roles — a second hierarchy to file things in is one too
many, and a note in a project inherits every one of those answers.
### Sharing one note with one person
Filing a note in a project is the right tool when the note belongs with a body of
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
at the foot of the note does the second: this note, this person, as reader or
editor. Only the note's owner can share it.
Type an **email address** rather than picking a name, and one of two things
happens. An address that already has an account is shared with straight away. One
that does not gets an invitation — a link that creates the account and the share
together, so the note is theirs the moment they sign up. Invitations wait in the
panel under the note, where they can be withdrawn; that is the only way back, since
only the token's hash is stored and a link sent to the wrong address cannot be
looked up.
### Naming somebody in a note
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
note *is* wanting them to read it, and doing it as two acts — write the name, then
open the panel and find the person — meant the second was forgotten, and the note
sat there addressing somebody who could not see it. The editor suggests names after
`@`, the same way `#` suggests projects.
- **Only the owner's mentions share.** An editor may write the name; giving
somebody else's note away is the owner's alone.
- **As a reader**, which is the least that satisfies "look at this". A role you
have raised in the panel is never lowered by a mention.
- **Only a name that has just appeared** counts. A note is saved on every typing
pause, so without that every save would be another share and another notification
about the same name.
## Finding
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
@@ -97,6 +138,14 @@ Search finds a word wherever it is:
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
+52
View File
@@ -0,0 +1,52 @@
# Notifications
What other people did that you need to know about, and what the day has in it.
Verdande tells you in three places at once, and they are the same thing said three
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
the app's icon.
## What causes one
| | |
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
| A new version is out | Administrators only, once per version |
Not your own actions: the server does not tell anybody what they just did
themselves. And not everything that happens — a bell that rings at every change in
a shared project is a bell people stop looking at.
**Repeat edits are folded.** A note is saved on every typing pause, so one person
writing for five minutes would otherwise be twenty identical lines. While the
message is unread, a further edit moves it back to the top and says who touched it
last. Once you have read it, the next edit is a new thing to be told.
## Push, and the badge
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
device: the phone in your pocket and the laptop you have open are different
answers, and a permission granted on one is not the other's.
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
PC — needs the app to be installed (added to the Dock, or installed as an app in
Chrome or Edge). Half the point of being told is being told while you are doing
something else, and the icon is the only place the app can say anything then. It
counts unread messages and goes down by itself as you read them. Firefox does not
support it; nothing else changes there.
The sentence you see is written in your browser from *what happened* and *who did
it*, so an English interface gets an English bell. The server's own wording is
Danish and is used only for push, which has no dictionary to look a phrase up in.
## Telling you about a new version
If the [update check](configuration.md) is on, administrators get one notification
per version — never one a day about the same one — and it leads to
**Settings → Beskeder**, where the button that restarts the instance to update it
sits. With the check off, nothing is fetched and nothing is said.
+325 -9
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.
@@ -110,6 +120,19 @@ paths:
properties:
email: { type: string, format: email }
password: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app. Its session comes back as
`session_token` instead of a cookie and slides — every use
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
(180 days) unused. Left out, it is a browser login.
device_name:
type: string
maxLength: 100
description: "Required with `client: app`; shown in the list of sessions."
example: Kristians iPhone
responses:
"200":
description: |
@@ -122,7 +145,14 @@ paths:
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
session_token:
type: string
description: |
An app login only, also while `totp_required` — the app
sends it to `/auth/login/totp` too. Sent from then on as
`Authorization: Bearer`. A browser never receives it.
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/login/totp:
@@ -263,6 +293,49 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/default-reminder:
put:
tags: [auth]
summary: The account's standing alarm on tasks that have a clock.
description: |
Reminders have always had to be set one at a time: a task written as
"i morgen kl 9" got a time and no alarm, because nothing creates a reminder
by itself. That is the right default for a task with no clock — there is no
moment to ring at — and an errand you walk every time for a task with one.
`minutes` is relative to the due moment and **negative for before it**, the
same convention `reminders.offset_min` uses because that is the column the
value ends up in: the store adds it to the due time. `0` is exactly at the
due moment. `null` turns the default off, which is where every account
starts — switching it on for everybody in a migration would have meant
alarms nobody asked for, on tasks already lying there.
A positive number is refused. An alarm after the thing it is warning about
is not a reminder, and accepting one here would make the single mistake this
value can make — a sign the wrong way round — look like a choice.
The default never stacks on an alarm the task already has, and a task can
opt out as it is written with "ingen alarm" in the quick-add line.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
minutes:
type: [integer, "null"]
maximum: 0
minimum: -10080
description: |
Minutes relative to the due moment, negative for before it.
Null is off.
example: -10
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/nav-order:
put:
tags: [auth]
@@ -848,6 +921,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
@@ -2307,7 +2394,15 @@ paths:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Who the project is shared with.
summary: Who the project is shared with, who can be added, and who has been invited.
description: >
Three lists in one answer, so the share panel opens complete in a single
round-trip. `candidates` is everybody with an account here who is not
already on the project — the instance's accounts are a closed, invited set,
so listing them to each other is the address book the feature needs, not a
disclosure. `pending` is the invitations sent to people who have no account
yet: without them an invitation sent yesterday is in neither of the other
two lists, and gets sent a second time.
responses:
"200":
description: OK
@@ -2319,6 +2414,17 @@ paths:
members:
type: array
items: { $ref: "#/components/schemas/Member" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
pending:
type: array
items:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [editor, viewer] }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members/{userID}:
@@ -2360,8 +2466,19 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
summary: Remove somebody from a project — its owner, or yourself.
description: |
Two things through one route: the owner taking somebody off, and anybody
taking themselves off. The second is what makes a share something you can
undo. You are let into a project without being asked, so a way out that
does not go through asking its owner is the other half of that.
The owner cannot leave their own project. That is a transfer of ownership,
not an exit, and there is no transfer yet.
Pointing at somebody else's membership without owning the project answers
404 rather than 403: saying "you may not" confirms the membership exists.
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
@@ -2384,13 +2501,32 @@ paths:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
user_id:
type: string
description: >
Somebody who already has an account here, picked from the
candidates in GET /members. One of user_id or email.
email:
type: string
format: email
description: >
An address. If it belongs to an account they are added at
once; otherwise an invitation is sent.
role: { type: string, enum: [editor, viewer] }
responses:
"200":
description: Added straight away — the person already had an account.
content:
application/json:
schema:
type: object
properties:
added: { type: boolean }
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [editor, viewer] }
"201":
description: Created
description: Invited. No account yet, so a link was made.
content:
application/json:
schema:
@@ -2399,8 +2535,30 @@ paths:
link: { type: string }
emailed: { type: boolean }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/invites/{inviteID}:
delete:
tags: [projects]
summary: Withdraw an invitation to a project before it is used. Its owner only.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: projectID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/activity:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
@@ -3072,6 +3230,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]
@@ -3688,7 +3880,21 @@ paths:
/api/v1/ai/plan/now:
post:
tags: [integrations]
summary: Send today's plan now, to whoever asks.
summary: Make today's plan now, to whoever asks.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
silent:
type: boolean
description: >
Make and store it without sending a notification — the card on
Today asking for a plan while somebody is looking at it. A
notification about something in front of you is the kind that
teaches people to ignore the bell.
description: |
The counts come from the database and the sentence from the model, in that
order: a model that counted for itself would sometimes count wrong, and a
@@ -3818,14 +4024,51 @@ paths:
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
`task.moved`, `task.deleted`, `comment.created`, `notification`,
`reminder`.
A browser opens it with its session cookie and from the instance's own
origin. The iOS app cannot put a header on a handshake, so it asks
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
origin without one is refused.
parameters:
- name: ticket
in: query
required: false
schema: { type: string }
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
responses:
"101": { description: Switching protocols. }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/ws-ticket:
post:
tags: [integrations]
summary: A one-use ticket for opening the live-sync socket without a cookie.
description: |
For the iOS app, whose session is a bearer token that a WebSocket
handshake cannot carry. Putting the token itself in the URL would leave a
credential lasting months in every log the URL passes through; the ticket
lasts thirty seconds and opens one connection.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ticket: { type: string }
expires_in: { type: integer, description: Seconds., example: 30 }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/ping:
get:
tags: [integrations]
summary: A liveness check that does not touch the database.
description: |
Also how the iOS app tells a Verdande from anything else at an address
somebody typed in, before anyone has signed in, and which half of the
interface to show. It carries no version: `/version` is behind a login on
purpose, and a public number is a list of the instances running an old one.
security: []
responses:
"200":
@@ -3836,6 +4079,9 @@ paths:
type: object
properties:
status: { type: string, const: ok }
product: { type: string, const: verdande }
edition: { type: string, enum: [full, notes] }
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
# --- data -----------------------------------------------------------------
@@ -4253,6 +4499,10 @@ components:
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
there is no separate service identity — and is refused on `/tokens`.
Or the iOS app's session, the `session_token` from an app login. That
one is a session and reaches everything a browser session does. A
browser's own session is never accepted here.
responses:
BadRequest:
description: The body could not be read.
@@ -4545,8 +4795,9 @@ components:
DailyPlanSettings:
type: object
description: >
One person's morning plan. Off by default — a notification at seven in the
morning that nobody asked for is not a help.
One person's morning plan, and today's plan itself. One resource, because
it is one thing: the plan is the resource and the setting is when it gets
made.
properties:
enabled: { type: boolean }
hour:
@@ -4555,6 +4806,17 @@ components:
last_sent:
type: string
description: The date it last went out, in that person's own timezone.
plan:
type: string
description: >
Today's plan as it was sent — kept rather than recomputed on every
look. A plan that says something different when you look at it than it
said when it arrived is worse than either. Absent when the stored plan
is from another day.
plan_date: { type: string }
plan_at:
type: integer
description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.
AISuggestion:
type: object
@@ -4646,6 +4908,17 @@ components:
never null: a client that has to check for both an absent array and an
empty one will one day check for only one of them.
example: ["labels"]
default_reminder_min:
type: [integer, "null"]
description: |
The account's standing alarm on tasks that have a clock, as minutes
relative to the due moment and negative for before it. Null is off,
which is where every account starts.
A type union rather than a number with a sentinel, because zero is a
real setting here — an alarm exactly at the due moment — so "off" and
"at the time" have to stay different answers.
example: -10
created_at: { type: string, format: date-time }
RecoveryCodes:
@@ -4764,6 +5037,12 @@ components:
it so anything the summary gets wrong is still visible.
example: Chrome på macOS
user_agent: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app, whose `device` is the name it gave itself and
whose `expires_at` moves forward each time it is used.
ip: { type: string }
created_at: { type: string, format: date-time }
last_seen_at: { type: string, format: date-time }
@@ -4836,6 +5115,21 @@ components:
task is still created, in the Inbox — a thought is not thrown away
over a typo — but the caller is told, because the alternative is
that the name vanishes from the title and nothing says why.
unknown_section:
type: string
description: |
The same, for a `/section` that no section of the project the task
landed in answers to. The task is created without a section.
reminder_not_set:
type: string
description: |
Present when the line asked to be reminded and the task has
nothing to count back from — "mind mig en time før" on a task with
a day but no clock. The offset form of a reminder fires relative to
the due moment, so without one the row would sit in the table and
never go off. The task is created; the alarm is not, and this says
so. An alarm that silently never rings is the one way a reminder
must not fail, because nothing afterwards reveals it.
ParsedTask:
type: object
@@ -4847,6 +5141,28 @@ components:
due_date: { type: string, format: date }
due_datetime: { type: string, format: date-time }
recurrence: { type: string }
reminder_offset_min:
type: integer
description: |
When to be told, as minutes relative to the due moment: negative for
before it, zero for at it. Absent when the line asked for no alarm.
Relative rather than absolute, so moving the task moves its alarm with
it. Mind the sign — the store adds this to the due time, so "an hour
before" is -60.
no_reminder:
type: boolean
description: |
The line said it wants no alarm on this one — "ingen alarm". It exists
because of the account-wide default: with alarms on for everything that
has a clock, there has to be a way to say "not this one" in the same
breath as writing it, rather than creating the task, opening it and
deleting the alarm just made.
reminder_at:
type: string
description: |
An absolute alarm, `YYYY-MM-DDTHH:MM`, set when the line gave the
reminder a clock of its own. It takes the day the task is due: "mind
mig kl 8" about something due Friday means Friday at eight.
labels:
type: array
items: { type: string }
+32
View File
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
+56 -2
View File
@@ -24,8 +24,12 @@ type Config struct {
DataDir string
SessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
// AppSessionTTL is how long a session from the iOS app may sit unused before
// it ends. It slides: every use moves the end forward, so a phone in daily use
// stays signed in and one left in a drawer does not.
AppSessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
SMTP SMTP
@@ -38,11 +42,39 @@ 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.
UpdateCheck bool
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
// which is always included. Set by the end-to-end suite, which serves the
// interface from a second host name to test it the way the app runs it. An
// operator has no reason to set it.
AppOrigins []string
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
@@ -136,13 +168,22 @@ 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),
}
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
c.AppOrigins = append(c.AppOrigins, o)
}
}
var err error
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
return nil, err
}
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
return nil, err
}
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
return nil, err
}
@@ -171,6 +212,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)
}
}
}
+89 -7
View File
@@ -39,6 +39,19 @@ type planSettings struct {
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
LastSent string `json:"last_sent,omitempty"`
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
//
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
// andet end den besked, man fik i morges, er værre end begge dele hver for
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
// gæt hver gang man kigger.
//
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
Plan string `json:"plan,omitempty"`
PlanDate string `json:"plan_date,omitempty"`
PlanAt int64 `json:"plan_at,omitempty"`
}
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
@@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings,
if v, ok := stored["last_sent"].(string); ok {
out.LastSent = v
}
if v, ok := stored["plan"].(string); ok {
out.Plan = v
}
if v, ok := stored["plan_date"].(string); ok {
out.PlanDate = v
}
if v, ok := stored["plan_at"].(float64); ok {
out.PlanAt = int64(v)
}
return out, nil
}
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
})
}
// handleGetPlanSettings er både indstillingen og dagens plan.
//
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
// to steder at huske at rydde en plan fra i går.
//
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
// et tomt kort.
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
}
writeJSON(w, http.StatusOK, p)
}
@@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, p)
}
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
type planNowRequest struct {
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
//
// En besked om noget, man står og ser på, er en besked, der lærer folk at
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
// hele dens formål: at vise, at push virker på det her apparat.
Silent bool `json:"silent"`
}
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
//
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
// af, er en indstilling, ingen tør slå til.
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
// lave en plan for den, der har beskeden slået fra.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
var req planNowRequest
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &req); err != nil {
return
}
}
user := userFrom(r.Context())
plan, err := s.buildPlan(r.Context(), user)
if err != nil {
s.internal(w, r, "build plan", err)
@@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
return
}
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
s.internal(w, r, "save plan", err)
return
}
sent := false
if !req.Silent {
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
sent = true
}
writeJSON(w, http.StatusOK, map[string]any{
"plan": plan, "sent": sent,
"plan_at": time.Now().Unix(),
})
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
}
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
p, err := s.planSettings(ctx, user.ID)
if err != nil {
return err
}
now := time.Now()
p.Plan = plan
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
p.PlanAt = now.Unix()
return s.savePlanSettings(ctx, user.ID, p)
}
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
@@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error {
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
// ene overskrive den andens `last_sent`.
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
}
p.LastSent = today
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
+61
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"testing"
"time"
)
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
@@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
}
}
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
}
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("lav planen: %d", resp.StatusCode)
}
if made["sent"] != false {
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
}
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if after["plan"] != made["plan"] {
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
}
if after["plan_at"] == nil {
t.Error("planen bærer ikke, hvornår den blev lavet")
}
}
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
// overskriften "i dag", er værre end et tomt kort.
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
t.Fatal("lav planen")
}
// Sat en dag tilbage, som om den var lavet i går.
user := userID(t, ts, "kristian@example.dk")
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
if err != nil {
t.Fatal(err)
}
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
t.Fatal(err)
}
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if plan, _ := out["plan"].(string); plan != "" {
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
}
}
+224
View File
@@ -0,0 +1,224 @@
package httpapi
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/kristianwind/verdande/internal/config"
)
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
preflight := map[string]string{
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type",
}
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
if resp.StatusCode != http.StatusNoContent {
t.Errorf("app preflight: status %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("app preflight: Allow-Origin %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
}
// An ordinary request from the app can be read.
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("app GET: Allow-Origin %q", got)
}
// Anybody else gets nothing, so the browser keeps them from reading.
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
}
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
}
}
}
// The app asks this before anyone has signed in, to tell a Verdande from a
// router's login page.
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
ts := newTestServer(t)
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ping: %d", resp.StatusCode)
}
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
t.Errorf("ping = %v", body)
}
if _, ok := body["version"]; ok {
t.Error("ping reveals the version to anybody")
}
}
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
h := http.Header{}
if origin != "" {
h.Set("Origin", origin)
}
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
}
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
token := body["session_token"].(string)
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Without a ticket or a cookie: refused.
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
t.Fatal("a socket opened with no credential")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("no credential: %v", resp)
}
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
ticket, _ := body["ticket"].(string)
if resp.StatusCode != http.StatusOK || ticket == "" {
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
}
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
if err != nil {
t.Fatalf("dial with a ticket: %v", err)
}
defer conn.CloseNow()
// And it is the right person's stream: their own new project arrives on it.
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
_, msg, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(msg), "Live fra telefonen") {
t.Errorf("first event was %s", msg)
}
// Spent: the same ticket does not open a second socket.
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
t.Error("a ticket opened two sockets")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("reused ticket: %v", resp)
}
}
func TestATicketExpires(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
ticket := body["ticket"].(string)
ts.api.wsTickets.mu.Lock()
v := ts.api.wsTickets.m[ticket]
v.expires = time.Now().Add(-time.Second)
ts.api.wsTickets.m[ticket] = v
ts.api.wsTickets.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
t.Error("an expired ticket opened a socket")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expired ticket: %v", resp)
}
}
// The cookie path is unchanged: a foreign page cannot open a socket that rides
// on the cookie, and the instance's own page can.
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var cookie string
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
if c.Name == sessionCookieInsecure {
cookie = c.Value
}
}
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dial := func(origin string) error {
h := http.Header{}
h.Set("Origin", origin)
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
if err == nil {
conn.CloseNow()
}
return err
}
if err := dial("https://evil.example"); err == nil {
t.Error("a foreign page opened a socket on the cookie")
}
if err := dial("capacitor://localhost"); err == nil {
t.Error("the app origin opened a socket on a cookie, without a ticket")
}
if err := dial("http://localhost"); err != nil {
t.Errorf("control: the instance's own origin: %v", err)
}
}
// The end-to-end suite runs the interface from a second host name and tells the
// server to count it as the app's. That origin is then the app's in every
// respect, and capacitor://localhost stays one too.
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.AppOrigins = []string{"http://app.localhost:8097"}
})
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Errorf("%s: Allow-Origin %q", origin, got)
}
}
// Not a prefix match: a neighbour on another port is nobody.
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("a neighbouring origin was answered: %q", got)
}
}
+169
View File
@@ -0,0 +1,169 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)
// What the iOS app needs from a server that a browser gets for free by being on
// the same origin. The app loads its interface from capacitor://localhost and
// talks to whichever server its owner typed in, so every request it makes is
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
// has signed in.
// appCORS answers the app's origin, and nobody else's.
//
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
// token, never a cookie, so a response to a request with a cookie on it is not
// something this header ever needs to unlock. Every other origin gets no CORS
// headers at all, exactly as before — the browser then keeps a foreign page from
// reading anything, which is the default this must not loosen.
func (s *Server) appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !s.isAppOrigin(origin) {
next.ServeHTTP(w, r)
return
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
// Read by the client: a rate limit's wait, and a download's file name.
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// --- the live-sync socket without a cookie -----------------------------------
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
// token has no way onto it. Putting the token in the URL would work and would
// leave a credential that lasts months in every proxy log the URL passes. A
// ticket is the usual answer: asked for with the token, good for one connection,
// gone in thirty seconds.
const wsTicketTTL = 30 * time.Second
type wsTicket struct {
userID string
expires time.Time
}
type wsTickets struct {
mu sync.Mutex
m map[string]wsTicket
}
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
func (t *wsTickets) issue(userID string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
ticket := hex.EncodeToString(raw)
t.mu.Lock()
defer t.mu.Unlock()
// Swept on the way in, so the map is bounded by thirty seconds of asking.
now := time.Now()
for k, v := range t.m {
if now.After(v.expires) {
delete(t.m, k)
}
}
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
return ticket, nil
}
// redeem spends a ticket. It is deleted before it is checked, so a second
// presentation finds nothing, whether the first one was in time or not.
func (t *wsTickets) redeem(ticket string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
v, ok := t.m[ticket]
delete(t.m, ticket)
if !ok || time.Now().After(v.expires) {
return "", false
}
return v.userID, true
}
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "issue websocket ticket", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ticket": ticket,
"expires_in": int(wsTicketTTL.Seconds()),
})
}
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
// ordinary session check.
//
// A socket opened with a ticket skips the origin check, and that is the point
// rather than a gap: the origin check exists because a browser attaches the
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
// be asked for with a credential and put in the URL on purpose — so there is no
// ambient authority for a foreign page to borrow.
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
// The app never holds a cookie, so a handshake from its origin without a
// ticket is not the app. Refused here rather than left to the origin
// check, which compares host names only: capacitor://localhost and an
// instance served at http://localhost have the same one.
if s.isAppOrigin(r.Header.Get("Origin")) {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
return
}
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
return
}
userID, ok := s.wsTickets.redeem(ticket)
if !ok {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
return
}
user, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, r, "load user", err)
return
}
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
s.handleWebSocket(w, r.WithContext(ctx))
}
// --- is this a Verdande? -----------------------------------------------------
// handlePing answers before anybody has signed in, which is when the app needs
// it: the person has typed an address and the app has to tell "that is a
// Verdande" from "that is a router's login page" before it shows a password
// field for it. The edition decides which half of the interface to show.
//
// No version. /version sits behind a login on purpose, and a public number is a
// list of the instances still running an old one.
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"product": "verdande",
"edition": s.edition(),
"api": 1,
})
}
+269
View File
@@ -0,0 +1,269 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"time"
)
// The iOS app loads its interface from itself — capacitor://localhost — so it
// never shares an origin with the server and never gets a cookie back. It signs
// in like a browser does and carries the same kind of session, as a bearer token.
// appDo is a request the way the app makes it: no cookie jar, the token in a
// header, and the browser engine reporting the request as cross-site, because to
// WKWebView it is.
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, ts.URL+path, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Origin", "capacitor://localhost")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
t.Helper()
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
"client": "app", "device_name": "Kristians iPhone",
})
}
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := appLogin(t, ts)
if resp.StatusCode != http.StatusOK {
t.Fatalf("app login: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("an app login set a cookie: %v", resp.Cookies())
}
token, _ := body["session_token"].(string)
if token == "" {
t.Fatalf("no session_token in %v", body)
}
if body["user"] == nil {
t.Error("no user in the response")
}
// The token is the whole credential, from a foreign origin, reads and writes.
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
}
// It is a session, not an API token: it reaches what a token may not, and it
// is in the list of devices under the name the app gave itself.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
}
var found bool
for _, raw := range body["sessions"].([]any) {
s := raw.(map[string]any)
if s["current"] == true {
found = true
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
}
}
}
if !found {
t.Error("the app's own session is not marked current")
}
// And signing out ends it.
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: %d", resp.StatusCode)
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("the token still works after logout: %d", resp.StatusCode)
}
}
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
// bearer must not work: that would turn the header into a second way past the
// SameSite and CSRF locks the cookie sits behind.
func TestABrowserSessionIsNotABearer(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var cookie string
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
if c.Name == sessionCookieInsecure {
cookie = c.Value
}
}
if cookie == "" {
t.Fatal("bootstrap left no session cookie")
}
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
}
// Control: the same value is still a perfectly good cookie.
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
}
}
func TestAppLoginWithASecondFactor(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
secret, _ := body["secret"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
resp, body := appLogin(t, ts)
token, _ := body["session_token"].(string)
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
}
if body["user"] != nil {
t.Error("the user came back before the code was given")
}
// Half a login reaches nothing, as in a browser, and does not slide yet.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
}
session, _, err := ts.db.SessionByToken(t.Context(), token)
if err != nil {
t.Fatal(err)
}
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("me after the code: %d", resp.StatusCode)
}
session, _, _ = ts.db.SessionByToken(t.Context(), token)
if session.IdleTTL != 7*24*time.Hour {
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
}
}
// A phone in daily use must not sign itself out; one left in a drawer must. The
// end moves forward each time the session is used, and a browser's does not.
func TestAnAppSessionSlides(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
token := body["session_token"].(string)
// Pretend it was last used a while ago and is a day from ending.
soon := time.Now().Add(24 * time.Hour).Unix()
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("me: %d", resp.StatusCode)
}
var expires int64
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
t.Errorf("using the app session left %v on it, want about the app TTL", left)
}
// Control: a browser session used the same way keeps its fixed end.
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
ts.do(t, "GET", "/api/v1/auth/me", nil)
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if expires != soon {
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
}
}
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, tc := range []struct {
name string
body map[string]string
field string
}{
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
} {
tc.body["email"] = "kristian@example.dk"
tc.body["password"] = "et langt kodeord"
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
continue
}
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
}
}
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
+123 -11
View File
@@ -33,7 +33,14 @@ type userJSON struct {
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
CreatedAt string `json:"created_at"`
// DefaultReminderMin is the account's standing alarm on tasks that have a
// clock, as minutes relative to the due moment and negative for before it.
// Null is off, which is where every account starts.
//
// A pointer rather than a number with a sentinel, so that "off" and "at the
// due moment" stay different answers: zero is a real setting here.
DefaultReminderMin *int `json:"default_reminder_min"`
CreatedAt string `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
@@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON {
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
DefaultReminderMin: u.DefaultReminderMin,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
@@ -112,16 +120,79 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleSetDefaultReminder turns the account's standing alarm on or off.
//
// `minutes` is relative to the due moment and negative for before it, which is the
// convention reminders.offset_min uses because that is the column it lands in. Null
// is off. A positive number is refused: an alarm AFTER the thing it is warning
// about is not a reminder, and allowing it here would make the one mistake this
// value can make — a sign the wrong way round — look like a choice.
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
var req struct {
Minutes *int `json:"minutes"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Minutes != nil {
switch {
case *req.Minutes > 0:
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
return
// A week is the outer edge of "remind me ahead of this"; beyond it the
// number is far more likely to be a unit mistake than an intention.
case *req.Minutes < -7*24*60:
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
return
}
}
user := userFrom(r.Context())
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
s.storeError(w, r, "set default reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
// what the app calls itself, shown in the list of signed-in devices.
Client string `json:"client"`
DeviceName string `json:"device_name"`
}
type loginResponse struct {
User *userJSON `json:"user,omitempty"`
TOTPRequired bool `json:"totp_required"`
// SessionToken is set for an app login only, and instead of the cookie: the
// app loads its interface from itself, so it never shares an origin with the
// server and a cookie would not come back. It is sent from then on as
// `Authorization: Bearer`. A browser never sees it — its session stays in an
// httpOnly cookie no script can read.
SessionToken string `json:"session_token,omitempty"`
}
// appClient reports whether a login asked for an app session, and refuses a value
// it does not know rather than quietly giving it a browser session: a client that
// asked for something else and got a cookie would look signed in until its next
// request, and then not be.
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
switch client {
case "", "web":
return false, true
case "app":
if strings.TrimSpace(deviceName) == "" {
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
return false, false
}
return true, true
}
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
return false, false
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
@@ -129,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := decodeJSON(w, r, &req); err != nil {
return
}
app, ok := appClient(w, req.Client, req.DeviceName)
if !ok {
return
}
user, err := s.db.UserByEmail(r.Context(), req.Email)
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
@@ -173,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// A session that still needs a code gets a short life of its own: long enough
// to fetch a phone, not long enough to be worth stealing.
ttl := s.cfg.SessionTTL
if app {
ttl = s.cfg.AppSessionTTL
}
if user.TOTPEnabled {
ttl = 10 * time.Minute
}
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil {
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
if user.TOTPEnabled {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
if app {
// Sliding only once the login is finished; a pending one keeps its ten
// minutes and handleLoginTOTP turns it on.
idle := s.cfg.AppSessionTTL
if user.TOTPEnabled {
idle = 0
}
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
s.internal(w, r, "mark app session", err)
return
}
resp.SessionToken = token
} else {
s.setSessionCookie(w, token, ttl)
}
uj := toUserJSON(user)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
if !user.TOTPEnabled {
uj := toUserJSON(user)
resp.User = &uj
}
writeJSON(w, http.StatusOK, resp)
}
type totpRequest struct {
@@ -263,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
s.loginLimiter.reset(clientIP(r))
s.totpLimiter.reset(user.ID)
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
// An app's session finishes as a sliding one; it already holds its token and
// gets no cookie.
if session.Client == "app" {
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
s.internal(w, r, "promote session", err)
return
}
uj := toUserJSON(user)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
return
}
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
s.internal(w, r, "promote session", err)
return
}
@@ -492,7 +597,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) {
+7 -6
View File
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
cfg := &config.Config{
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
AppSessionTTL: 7 * 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
}
if configure != nil {
configure(cfg)
+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
}
+375
View File
@@ -0,0 +1,375 @@
package httpapi
import (
"context"
"encoding/json"
"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.
//
// And note what this list could NOT catch, because it is the shape of mistake
// that outlived the first version of this test. The list went green at 29
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
// exports, both task imports, the project templates, the sections and six AI
// task endpoints were all still served by the notes edition — because the test
// compares the router against THIS LIST, and the list was written from the
// routes somebody had thought of. It is a drift check, and it cannot be an
// is-it-complete check.
//
// So when a route is added that one edition should not have, the question to
// ask is not "does the walk still pass" but "can this endpoint do anything at
// all without the half it is being withheld from". Three of the ones above were
// settled by reading rather than by the name: a template turned out to create
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
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/sections/{sectionID}",
"DELETE /api/v1/tasks/{taskID}",
"DELETE /api/v1/templates/{templateID}",
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
"GET /.well-known/caldav",
"GET /api/v1/ai/plan",
"GET /api/v1/delegated",
"GET /api/v1/export/projects.zip",
"GET /api/v1/export/projects/{projectID}.csv",
"GET /api/v1/export/projects/{projectID}.ics",
"GET /api/v1/export/tasks.ics",
"GET /api/v1/feed",
"GET /api/v1/filters",
"GET /api/v1/filters/preview",
"GET /api/v1/filters/{filterID}/tasks",
"GET /api/v1/labels",
"GET /api/v1/projects/{projectID}/sections",
"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/templates",
"GET /api/v1/upcoming",
"GET /caldav/{userID}/{projectID}/{taskFile}",
"GET /ics/{token}",
"OPTIONS /caldav/*",
"PATCH /api/v1/filters/{filterID}",
"PATCH /api/v1/labels/{labelID}",
"PATCH /api/v1/sections/{sectionID}",
"PATCH /api/v1/tasks/{taskID}",
"POST /api/v1/ai/inbox/tidy",
"POST /api/v1/ai/inbox/tidy/apply",
"POST /api/v1/ai/plan/now",
"POST /api/v1/ai/tasks/{taskID}/split",
"POST /api/v1/feed/rotate",
"POST /api/v1/filters",
"POST /api/v1/import/csv",
"POST /api/v1/import/todoist",
"POST /api/v1/labels",
"POST /api/v1/projects/{projectID}/sections",
"POST /api/v1/projects/{projectID}/sections/reorder",
"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",
"POST /api/v1/templates",
"POST /api/v1/templates/{templateID}/use",
"POST /inbound/hook/{token}",
"PROPFIND /caldav",
"PROPFIND /caldav/{userID}",
"PROPFIND /caldav/{userID}/{projectID}",
"PUT /api/v1/ai/plan",
"PUT /caldav/{userID}/{projectID}/{taskFile}",
"REPORT /caldav/{userID}/{projectID}",
} {
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",
"PATCH /api/v1/notes/{noteID}",
"DELETE /api/v1/notes/{noteID}",
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
// enkeltbrugers notesblok, så den halvdel skal navngives her.
"POST /api/v1/notes/{noteID}/shares",
"GET /api/v1/notes/{noteID}/shares",
"POST /api/v1/notes/{noteID}/attachments",
"POST /api/v1/notes/import",
"GET /api/v1/export/notes.zip",
"GET /api/v1/export/account",
"POST /api/v1/ai/notes/{noteID}/actions",
"GET /api/v1/projects", // a notebook, in this edition
"GET /api/v1/search",
"GET /api/v1/auth/me",
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
// er værktøjslisten bagved, der er kortere.
"POST /api/v1/mcp",
"POST /mcp",
} {
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")
}
}
}
// TestNotesEditionOffersNoTaskToolsOverMCP.
//
// The same hole as the routes, through a different door — and it was still open
// after the first gate. An MCP server advertises its tools, so a model connected
// to a notes instance would be told it can create_task, call it, get something
// back, and tell the person their task was filed.
//
// Asked through tools/list rather than by reading the server's own slice: that is
// what a client actually sees, and the slice is unexported for the same reason.
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
noteTools := []string{"search_notes", "create_note", "update_note"}
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
t.Run(edition, func(t *testing.T) {
names := mcpToolNames(t, edition)
// Both editions: the shared tool, and the notes half.
for _, want := range append([]string{"list_projects"}, noteTools...) {
if !names[want] {
t.Errorf("%s edition does not offer %s", edition, want)
}
}
for _, task := range taskTools {
switch edition {
case config.EditionFull:
if !names[task] {
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
}
default:
if names[task] {
t.Errorf("the notes edition offers %s over MCP", task)
}
}
}
t.Logf("%s: %d tools", edition, len(names))
})
}
}
// mcpToolNames asks one edition's MCP server for its tool list.
func mcpToolNames(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() })
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if resp == nil {
t.Fatal("tools/list got no response")
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var parsed struct {
Result struct {
Tools []struct{ Name string } `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("tools/list: %v — %s", err, raw)
}
if len(parsed.Result.Tools) == 0 {
t.Fatalf("tools/list returned no tools at all: %s", raw)
}
names := map[string]bool{}
for _, tool := range parsed.Result.Tools {
names[tool.Name] = true
}
return names
}
+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) {
+72 -63
View File
@@ -23,7 +23,9 @@ import (
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
// and a description that only names the endpoint gives it nothing to decide with.
func (s *Server) buildMCP() *mcp.Server {
m := mcp.NewServer("verdande", "1")
// Udgavens eget navn: det er sådan, instansen står i klientens
// forbindelsesliste.
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
m.Register(mcp.Tool{
Name: "list_projects",
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
}),
}, s.mcpListProjects)
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
// lave. add_comment er med her: en kommentar hænger på en opgave.
if !s.cfg.NotesOnly() {
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
}
s.registerNoteTools(m)
return m
+50 -2
View File
@@ -18,6 +18,8 @@ type ctxKey int
const (
ctxUser ctxKey = iota
ctxSession
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
ctxTicket
)
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
@@ -126,9 +128,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
})
}
// authenticate resolves a request to a user, by session cookie or by API token.
// authenticate resolves a request to a user, by session cookie, by API token, or by
// an app's session sent as a bearer token.
//
// Both are accepted everywhere. An API token belongs to a person and carries their
// All are accepted everywhere. An API token belongs to a person and carries their
// permissions exactly — there is no separate service identity to reason about, and
// a script therefore cannot reach anything its owner could not.
// errNoCredentials means the request carried nothing to authenticate with, as
@@ -145,6 +148,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
return user, nil, nil
}
// Any other bearer is an app session (see loginResponse.SessionToken). It is
// accepted only for a session that was issued to the app: a browser's session
// never leaves its httpOnly cookie, and a cookie value found somewhere and
// replayed as a header must not become a second way to use it.
if token := bearerToken(r); token != "" {
session, user, err := s.db.SessionByToken(r.Context(), token)
if err != nil {
return nil, nil, err
}
if session.Client != "app" {
return nil, nil, store.ErrNotFound
}
return user, session, nil
}
cookie, err := r.Cookie(s.cookieName())
if err != nil || cookie.Value == "" {
return nil, nil, errNoCredentials
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
// The iOS app, before it has a token: its sign-in. A web page cannot send
// this Origin — the browser writes the header, not the page — and an app
// login sets no cookie, so there is no session here for a forged request
// to ride on.
if s.isAppOrigin(r.Header.Get("Origin")) {
next.ServeHTTP(w, r)
return
}
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
switch r.Header.Get("Sec-Fetch-Site") {
@@ -220,6 +246,28 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
})
}
// appOrigins are where the iOS app's interface is served from. Capacitor loads
// the bundled build on this scheme inside WKWebView, so every request the app
// makes to a server arrives from here, whichever server it is.
var appOrigins = []string{"capacitor://localhost"}
func (s *Server) isAppOrigin(origin string) bool {
if origin == "" {
return false
}
for _, o := range appOrigins {
if origin == o {
return true
}
}
for _, o := range s.cfg.AppOrigins {
if origin == o {
return true
}
}
return false
}
func (s *Server) isOwnOrigin(origin string) bool {
if strings.EqualFold(origin, s.cfg.BaseURL) {
return true
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
})
}
// notifyProjectShared tells somebody that a project is now theirs to work in.
//
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
// opgaver til dig i det.
//
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
// handling — de skal ikke have besked om det, de lige selv gjorde.
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
if project == nil || userID == "" {
return
}
actor := userFrom(r.Context())
if actor.ID == userID {
return
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
+152 -14
View File
@@ -452,22 +452,100 @@ type memberJSON struct {
Role string `json:"role"`
}
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
// dukker op.
type projectInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleListMembers is everything the share panel needs, in one answer: who is on
// the project, who can be added, and who has been invited and not arrived.
//
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
//
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
already := map[string]bool{}
for _, m := range members {
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
already[m.UserID] = true
}
writeJSON(w, http.StatusOK, map[string]any{"members": out})
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, r, "get project", err)
return
}
already[project.OwnerID] = true
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
candidates := make([]personJSON, 0, len(people))
for _, p := range people {
if already[p.ID] {
continue
}
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list project invites", err)
return
}
pending := make([]projectInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"candidates": candidates,
"pending": pending,
})
}
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
//
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
// udløber, med et link, der virker hele tiden.
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
if err != nil {
s.storeError(w, r, "delete project invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type inviteRequest struct {
Email string `json:"email"`
Role string `json:"role"`
// UserID er den, man har peget på i listen. Email er den, man kender adressen
// på — og den er også den, der virker, når der ingen konto er endnu.
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -478,8 +556,14 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// handleInvite shares a project with somebody. An existing user is added directly;
// anybody else gets a link that creates their account and their membership at once.
// handleInvite shares a project with somebody.
//
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
// kontoen og medlemskabet på én gang.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// Somebody who already has an account is added straight away: sending them
// through a signup form they cannot complete would be a dead end.
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
if existing.ID == project.OwnerID {
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
if req.UserID == "" {
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
req.UserID = existing.ID
}
}
if req.UserID != "" {
if req.UserID == project.OwnerID {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
// tom plads, ingen kan fjerne.
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
// de kiggede — uden at noget sagde hvornår eller fra hvem.
s.notifyProjectShared(r, project, person.ID)
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
// Personen sendes med retur, fordi fladen kan have delt med en adresse
// og derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
return
}
// Et andet link til den samme indbakke hjælper ingen: det første virker
// stadig, og nu er der to at holde styr på og to at trække tilbage.
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
return
}
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
// anybody removing themselves.
//
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
//
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
// overdragelse, og den findes ikke endnu.
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
// persons medlemskab, bekræfter at medlemskabet findes.
if me.ID != owner && userID != me.ID {
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner cannot be removed from their own project")
+236
View File
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
}
}
}
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
// beskytte ved at lade være med at vise dem.
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
// Først står de i listen over dem, der kan tilføjes.
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if !hasPerson(before["candidates"], anden.ID) {
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
}
// Delt ved at pege, ikke ved at stave.
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": anden.ID, "role": "editor"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, added)
}
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
t.Errorf("user = %v, want Anden", added["user"])
}
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if hasPerson(after["candidates"], anden.ID) {
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
}
found := false
for _, raw := range after["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == anden.ID {
found = true
if m["role"] != "editor" {
t.Errorf("role = %v, want editor", m["role"])
}
}
}
if !found {
t.Errorf("Anden blev ikke medlem: %v", after["members"])
}
}
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if hasPerson(body["candidates"], me.ID) {
t.Errorf("ejeren står som kandidat til sit eget projekt")
}
}
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
//
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
// godt som det første, og så er der to at trække tilbage i stedet for et.
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invitation: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
pending, _ := body["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
}
first := pending[0].(map[string]any)
if first["email"] != "fremmed@example.dk" {
t.Errorf("email = %v", first["email"])
}
// Et andet link til den samme indbakke hjælper ingen.
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE",
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if p, _ := after["pending"].([]any); len(p) != 0 {
t.Errorf("invitationen står der stadig: %v", after["pending"])
}
}
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
// hasPerson siger, om en liste af personer indeholder et bestemt id.
func hasPerson(raw any, id string) bool {
list, _ := raw.([]any)
for _, item := range list {
if p, ok := item.(map[string]any); ok && p["id"] == id {
return true
}
}
return false
}
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anden := ts.newUser(t, "anden@example.dk", "Anden")
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": andenUser.ID, "role": "editor"})
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
var shared map[string]any
for _, raw := range inbox["notifications"].([]any) {
if n := raw.(map[string]any); n["kind"] == "project.shared" {
shared = n
}
}
if shared == nil {
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
}
if shared["project_id"] != projectID {
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
shared["project_id"], projectID)
}
// Udgangen.
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
}
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
}
}
// En udgang for en selv er ikke en dør til de andres medlemskaber.
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
en := ts.newUser(t, "en@example.dk", "En")
ts.newUser(t, "to@example.dk", "To")
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
if err != nil {
t.Fatal(err)
}
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
for _, u := range []string{enUser.ID, toUser.ID} {
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": u, "role": "editor"})
}
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if len(members["members"].([]any)) != 3 {
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
}
}
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
resp, _ := ts.do(t, "DELETE",
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("status %d, want 409", resp.StatusCode)
}
}
+4
View File
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
ticketed, _ := r.Context().Value(ctxTicket).(bool)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// The socket is same-origin only. Without this, any page on the internet
// could open a socket carrying the user's cookie and watch their tasks —
// the same-origin policy does not apply to WebSocket handshakes.
OriginPatterns: s.allowedOrigins(),
// Except one opened with a ticket, which carries no cookie for a foreign
// page to borrow: see handleWebSocketEntry.
InsecureSkipVerify: ticketed,
})
if err != nil {
s.log.Debug("websocket handshake", "err", err)
+268 -84
View File
@@ -3,6 +3,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
@@ -59,6 +60,10 @@ type Server struct {
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// One-use tickets for opening the live-sync socket without a cookie — see
// app_handlers.go.
wsTickets *wsTickets
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -77,6 +82,20 @@ 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
// Icon paths this edition serves from another file. Empty in the full edition.
assets map[string]string
// The second door: /urd and its own web manifest. Built on every instance,
// whatever the edition, because it is how the notes face is installed as its
// own app rather than a property of the instance. See shell.go.
urdShell []byte
urdManifest []byte
router chi.Router
}
@@ -86,7 +105,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
@@ -100,9 +119,55 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// their own code gets it right on the first or second try; ten in an hour is
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
wsTickets: newWSTickets(),
}
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
}
s.assets = resolveNotesAssets(web, func(name, alias string) {
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
"path", name, "wanted", alias)
})
}
// The second door, on every edition. A notes instance already calls itself urd
// at the root; the door is still built there so the two addresses behave the
// same, and so a single test does not have to know which edition it is in.
if web != nil {
urdShell, urdManifest, err := urdDoorAssets(web)
if err != nil {
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
} else {
s.urdShell, s.urdManifest = urdShell, urdManifest
}
}
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)
@@ -125,13 +190,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Docker both need this to answer before anyone has logged in.
r.Get("/healthz", s.handleHealth)
// The same question as inside /api/v1, asked again here because four of the
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
// hook, and the well-known path CalDAV clients discover through. They are out
// here because their callers cannot hold a session, not because they are any
// less about tasks — and the first gate missed all four for exactly that
// reason. It read as complete because the /tasks tree was covered.
full := !s.cfg.NotesOnly()
// The calendar feed is outside /api/v1 and outside the session: a calendar
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
//
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
if full {
r.Get("/ics/{token}", s.handleICSFeed)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
//
// Withheld rather than left to answer an empty collection: the well-known
// path is a claim that a CalDAV server is here, and a client that believes
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
// broken calendar server on an instance that has none and needs none. Same
// reasoning as the /.well-known/ 404 in serveWeb.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
}
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
@@ -156,8 +239,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
//
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
// i indbakken — så den findes ikke i noter-udgaven.
if full {
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
}
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
@@ -184,14 +272,21 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Gmail connection with calendar tokens.
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
})
r.Route("/caldav", s.caldavRoutes)
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
if full {
r.Route("/caldav", s.caldavRoutes)
}
r.Route("/api/v1", func(r chi.Router) {
// Before the CSRF lock, so a preflight is answered and never reaches it.
r.Use(s.appCORS)
r.Use(s.requireCSRF)
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/ping", s.handlePing)
// The live-sync socket checks its own credential: a ticket, or the
// session as everywhere else. See handleWebSocketEntry.
r.Get("/ws", s.handleWebSocketEntry)
// The beacon's receiving end, and the number it produces. Both without a
// session, because the caller is a stranger's installation — that is the
@@ -242,6 +337,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Put("/default-reminder", s.handleSetDefaultReminder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
@@ -285,26 +381,39 @@ 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)
r.Post("/ws-ticket", s.handleWSTicket)
// The MCP endpoint. Reachable with a personal API token, so a connector
// sees exactly what its owner sees.
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)
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
// findes, er en indstillingsside, der tilbyder en adresse, intet
// svarer på.
if full {
r.Get("/feed", s.handleGetFeed)
}
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
@@ -330,7 +439,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
if full {
r.Post("/feed/rotate", s.handleRotateFeed)
}
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
@@ -351,20 +462,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
if full {
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
}
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
if full {
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
}
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
@@ -449,33 +564,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
// Begge retninger laver eller læser opgaver: Todoist-importen laver
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
// frem for at fejle, hvilket er den stille version af samme fejl.
if full {
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
}
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
if full {
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)
}
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
// En skabelon er et projekt plus sektioner plus opgaver — læst i
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
// kun siger "projekt". Uden opgaver er den et tomt projekt med
// overskrifter i.
if full {
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
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)
@@ -491,14 +626,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireProject(store.RoleViewer))
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
// Sektioner grupperer opgaver inde i et projekt, og det er
// afgjort af skemaet frem for af navnet: `section_id` står på
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
// kan ikke indeholde noget.
if full {
r.Get("/sections", s.handleListSections)
}
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
if full {
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
}
})
r.Group(func(r chi.Router) {
@@ -506,8 +652,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
@@ -573,32 +719,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
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)
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)
if full {
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
})
}
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)
})
})
}
})
})
@@ -666,7 +816,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
}
@@ -675,6 +825,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
// The second door, before anything else touches the filesystem: /urd is not a
// file, so the fallback below would hand back the other door's shell.
if b := s.door(name); b != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
return
}
// This edition's own icon, where it has one. Before the Open rather than after,
// unlike the shell below: these are real files, so there is no fallback to get
// in the way, and resolveNotesAssets has already checked each one exists.
if alias, ok := s.assets[name]; ok {
name = alias
}
f, err := s.web.Open(name)
if errors.Is(err, fs.ErrNotExist) {
// A client-side route: hand back the shell and let the app route it.
@@ -687,6 +853,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"})
+12 -3
View File
@@ -23,8 +23,11 @@ type sessionJSON struct {
// Device is a summary; UserAgent is what it was summarised from. Both, because
// the summary is what you read and the original is what settles the question
// when the summary is not enough.
Device string `json:"device"`
UserAgent string `json:"user_agent"`
Device string `json:"device"`
UserAgent string `json:"user_agent"`
// Client is "web" or "app". An app session slides — its expires_at moves
// forward each time it is used — where a browser's is fixed.
Client string `json:"client"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
@@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
out := make([]sessionJSON, 0, len(sessions))
for _, sess := range sessions {
// The app names itself; a browser is described from its user agent.
device := sess.DeviceName
if device == "" {
device = describeUserAgent(sess.UA)
}
out = append(out, sessionJSON{
ID: sess.ID,
Current: current != nil && sess.ID == current.ID,
Device: describeUserAgent(sess.UA),
Device: device,
UserAgent: sess.UA,
Client: sess.Client,
IP: sess.IP,
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
+341
View File
@@ -0,0 +1,341 @@
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"strings"
)
// 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 {
// Any .webmanifest, not the one name — there are two of them now, and the
// second one was served as HTML for about four minutes because this read
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
// with the wrong type is ignored silently, and the only symptom is an install
// prompt that does not appear.
if strings.HasSuffix(name, ".webmanifest") {
return "application/manifest+json"
}
return "text/html; charset=utf-8"
}
// notesAssetAliases maps the icon paths the shell and the manifest already point
// at to urd's own files. Both sets are in the binary; the server decides.
//
// Aliased rather than renamed in the manifest, and the reason is the third entry.
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
// fixes all four at once, including the favicon in `app.html`, which is not in the
// manifest either.
var notesAssetAliases = map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}
// resolveNotesAssets returns the aliases that have a file behind them, and reports
// the ones that do not.
//
// Resolved once at startup rather than per request, so a missing file is one loud
// line in the log instead of a silent fallback on every page load. The fallback is
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
// absence turns into a broken-image placeholder on the Updates screen — which is
// exactly how four releases of nolimit-views shipped with no icon — so serving the
// wrong mark beats serving none.
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
out := map[string]string{}
for name, alias := range notesAssetAliases {
if _, err := fs.Stat(web, alias); err != nil {
missing(name, alias)
continue
}
out[name] = alias
}
return out
}
// ---------------------------------------------------------------------------
// The second door.
//
// One instance, one database, one login — and two things you can install. urd is
// reached at /urd, which serves the same single-page app under its own identity:
// its own title, its own mark, and its own web manifest.
//
// The manifest is the part that matters and the part that is easy to get wrong.
// An operating system does not install a URL, it installs a *manifest*, and the
// identity of an installed app is the manifest's `id`. Two manifests on one origin
// with different ids are two installable apps; two URLs sharing one manifest are
// one app with a bookmark.
//
// And the shell has to be served per door rather than patched by JavaScript,
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
// manifest's icons entirely. A head rewritten after load is a head that may or may
// not be the one iOS read.
// ---------------------------------------------------------------------------
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
// because those two being the same string is the whole mechanism.
const urdDoorPath = "/urd"
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
// this door is: what the tab says, what a crawler reads, which manifest the browser
// fetches, and which icons it finds without the manifest.
//
// Note what is NOT in here, for the same reason as in shellRebrands above: the
// three `verdande:` localStorage keys in the inline theme script. They are storage
// rather than a name, the rest of the app reads them, and — now more than before —
// the two doors share an origin and therefore share that storage. Renaming them
// here would give the two apps different themes by accident and lose everybody's
// setting on the way.
func urdShellRebrands() []rebrand {
return []rebrand{
{"<title>verdande</title>", "<title>urd</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + notesShellDescription + `" />`,
},
{
`<link rel="manifest" href="/manifest.webmanifest" />`,
`<link rel="manifest" href="/urd.webmanifest" />`,
},
{
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
},
{
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
},
}
}
// urdManifest builds the second door's manifest from the one the frontend build
// produced, rather than writing a second one by hand.
//
// Transformed as JSON rather than by string replacement, which the shell above
// does: a manifest needs keys the built one does not have — `id` and `start_url`
// — so there is nothing to anchor a replacement on, and a hand-written copy would
// drift on the fields the two genuinely share (the colours, the display mode, the
// language).
//
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
// out even though "/" is what the spec would default to from a start_url of
// "/urd": an app whose scope did not cover the whole origin would open its own
// links in a browser tab the first time somebody navigated out of /urd, and that
// is a rule nobody should have to remember while reading this.
func urdManifest(raw []byte) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
}
m["id"] = urdDoorPath
m["start_url"] = urdDoorPath
m["scope"] = "/"
m["name"] = "urd"
m["short_name"] = "urd"
m["description"] = notesManifestDescription
icons, ok := m["icons"].([]any)
if !ok || len(icons) == 0 {
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
}
for i, entry := range icons {
icon, ok := entry.(map[string]any)
if !ok {
return nil, fmt.Errorf("icon %d is not an object", i)
}
src, _ := icon["src"].(string)
// Every icon must be one this door has a file for. Refusing an unknown one
// is the point: an icon added to the manifest and not to this map would
// otherwise leave urd's install prompt showing Verdande's mark, which is
// cosmetic enough that nobody files it.
swapped, ok := map[string]string{
"/icon.svg": "/urd-icon.svg",
"/icon-192.png": "/urd-icon-192.png",
"/icon-512.png": "/urd-icon-512.png",
}[src]
if !ok {
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
}
icon["src"] = swapped
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// urdDoorAssets builds the second door from the files the frontend build produced.
//
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
// that manifest is not being served is an install prompt that silently falls back
// to the other app's identity.
func urdDoorAssets(web fs.FS) (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, urdShellRebrands())
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 = urdManifest(raw)
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
return shell, manifest, nil
}
// door returns the bytes for the second door's two paths, or nil.
//
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
// below: /urd is not a file and never will be, so it has to be answered before the
// fallback to index.html gets to it — which would serve the other door's shell
// under this door's address, with no error anywhere.
func (s *Server) door(name string) []byte {
switch name {
case "urd":
return s.urdShell
case "urd.webmanifest":
return s.urdManifest
}
return nil
}
+442
View File
@@ -0,0 +1,442 @@
package httpapi
import (
"encoding/json"
"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" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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": "/",
"display": "standalone",
"background_color": "#111312",
"theme_color": "#111312",
"lang": "da",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
`
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)
// Both sets: the edition's rename and the second door's. The door was added
// after this test and its anchors were NOT covered, which is how its fixture
// came to be missing two <link> lines the real file has — caught only because
// applyRebrands refuses an anchor it cannot find.
for what, pairs := range map[string][]rebrand{
"the notes edition's rename": shellRebrands("urd", "d"),
"the urd door": urdShellRebrands(),
} {
for _, pair := range pairs {
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 — %s replaces it",
n, pair.old, what)
}
}
}
// 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 edition anchors, %d door anchors",
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
}
// 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")
}
})
}
}
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
func webWithIcons() fstest.MapFS {
web := builtWeb()
for _, name := range []string{
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
} {
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
}
return web
}
// TestEachEditionServesItsOwnMark.
//
// Four paths, named one by one rather than counted: the favicon in app.html, the
// manifest's two, and the one Safari looks for by name. The last is the reason
// this is an alias rather than a rewritten manifest — Safari does not read the
// manifest at all.
func TestEachEditionServesItsOwnMark(t *testing.T) {
for _, tc := range []struct {
edition string
want map[string]string
}{
{"notes", map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}},
// The control, and it is the half that stops this being a swap rather than
// a choice: the full edition must resolve nothing.
{"full", map[string]string{}},
} {
t.Run(tc.edition, func(t *testing.T) {
srv := serverWithWeb(t, tc.edition, webWithIcons())
if len(srv.assets) != len(tc.want) {
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
}
for path, alias := range tc.want {
if srv.assets[path] != alias {
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
}
}
// And nothing else: an alias on a build asset would serve one file's
// bytes under another's hashed, immutable name.
for path := range srv.assets {
if _, ok := tc.want[path]; !ok {
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
}
}
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
})
}
}
// TestAMissingMarkIsReportedAndFallsBack.
//
// The fallback is deliberate and it is the only place in this file that prefers
// carrying on to failing: an icon's absence is a broken-image placeholder on the
// one screen somebody reads before deciding whether to press update, so the wrong
// mark beats no mark. What must not happen is it being quiet.
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
web := webWithIcons()
delete(web, "urd-icon-512.png")
var reported []string
got := resolveNotesAssets(web, func(name, alias string) {
reported = append(reported, name+" → "+alias)
})
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
}
if _, ok := got["icon-512.png"]; ok {
t.Error("a path with no file behind it was aliased anyway")
}
if got["icon.svg"] != "urd-icon.svg" {
t.Error("one missing icon took the other three with it")
}
}
// TestTheUrdDoorIsItsOwnApp.
//
// An operating system does not install a URL, it installs a manifest, and the
// identity of an installed app is that manifest's `id`. So this asserts the two
// things that decide whether /urd becomes a second icon on a home screen or just a
// bookmark into the first one — and it asserts them as named values, because an
// empty manifest, verdande's manifest and urd's all parse as JSON.
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
srv := serverWithWeb(t, "full", webWithIcons())
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
}
shell := string(srv.door("urd"))
for _, want := range []string{
"<title>urd</title>",
`<link rel="manifest" href="/urd.webmanifest" />`,
// iOS reads this one by name and has historically ignored the manifest's
// icons entirely, which is why the shell is served per door rather than
// patched by script after load.
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
} {
if !strings.Contains(shell, want) {
t.Errorf("the urd door's shell does not contain %q", want)
}
}
// The same trap as the edition shell: the two doors share an origin and
// therefore share localStorage, so renaming a storage key here would give the
// two apps different themes by accident.
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
}
var m map[string]any
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
t.Fatalf("the urd manifest is not JSON: %v", err)
}
for key, want := range map[string]any{
// id is the install identity. Without it, two manifests on one origin are
// one app with two start urls.
"id": "/urd",
"start_url": "/urd",
"scope": "/",
"name": "urd",
"short_name": "urd",
} {
if m[key] != want {
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
}
}
// And the shared half, to prove it was transformed rather than written out: a
// hand-written copy would drift on exactly these.
var built map[string]any
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
_ = json.Unmarshal(raw, &built)
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
if _, ok := built[key]; ok && m[key] != built[key] {
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
}
}
icons, _ := m["icons"].([]any)
if len(icons) == 0 {
t.Fatal("the urd manifest has no icons")
}
for i, entry := range icons {
src, _ := entry.(map[string]any)["src"].(string)
if !strings.HasPrefix(src, "/urd-") {
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
}
}
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
}
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
// added to the built manifest and not to urd's map would otherwise leave urd's
// install prompt showing Verdande's mark, which nobody reports.
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
if _, err := urdManifest(raw); err == nil {
t.Error("urdManifest accepted an icon it has no file for")
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
t.Errorf("the error does not name the icon: %v", err)
}
}
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
// by the browser with no error anywhere; the only symptom is that the install
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
// second door was added.
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
if got := contentTypeFor(name); got != "application/manifest+json" {
t.Errorf("%s is served as %q", name, got)
}
}
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
t.Errorf("index.html is served as %q", got)
}
}
+123 -2
View File
@@ -243,6 +243,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
// The store wrote the labels; the struct it was handed does not know that, and
// the response has to describe the task that now exists.
t.Labels = req.Labels
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
@@ -337,6 +341,12 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
s.storeError(w, r, "get task", err)
return
}
// Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den
// halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid".
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
@@ -501,6 +511,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 +539,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 +566,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 +612,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 {
@@ -585,14 +630,55 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
if unknownProject != "" || unknownSection != "" {
// The alarm the line asked for, after the task because a reminder needs its id.
//
// Set when the line asked to be reminded and the task has nothing to count back
// from. An offset is relative to the due moment, and DueReminders selects the
// offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day
// but no clock there is nothing to be an hour before, and the row would sit in
// the table forever without firing. Reported the same way an unknown project
// is: the task is created either way, and the caller is told what could not be
// honoured. An alarm that silently never rings is the one way a reminder must
// not fail, because nothing afterwards reveals it.
reminderNotSet := ""
switch {
case len(parsed.ReminderAt) == len("2006-01-02T15:04"):
day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:]
if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil {
if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil {
s.internal(w, r, "create reminder", err)
return
}
}
case parsed.ReminderOffsetMin != nil:
if t.DueDatetime == nil {
reminderNotSet = "no due time to count back from"
} else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil {
s.internal(w, r, "create reminder", err)
return
}
}
// Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har
// allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen
// alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden,
// mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen.
if !parsed.NoReminder {
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
}
if unknownProject != "" || unknownSection != "" || reminderNotSet != "" {
// Alongside the task rather than instead of it: the task was created, and
// this says what could not be honoured while creating it.
writeJSON(w, http.StatusCreated, struct {
taskJSON
UnknownProject string `json:"unknown_project,omitempty"`
UnknownSection string `json:"unknown_section,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection})
ReminderNotSet string `json:"reminder_not_set,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet})
return
}
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
@@ -769,3 +855,38 @@ func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
// applyDefaultReminder gives a task the alarm the account asks for without being
// asked each time.
//
// Three things make it a no-op, and each of them is the point rather than a guard:
//
// - The account has the default off. That is where every account starts, and it
// stays there until somebody turns it on: switching it on in a migration would
// have given every existing user alarms they never asked for, on tasks already
// lying there, in software other people run themselves.
// - The task has no clock. A task due "tomorrow" has no moment to ring at, and
// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` —
// so the row would sit there and never fire.
// - The task already has a reminder. The line may have asked for one itself, or
// the task may be being edited for the second time; either way the default is
// a default and must not stack a second alarm on top of a stated one.
//
// Called on every path a task can gain a clock — created, edited, or quick-added —
// because a default that only applies to one of them is a setting that is true on
// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple
// Reminders brings its own alarm, and two alarms for one task is worse than none.
func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error {
if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil {
return nil
}
existing, err := s.db.ListReminders(ctx, t.ID)
if err != nil {
return err
}
if len(existing) > 0 {
return nil
}
_, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin)
return err
}
+560
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)
@@ -1907,3 +2080,390 @@ func TestSnoozeSinksAndWakes(t *testing.T) {
t.Errorf("woken task did not return: order = %v", got)
}
}
// --- alarms written into the line ---------------------------------------------
// A dictated sentence sets its own alarm, and the alarm actually goes off.
//
// The assertion is on DueReminders rather than on a row existing, and the
// difference is the whole test: a reminder row with the offset the wrong way round
// exists just as convincingly and rings an hour LATE. So the question asked here is
// "would this fire, and when" — one minute before it is due it must not be in the
// list, one minute after it must.
func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til tandlægen i morgen kl 9 mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "ring til tandlægen" {
t.Errorf("content = %v, want %q", body["content"], "ring til tandlægen")
}
if body["reminder_not_set"] != nil {
t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"])
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin == nil {
t.Fatalf("reminder has no offset; it should not be absolute")
}
if *rems[0].OffsetMin != -60 {
t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+
"positive number is an alarm an hour late", *rems[0].OffsetMin)
}
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
if err != nil {
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
}
early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute))
if err != nil {
t.Fatalf("due reminders (early): %v", err)
}
if len(early) != 0 {
t.Errorf("61 minutes before it is due, %d reminders are already due", len(early))
}
ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute))
if err != nil {
t.Fatalf("due reminders (ripe): %v", err)
}
if len(ripe) != 1 {
t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe))
}
if ripe[0].TaskContent != "ring til tandlægen" {
t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent)
}
}
// An alarm with nothing to count back from is refused out loud.
//
// "mind mig en time før" on a task with a day but no clock has no moment to be an
// hour before, and DueReminders selects the offset form only when the task has a
// due_datetime — so the row would sit there and never fire. The task is created and
// the caller is told, the same way an unknown #project is reported. An alarm that
// silently never rings is the one way a reminder must not fail.
func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "køb mælk i morgen mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "køb mælk" {
t.Errorf("content = %v, want %q", body["content"], "køb mælk")
}
if body["reminder_not_set"] == nil {
t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so")
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+
"than none, because nothing afterwards reveals it", len(rems))
}
}
// A reminder given a clock of its own is kept as an absolute moment.
func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til tandlægen i morgen kl 9 mind mig kl 8",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin != nil {
t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin)
}
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
if err != nil {
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
}
// Due at nine, reminded at eight: one hour earlier, on the task's own day.
if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) {
t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want)
}
}
// --- the account's standing alarm --------------------------------------------
// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes
// before the due moment.
func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) {
t.Helper()
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set default reminder: %d %v", resp.StatusCode, body)
}
}
// With the setting on, a task that has a clock gets its alarm without being asked.
func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
}
}
// And off is where every account starts. This is the control for the test above:
// without it, an alarm appearing for any reason at all reads as the setting working.
func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+
"on for everybody is not a default anybody chose", len(rems))
}
}
// A task with a day but no clock gets nothing, because there is no moment to ring
// at: DueReminders selects the offset form only when due_datetime is set, so the
// row would be one that never fires.
func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems))
}
}
// The default does not stack on top of an alarm the line asked for.
func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16 mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 {
t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin)
}
}
// "ingen alarm" is the way out of the default while writing the task.
//
// Without it the only way to have a timed task that does not ring is to create it,
// open it, and delete the alarm that was just made — which is the omission a
// default quietly creates.
func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16 ingen alarm",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "hente cyklen" {
t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen")
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems))
}
}
// A clock added later gets the alarm too.
//
// This is the half a promise about "every task with a date and time" loses if the
// default is only applied where tasks are created: a task written now and given a
// time this afternoon is the same task.
func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
taskID := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Fatalf("got %d reminders before there was a clock, want 0", len(rems))
}
resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{
"due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
"due_time": "16:00",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch task: %d %v", resp.StatusCode, body)
}
rems, err = ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders after the clock was added, want 1", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
}
}
// A positive offset is refused. An alarm after the thing it warns about is not a
// reminder, and accepting one here would make the single mistake this value can
// make — a sign the wrong way round — look like a choice somebody made.
func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10})
if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body)
}
}
// Null turns it off again, and the alarms already made are left alone: a default is
// what happens to the NEXT task, not a statement about the ones already written.
func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
kept := body["id"].(string)
resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("turn off: %d %v", resp.StatusCode, body)
}
resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente bilen i morgen kl 17",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders after turning the default off, want 0", len(rems))
}
rems, err = ts.db.ListReminders(ctx, kept)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Errorf("the alarm made while it was on is gone; turning a default off is not " +
"a statement about the tasks already written")
}
}
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"strings"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Besked til administratorerne, når der er kommet en ny version.
//
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
//
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
const updateScope = "update"
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
// slukket, opdager det ved den første time, den er vågen.
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
}
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
// prøves uden at spørge GitHub om noget.
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
// blive til en besked ad bagvejen.
if status.Disabled || !status.Available || status.Latest == "" {
return nil
}
stored, err := s.db.InstanceSettings(ctx, updateScope)
if err != nil {
return err
}
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if last, _ := stored["notified_version"].(string); last == status.Latest {
return nil
}
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
title := updateTitle(status.Latest)
body := firstLineOf(status.Notes)
for _, u := range users {
if !u.IsAdmin {
continue
}
s.notifyDirect(ctx, &store.Notification{
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
})
}
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
"notified_version": status.Latest,
})
}
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
// `kind` — se KINDS i Notifications.svelte.
func updateTitle(version string) string {
return "verdande " + version + " er klar"
}
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
//
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
func firstLineOf(notes string) string {
for _, line := range strings.Split(notes, "\n") {
if line = strings.TrimSpace(line); line != "" {
return firstWords(line, 30)
}
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/update"
)
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
// der var noget at se efter.
func TestANewVersionTellsTheAdministrator(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
status := update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
}
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("besked om ny version: %v", err)
}
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "update.available" {
t.Errorf("besked = %v", n)
}
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
// har ingen ordbog at slå sætningen op i.
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
t.Errorf("titel = %q", title)
}
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
}
// Og den næste version er en ny ting at få at vide.
status.Latest = "v0.46.0"
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 2 {
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
}
}
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "sofie@example.dk", "Sofie")
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
}
}
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, status := range []update.Status{
{Current: "v0.44.0", Disabled: true},
{Current: "v0.44.0", Available: false},
{Current: "v0.44.0", Available: true, Latest: ""},
} {
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("%v: %v", status, err)
}
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
}
}
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
// klikkes.
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
n := firstOf(t, mustNotifications(t, ts))
for _, key := range []string{"note_id", "task_id", "project_id"} {
if v, ok := n[key]; ok && v != "" {
t.Errorf("%s = %v, want ingenting", key, v)
}
}
}
func mustNotifications(t *testing.T, ts *testServer) []any {
t.Helper()
list, _ := notifications(t, ts)
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
return list
}
+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
+13
View File
@@ -54,6 +54,9 @@ type Runner struct {
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
DailyPlans func(ctx context.Context) error
// NotifyOfUpdates tells the administrators when a newer version is out.
NotifyOfUpdates func(ctx context.Context) error
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
@@ -98,6 +101,9 @@ func (r *Runner) Start(ctx context.Context) {
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
// syv er syv forskellige steder.
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
// seks timer, og beskeden sendes én gang pr. version.
r.every(ctx, "updates", time.Hour, r.updateNotices)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -132,6 +138,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) updateNotices(ctx context.Context) error {
if r.NotifyOfUpdates == nil {
return nil
}
return r.NotifyOfUpdates(ctx)
}
func (r *Runner) dailyPlans(ctx context.Context) error {
if r.DailyPlans == nil {
return nil
+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())
}
}
+180 -1
View File
@@ -37,6 +37,7 @@ const (
KindSection Kind = "section"
KindLabel Kind = "label"
KindRepeat Kind = "repeat"
KindReminder Kind = "reminder"
)
// Span is a byte range of the input that was consumed, and what it was read as.
@@ -70,7 +71,28 @@ type Result struct {
Recurrence string `json:"recurrence_rule,omitempty"`
// RecurrenceText is that rule written back out in Danish, for the preview.
RecurrenceText string `json:"recurrence_text,omitempty"`
Spans []Span `json:"spans,omitempty"`
// ReminderOffsetMin is when to be told, as minutes relative to the task's due
// moment: negative for before it, zero for at it. Nil when the line asked for
// no reminder.
//
// Relative rather than absolute, so that moving the task moves its alarm with
// it. Mind the sign: the store ADDS this to the due time
// (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that
// backwards gives an alarm an hour late, which is the one direction a reminder
// must never be wrong in.
ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"`
// ReminderAt is an absolute reminder, "YYYY-MM-DDTHH:MM", set when the line
// gave the reminder a clock of its own. It takes the day the task is due:
// "mind mig kl 8" about something due Friday means Friday at eight, not today.
ReminderAt string `json:"reminder_at,omitempty"`
// NoReminder is the line saying it wants no alarm on this one — "ingen alarm".
//
// It exists because of the account-wide default: with alarms on for everything
// that has a clock, there has to be a way to say "not this one" in the same
// breath as writing it. Without that the only way out is to create the task,
// open it, and delete the alarm that was just made.
NoReminder bool `json:"no_reminder,omitempty"`
Spans []Span `json:"spans,omitempty"`
}
// Parse reads input as of the moment `now`, which is also the timezone dates are
@@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result {
// mask keeps byte offsets intact, so spans still point into the original input.
masked := mask(input, spans)
// Reminders before the clock and the calendar, and that order is the whole
// trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a
// clock, and either one read by the time parser first becomes the task's own
// due time instead of its alarm. The phrase is anchored on a keyword the way a
// project is anchored on '#', so consuming it this early cannot take anything
// that was not offered to it.
// "No alarm" before "alarm", or the refusal leaves the very word the stage
// below is looking for standing in the line.
res.NoReminder, spans = extractNoReminder(masked, spans)
masked = mask(masked, spans)
rem, spans := extractReminder(masked, spans)
masked = mask(masked, spans)
// Recurrence before the date parser, and this order is load-bearing: "hver
// mandag" contains a weekday, and a date parser reading it first would claim
// Monday as a one-off due date and leave "hver" stranded in the title.
@@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result {
res.DueTime = pad2(hour) + ":" + pad2(minute)
}
// The reminder is resolved last, because an absolute one needs the due date the
// stages above just worked out.
if rem.found {
if rem.hasClock {
day := res.DueDate
if day == "" {
day = now.Format("2006-01-02")
}
res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min)
} else {
off := rem.offsetMin
res.ReminderOffsetMin = &off
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
res.Spans = spans
res.Content = remove(input, spans)
@@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) {
}
return "", spans
}
// --- reminders ----------------------------------------------------------------
// reReminderLead finds the words that introduce an alarm. A reminder is anchored
// on a keyword the way a project is anchored on '#': nothing becomes an alarm by
// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine
// and not to be woken before it.
//
// The trailing \b is load-bearing. Without it the pattern matches the first six
// letters of "alarmen" and of "mind migrationen", takes them out of the title, and
// sets an alarm nobody asked for.
var reReminderLead = regexp.MustCompile(
`(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` +
`|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`)
// reReminderOffset reads the tail as "this long before": "en time før", "10 min
// før", "et kvarter før", "dagen før", "two days before".
var reReminderOffset = regexp.MustCompile(
`(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` +
`(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` +
`|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` +
`(?:før|foer|inden|before)`)
// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30".
var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`)
// reminder is what the phrase said. OffsetMin is minutes relative to the due
// moment and negative for "before"; zero means at the due moment, which is what a
// bare "mind mig" asks for.
type reminder struct {
found bool
offsetMin int
hour, min int
hasClock bool
}
// extractReminder reads the alarm out of the line.
//
// The lead-in is matched first and the tail interpreted afterwards, rather than in
// one pattern, for the same reason the recurrence stage does it: a single
// expression able to express all three endings — a duration, a clock, or nothing
// at all — matches far more than the three things it is for.
func extractReminder(input string, spans []Span) (reminder, []Span) {
m := reReminderLead.FindStringSubmatchIndex(input)
if m == nil {
return reminder{}, spans
}
rem := reminder{found: true}
start, end := m[2], m[3]
tail := input[end:]
if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil {
n := 1
if t[2] >= 0 {
n = spokenNumber(tail[t[2]:t[3]])
}
rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]])
end += t[1]
} else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil {
hour := atoi(tail, t[2], t[3])
minute := 0
if t[4] >= 0 {
minute = atoi(tail, t[4], t[5])
}
// An impossible clock is not a reminder. Saying nothing is better than
// setting one for 25:00, which the store would happily keep and never fire.
if !valid(hour, minute) {
return reminder{}, spans
}
rem.hour, rem.min, rem.hasClock = hour, minute, true
end += t[1]
}
return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder})
}
// spokenNumber reads the count in front of a unit. People dictating say the word
// rather than the digit, and "mind mig en time før" is the commonest form there is.
func spokenNumber(w string) int {
switch strings.ToLower(w) {
case "en", "et", "a", "an":
return 1
case "to", "two":
return 2
case "tre", "three":
return 3
}
if n, err := strconv.Atoi(w); err == nil {
return n
}
return 1
}
// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their
// own count, so they come first: with a leading "en" they multiply by one and come
// out as themselves.
func unitMinutes(unit string) int {
u := strings.ToLower(strings.Join(strings.Fields(unit), " "))
switch {
case strings.HasPrefix(u, "halv"):
return 30
case strings.HasPrefix(u, "kvarter"):
return 15
case strings.HasPrefix(u, "min"):
return 1
case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"):
return 60
case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"):
return 1440
}
return 0
}
// reNoReminder finds the line asking not to be reminded. The account-wide default
// gives every task with a clock an alarm, and this is how one task opts out while
// it is being written rather than afterwards.
var reNoReminder = regexp.MustCompile(
`(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` +
`|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`)
func extractNoReminder(input string, spans []Span) (bool, []Span) {
m := reNoReminder.FindStringSubmatchIndex(input)
if m == nil {
return false, spans
}
return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder})
}
+148
View File
@@ -636,3 +636,151 @@ func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
}
}
}
// --- reminders ----------------------------------------------------------------
// A dictated sentence can set its own alarm.
//
// The offset is negative because the store ADDS it to the due moment
// (`due_datetime + offset_min * 60`). A positive number there is an alarm after
// the thing it was meant to warn about, which is the one direction a reminder must
// never be wrong in — so the sign is asserted here and not assumed.
func TestParseReminderOffset(t *testing.T) {
now := ref(t)
cases := []struct {
in string
want int
}{
{"ring til tandlægen i morgen kl 9 mind mig en time før", -60},
{"ring til tandlægen i morgen kl 9 mind mig 10 min før", -10},
{"ring til tandlægen i morgen kl 9 mind mig 10 minutter før", -10},
{"ring til tandlægen i morgen kl 9 mind mig et kvarter før", -15},
{"ring til tandlægen i morgen kl 9 mind mig en halv time før", -30},
{"møde i morgen kl 9 mind mig dagen før", -1440},
{"møde i morgen kl 9 mind mig to dage før", -2880},
{"møde i morgen kl 9 mind mig 2 timer før", -120},
// Det er talt ind, så kommaet hører til sætningen og ikke til syntaksen.
{"ring til tandlægen i morgen kl 9, mind mig en time før", -60},
{"ring til tandlægen i morgen kl 9, påmind mig en time før", -60},
{"ring til tandlægen i morgen kl 9, mind mig om det en time før", -60},
// Engelsk i samme linje, som resten af parseren gør det.
{"call the dentist tomorrow at 9 remind me an hour before", -60},
{"call the dentist tomorrow at 9 remind me 15 minutes before", -15},
{"call the dentist tomorrow at 9 remind me two days before", -2880},
// En bar påmindelse er på selve forfaldstidspunktet.
{"ring til tandlægen i morgen kl 9 mind mig", 0},
{"ring til tandlægen i morgen kl 9 med alarm", 0},
}
for _, c := range cases {
got := Parse(c.in, now, "da")
if got.ReminderOffsetMin == nil {
t.Errorf("Parse(%q): ReminderOffsetMin = nil, want %d", c.in, c.want)
continue
}
if *got.ReminderOffsetMin != c.want {
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want %d", c.in, *got.ReminderOffsetMin, c.want)
}
}
}
// A reminder with a clock of its own takes the day the task is due.
//
// "mind mig kl 8" about something due Friday means Friday at eight. Resolving it
// against today instead would fire the alarm days early, silently.
func TestParseReminderClockTakesTheDueDay(t *testing.T) {
now := ref(t)
cases := []struct {
in string
want string
}{
{"ring til tandlægen i morgen kl 9 mind mig kl 8", "2026-03-11T08:00"},
{"ring til tandlægen i morgen kl 9 mind mig kl. 7.30", "2026-03-11T07:30"},
{"ring til tandlægen i morgen kl 9, påmind kl 7:15", "2026-03-11T07:15"},
{"call the dentist tomorrow at 9 remind me at 8", "2026-03-11T08:00"},
// Ingen dato i linjen: så er det i dag, som en bar klokketid også er.
{"ring til tandlægen mind mig kl 23", "2026-03-10T23:00"},
}
for _, c := range cases {
got := Parse(c.in, now, "da")
if got.ReminderAt != c.want {
t.Errorf("Parse(%q): ReminderAt = %q, want %q", c.in, got.ReminderAt, c.want)
}
if got.ReminderOffsetMin != nil {
t.Errorf("Parse(%q): also set an offset (%d); a reminder is one or the other",
c.in, *got.ReminderOffsetMin)
}
}
}
// The alarm does not eat the task's own time, and the task's time does not eat the
// alarm. This is the ordering the stage exists for.
func TestReminderDoesNotTakeTheTasksOwnTime(t *testing.T) {
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 8", ref(t), "da")
if got.DueTime != "09:00" {
t.Errorf("DueTime = %q, want 09:00 — the reminder took the task's clock", got.DueTime)
}
if got.DueDate != "2026-03-11" {
t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate)
}
if got.ReminderAt != "2026-03-11T08:00" {
t.Errorf("ReminderAt = %q, want 2026-03-11T08:00", got.ReminderAt)
}
if got.Content != "ring til tandlægen" {
t.Errorf("Content = %q, want %q", got.Content, "ring til tandlægen")
}
}
// Nothing becomes an alarm by standing near a clock.
func TestNoReminderWhereNoneWasAskedFor(t *testing.T) {
now := ref(t)
for _, in := range []string{
"ring til tandlægen i morgen kl 9",
"betal moms i morgen kl 10 p1 #Firma @regnskab",
// The \b cases: these words merely begin with the keywords.
"skift batteri i alarmen",
"mind migrationen om at køre",
"vand planterne hver mandag kl 9",
} {
got := Parse(in, now, "da")
if got.ReminderOffsetMin != nil {
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want none", in, *got.ReminderOffsetMin)
}
if got.ReminderAt != "" {
t.Errorf("Parse(%q): ReminderAt = %q, want none", in, got.ReminderAt)
}
}
}
// The phrase is highlighted, like every other thing the parser claims — see the
// package comment: a parser you cannot see working is one you stop trusting.
func TestReminderIsHighlighted(t *testing.T) {
in := "ring til tandlægen i morgen kl 9 mind mig en time før"
got := Parse(in, ref(t), "da")
var span *Span
for i := range got.Spans {
if got.Spans[i].Kind == KindReminder {
span = &got.Spans[i]
}
}
if span == nil {
t.Fatalf("no %q span in %v", KindReminder, got.Spans)
}
if marked := in[span.Start:span.End]; marked != "mind mig en time før" {
t.Errorf("highlighted %q, want %q", marked, "mind mig en time før")
}
}
// An impossible clock is not a reminder: the store would keep 25:00 and never fire
// it, so saying nothing is the honest answer.
func TestImpossibleReminderClockIsNotKept(t *testing.T) {
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 25", ref(t), "da")
if got.ReminderAt != "" {
t.Errorf("ReminderAt = %q, want none", got.ReminderAt)
}
if got.ReminderOffsetMin != nil {
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
}
}
+83
View File
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
return nil
}
// --- invitationer til ét projekt --------------------------------------------------
// ProjectInvite is somebody invited to a project who has not arrived yet.
//
// The same shape as NoteInvite and deliberately not the same type: the two rows
// differ in which column carries the target, and one struct covering both would
// need a field that is empty half the time — which is how a caller ends up
// reading the wrong one.
type ProjectInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListProjectInvites is who has been invited to a project and not yet taken it up.
//
// The panel needs them beside the members for the same reason the note panel does:
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
// second link to the same inbox, and no way to take either back. That mattered
// less while inviting meant typing an address; it matters more now that the panel
// offers a list to pick from, because the people who are *missing* from that list
// are exactly the ones already invited.
//
// Expired ones are left out, the same rule the administrator's list follows.
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, projectID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProjectInvite{}
for rows.Next() {
var i ProjectInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
// withdraw anything else.
//
// The project id is in the WHERE rather than checked beforehand, for the same
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
// established for *this* project, and a statement that can only touch this
// project's rows cannot be talked into deleting somebody else's invite by id.
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
inviteID, projectID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingProjectInvite reports whether this address already has a live invitation
// to this project, so a second attempt can say so instead of sending another link.
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
AND accepted_at IS NULL AND expires_at > ?`,
projectID, email, time.Now().Unix()).Scan(&n)
return n > 0, err
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
@@ -0,0 +1,23 @@
-- Alarm som standard på opgaver med et klokkeslæt.
--
-- Påmindelser har virket længe og skulle sættes én ad gangen: en opgave med
-- "i morgen kl 9" fik en tid og ingen alarm, for intet skaber en påmindelse af sig
-- selv. Det er den rigtige standard for en opgave UDEN klokkeslæt — der er intet
-- tidspunkt at ringe på — men for en med et er det en omvej, man går hver gang.
--
-- Minutter i forhold til forfaldstiden, og NULL betyder slået fra. Én nullable
-- kolonne frem for et flueben plus et tal, så tilstanden "slået til uden
-- forskydning" ikke findes og derfor ikke kan blive uenig med sig selv.
--
-- Fortegnet er det samme som i reminders.offset_min, fordi det er den kolonne,
-- værdien ender i: lageret LÆGGER tallet til forfaldstiden, så en alarm FØR er
-- negativ og 0 er præcis på tidspunktet. To forskellige fortegnsregler for det
-- samme begreb er den slags forskel, der kun viser sig som en alarm, der ringer
-- på det forkerte tidspunkt.
--
-- NULL for alle, der findes i dag, og det er med vilje. At slå den til i en
-- migrering ville give hver eksisterende bruger alarmer, de ikke har bedt om, på
-- opgaver de har liggende — og det her er software, andre selv driver. En
-- indstilling, man selv slår til, er en anden sag end en notifikation, der
-- pludselig kommer.
ALTER TABLE users ADD COLUMN default_reminder_min INTEGER;
@@ -0,0 +1,19 @@
-- Sessioner fra en app.
--
-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en
-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes
-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke
-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en
-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder,
-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et
-- token med vilje ikke gør.
--
-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen
-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at
-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får
-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke
-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt
-- år, skal.
ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web';
ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0;
+70 -31
View File
@@ -15,9 +15,36 @@ type Session struct {
UA string
IP string
PendingTOTP bool
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
// Client is "web" for a browser and "app" for the iOS app, which carries its
// session as a bearer token rather than a cookie. DeviceName is what the app
// calls itself; a browser has none and is described from its user agent.
Client string
DeviceName string
// IdleTTL above zero makes the session slide: each use moves ExpiresAt to
// IdleTTL from now. Zero is the fixed lifetime a browser session has always had.
IdleTTL time.Duration
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl,
created_at, last_seen_at, expires_at`
func scanSession(row interface{ Scan(...any) error }) (*Session, error) {
var s Session
var pending int
var idle, created, lastSeen, expires int64
if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle,
&created, &lastSeen, &expires); err != nil {
return nil, err
}
s.PendingTOTP = pending == 1
s.IdleTTL = time.Duration(idle) * time.Second
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
return &s, nil
}
// CreateSession issues a session and returns the value to put in the cookie.
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
UA: truncate(ua, 500),
IP: truncate(ip, 100),
PendingTOTP: pendingTOTP,
Client: "web",
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(ttl),
@@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
id := auth.HashToken(token)
var s Session
var pending int
var created, lastSeen, expires int64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
FROM sessions WHERE id = ?`, id).
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
s, err := scanSession(db.QueryRowContext(ctx,
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrNotFound
}
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
return nil, nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
if time.Now().After(s.ExpiresAt) {
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
return nil, nil, ErrNotFound
@@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
// last_seen_at drives the session list in settings ("this device, 2 minutes
// ago"). It is written at most once a minute: every request would be a write
// per request, which on SQLite means a write lock per request.
//
// A sliding session moves its end in the same write, and only then: its
// expiry is therefore accurate to the minute, which is far finer than the
// months it is measured in.
if time.Since(s.LastSeenAt) > time.Minute {
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
now := time.Now()
if s.IdleTTL > 0 && !s.PendingTOTP {
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
now.Unix(), s.ExpiresAt.Unix(), s.ID)
} else {
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID)
}
}
return &s, user, nil
return s, user, nil
}
// MarkAppSession records that a session belongs to the iOS app, what the device
// calls itself, and how long it may sit unused (see Session.IdleTTL).
//
// A login still waiting on its code is marked with idle 0, so it keeps its ten
// minutes; PromoteSession turns sliding on once the code is in.
func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error {
_, err := db.ExecContext(ctx,
`UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`,
truncate(deviceName, 100), int64(idle/time.Second), sessionID)
return err
}
// PromoteSession completes a two-step login: the session stops being pending and
// its clock restarts, so the full lifetime is counted from finishing the login and
// not from typing the password.
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
//
// idle above zero makes the finished session slide (see Session.IdleTTL); a
// browser passes zero.
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error {
res, err := db.ExecContext(ctx,
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
time.Now().Add(ttl).Unix(), sessionID)
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
if err != nil {
return err
}
@@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
`SELECT `+sessionColumns+`
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
if err != nil {
return nil, err
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
var out []Session
for rows.Next() {
var s Session
var pending int
var created, lastSeen, expires int64
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
s, err := scanSession(rows)
if err != nil {
return nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, s)
out = append(out, *s)
}
return out, rows.Err()
}
+44 -3
View File
@@ -46,7 +46,22 @@ type User struct {
// NavOrder is the order this person wants the fixed views in, by key. Empty
// means the order the program ships with. Unknown keys are ignored and missing
// ones are appended, so adding a view later is not a migration.
NavOrder []string
NavOrder []string
// DefaultReminderMin gives every task that has a clock an alarm without being
// asked, as minutes relative to the due moment: negative for before it, zero
// for at it. Nil is off, which is what every account starts as.
//
// The same sign convention as reminders.offset_min, because this is the column
// the value ends up in — the store ADDS it to the due time. Two sign rules for
// one idea is the kind of difference that only shows up as an alarm going off
// at the wrong moment.
//
// Only tasks with a clock. A task due "tomorrow" with no time has no moment to
// ring at, and DueReminders selects the offset form only when due_datetime is
// set, so an alarm there would be a row that never fires.
DefaultReminderMin *int
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -136,7 +151,8 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order,
default_reminder_min, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -145,10 +161,12 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var created, updated int64
var collapsed, navOrder string
var defaultReminder sql.NullInt64
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder,
&defaultReminder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -172,6 +190,10 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.NavOrder = []string{}
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
if defaultReminder.Valid {
v := int(defaultReminder.Int64)
u.DefaultReminderMin = &v
}
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
@@ -691,3 +713,22 @@ func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
}
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
}
// SetDefaultReminder records whether tasks with a clock get an alarm unasked, and
// how far ahead of the due moment it rings. Nil turns it off.
func (db *DB) SetDefaultReminder(ctx context.Context, userID string, minutes *int) error {
var value any
if minutes != nil {
value = *minutes
}
res, err := db.ExecContext(ctx,
`UPDATE users SET default_reminder_min = ?, updated_at = ? WHERE id = ?`,
value, time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+6
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
@@ -51,8 +52,11 @@ nav:
- Signing in: signing-in.md
- Quick add: quick-add.md
- Notes: notes.md
- Notifications: notifications.md
- Opening links: links.md
- Filters: filters.md
- Importing from Todoist: import.md
- The iOS app: ios.md
- Connecting things:
- Calendar: calendar.md
- Feeds and CalDAV: caldav.md
@@ -60,6 +64,8 @@ nav:
- Mailboxes: mailboxes.md
- Claude (MCP): mcp.md
- AI features: ai.md
- Privacy:
- The install count: beacon.md
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
# file, and api.md links to it. A nav entry expects a page to render.
- Reference:
+67 -11
View File
@@ -819,6 +819,15 @@
color: var(--accent);
}
</style>
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
står under "It counts itself". Plausible på vores egen maskine: ingen
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
<script
defer
data-domain="verdandetodo.com"
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
></script>
</head>
<body>
@@ -1326,6 +1335,64 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.47</span>
Click a picture in a note and it opens at full size, with a button that
copies it to the clipboard. In the note it is scaled to the width of the
column — a photo off a phone is four thousand pixels across — so what you
were looking at was a thumbnail that did not look like one.
</li>
<li>
<span class="ver">0.47</span>
Search in ⌘K, pick a note, and the rest of the matches stay in the list
beside it. They used to exist only in the popup you had just closed.
</li>
<li>
<span class="ver">0.46</span>
Share a project with somebody who already has an account by picking their
name, rather than by spelling an email address you would have to know.
Invitations that have not been taken up stay visible so they do not get
sent twice, and can be withdrawn.
</li>
<li>
<span class="ver">0.46</span>
You are told when a project is shared with you, and there is a
<strong>Leave the project</strong> button. Being let in without being
asked, without being told, and with no way out was three things at once.
</li>
<li>
<span class="ver">0.45</span>
Search for a setting instead of remembering which tab it was under — in
the settings themselves and in ⌘K. It knows them by what they do, so
<em>beacon</em> finds the install count even though that is not its name.
</li>
<li>
<span class="ver">0.45</span>
Today's plan is a card on <strong>I dag</strong> rather than only a
message in the morning that was gone if you did not read it at once. And
a link can be told which browser to open in.
</li>
<li>
<span class="ver">0.44</span>
An optional AI layer, on your own key: it tidies the inbox, pulls the
tasks out of a meeting note, writes a morning plan, turns
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
for, and answers questions about your own notes with links to what the
answer rests on. Every one of them is a line you accept, edit or refuse —
it suggests, it never writes.
</li>
<li>
<span class="ver">0.44</span>
Administrators are told in the bell when a new version is out, once per
version, and the message leads to the button that restarts.
</li>
<li>
<span class="ver">0.43</span>
Anything that can call a URL can put a task in your inbox — a shortcut on
your phone, a script, a service. Notes can be handed to somebody who has
no account yet, and writing <code>@Sofie</code> in a note shares it with
her and tells her. Unread counts show on the app's icon in the Dock.
</li>
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
@@ -1362,17 +1429,6 @@
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
</ul>
</section>
+219
View File
@@ -0,0 +1,219 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
/**
* The interface the way the iOS app runs it: from a different origin than the
* server, with no cookie, its session as a bearer token.
*
* The real app is served from capacitor://localhost, which a test browser cannot
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
* the loopback address, so it is this same server, but to the browser it is a
* different origin, and that difference is the whole thing under test. The server
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
*
* bypassCSP because the server's policy is written for its own pages
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
* Without this the browser would block exactly the cross-origin calls the test
* exists to make, and the failure would read as the app not working.
*/
const PORT = 8097;
const SERVER = `http://localhost:${PORT}`;
test.use({
baseURL: `http://app.localhost:${PORT}`,
bypassCSP: true,
storageState: { cookies: [], origins: [] }
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.Capacitor = { isNativePlatform: () => true };
});
});
/** The app's session, read the way the app reads it. */
const storedToken = (page) =>
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
await page.goto('/');
// First the address — and an address with nothing behind it is said to be so,
// rather than leading to a sign-in form that cannot succeed.
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
await page.getByRole('button', { name: 'Fortsæt' }).click();
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
await page.getByLabel(/Serveradresse/).fill(SERVER);
await page.getByRole('button', { name: 'Fortsæt' }).click();
// The sign-in form, saying where it signs in to.
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// Signed in by token, and by nothing else: no session cookie on either origin.
const token = await storedToken(page);
expect(token).not.toBe('');
const cookies = await page.context().cookies();
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
// Live sync over a ticketed socket: a project made behind the interface's back
// — straight to the API, as another device would — shows up in the sidebar
// without a reload. Nothing but the socket can have put it there.
const name = `Fra en anden enhed ${Date.now()}`;
const status = await page.evaluate(
async ({ server, token, name }) => {
const res = await fetch(`${server}/api/v1/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return res.status;
},
{ server: SERVER, token, name }
);
expect(status).toBe(201);
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
// A picture from the server, which an <img> cannot fetch with a token. It is
// shown from a blob, and the path it came from is kept for the note to save.
const path = await page.evaluate(
async ({ server, token }) => {
const auth = { Authorization: `Bearer ${token}` };
const note = await fetch(`${server}/api/v1/notes`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: '# Billede fra appen' })
}).then((r) => r.json());
// A one-pixel PNG.
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
method: 'POST',
headers: auth,
body: form
}).then((r) => r.json());
const img = document.createElement('img');
img.id = 'appbillede';
img.setAttribute('src', saved.url);
document.body.append(img);
return saved.url;
},
{ server: SERVER, token }
);
const img = page.locator('#appbillede');
await expect(img).toHaveAttribute('data-src', path);
await expect(img).toHaveAttribute('src', /^blob:/);
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
// Signing out takes the token off the phone, and keeps the server.
await page.evaluate(() => document.getElementById('appbillede')?.remove());
// Two taps: the first asks, the second signs out.
await page.getByRole('button', { name: /Log ud/ }).click();
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
expect(await storedToken(page)).toBe('');
// And the token is dead on the server too, not just forgotten here.
const after = await page.evaluate(
async ({ server, token }) =>
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
.status,
{ server: SERVER, token }
);
expect(after).toBe(401);
});
/**
* Signs in the way the app does, outside the page, and hands the page the server
* and the session as the app would have stored them.
*/
async function signedInApp(page) {
const origin = { Origin: `http://app.localhost:${PORT}` };
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
method: 'POST',
headers: { ...origin, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: USER.email,
password: USER.password,
client: 'app',
device_name: 'Prøve-iPhone'
})
});
const { session_token: token } = await res.json();
expect(token).toBeTruthy();
await page.addInitScript(
({ server, token }) => {
localStorage.setItem('verdande.app.server', server);
localStorage.setItem('verdande.app.session', token);
},
{ server: SERVER, token }
);
const call = async (method, path, body) => {
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const r = await fetch(`${SERVER}/api/v1${path}`, init);
return r.status === 204 ? null : r.json();
};
return call;
}
/**
* The picture in a note is shown from a blob made on the phone. Editing the note
* turns the page back into markdown, and what it must write is the path the
* picture came from — a blob address saved into a note is a picture that is gone
* the next time anybody opens it, on any device.
*/
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
page
}) => {
const call = await signedInApp(page);
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
'base64'
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
await call('PATCH', `/notes/${note.id}`, {
body: `# Billednote fra appen\n\n![](${saved.url})\n\nTekst under billedet`
});
await page.goto('/noter?note=' + note.id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const img = ed.locator('img');
await expect(img).toHaveAttribute('src', /^blob:/);
await expect(img).toHaveAttribute('data-src', saved.url);
// Touch the note so it saves, and wait for the save itself.
const patched = page.waitForResponse(
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
);
await ed.getByText('Tekst under billedet').click();
await page.keyboard.press('End');
await page.keyboard.type(' og mere');
await patched;
const after = await call('GET', `/notes/${note.id}`);
expect(after.body).toContain('og mere');
expect(after.body).toContain(`](${saved.url})`);
expect(after.body).not.toContain('blob:');
});
+272
View File
@@ -0,0 +1,272 @@
import { test, expect } from '@playwright/test';
import { watchForTrouble } from './trouble.js';
/**
* 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');
// ð, ikke ᚹ wunjo og ikke ᚢ uruz. Begge de to er navngivne værdier frem for
// "der står noget": en tom span, Verdandes mærke og uruz-appens ser ens ud for
// en prøve, der kun spørger, om feltet er udfyldt.
await expect(mærke.locator('.rune')).toHaveText('ð');
await expect(mærke.locator('.rune')).not.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);
});
/**
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
*
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
* manglede. Der er ingen fejlskærm at se.
*
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
* sektion, der ikke var noget i.
*
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
* notesbogens noter.
*
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
*
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
* ikke "ingen flade".
*/
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
const trouble = watchForTrouble(page);
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
// og den er den eneste side med en id i stien, der kan nås.
await page.goto('/noter');
const projekter = await (await page.request.get('/api/v1/projects')).json();
const id = (projekter.projects ?? projekter)[0]?.id;
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
const sider = [
'/noter',
'/genveje',
'/indstillinger',
'/indstillinger/ai',
'/indstillinger/brugere',
'/indstillinger/data',
'/indstillinger/fejl',
'/indstillinger/historik',
'/indstillinger/integrationer',
'/indstillinger/notifikationer',
'/indstillinger/tokens',
`/projekt/${id}`
];
for (const side of sider) {
await page.goto(side);
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
// nået at komme afsted. Uden den her går løkken videre, før svarene er
// kommet, og prøven måler ingenting.
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await page.waitForLoadState('networkidle');
}
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
});
test('ikonet er urds, hele vejen ud', async ({ page }) => {
// Målt på stregerne i SVG'en frem for på "der kom et billede": en tom
// respons, verdandes mærke og urds ser alle ens ud for en prøve, der kun
// spørger, om der står noget.
const svg = await (await page.request.get('/icon.svg')).text();
expect(svg, 'ð: skålen').toContain('cx="32" cy="42" r="12"');
expect(svg, 'ð: ascenderen').toContain('M44 36L27 10');
expect(svg, 'ð: tværstregen, som er det der skiller ð fra et d').toContain('M25 20h18');
expect(svg, 'og ikke ᚹ wunjos flag').not.toContain('M26 15l17');
// De tre PNG'er har ingen streger at læse, så de måles på at de er urds —
// sammenlignet med filen under sit eget navn, som ligger i samme byg.
for (const [sti, egen] of [
['/icon-192.png', '/urd-icon-192.png'],
['/icon-512.png', '/urd-icon-512.png'],
['/apple-touch-icon.png', '/urd-apple-touch-icon.png']
]) {
const a = await (await page.request.get(sti)).body();
const b = await (await page.request.get(egen)).body();
expect(a.length, `${sti} er tom`).toBeGreaterThan(1000);
expect(a.equals(b), `${sti} er ikke urds`).toBe(true);
}
});
+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 }
]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
/**
* Indekset over indstillingerne skal passe på siderne.
*
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
*
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
* ville melde fejl på det, der med rette var skjult.
*/
const here = dirname(fileURLToPath(import.meta.url));
const routes = join(here, '..', 'src', 'routes');
const locales = join(here, '..', 'src', 'lib', 'locales');
/** `/indstillinger/data` → filen, den side tegnes af. */
function pageFile(path) {
return join(routes, path, '+page.svelte');
}
test('hvert afsnit i indekset findes på den side, det peger på', () => {
const missing = [];
for (const entry of SETTINGS) {
const src = readFileSync(pageFile(entry.path), 'utf8');
const hits = src.split(`id="${entry.anchor}"`).length - 1;
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
}
expect(missing).toEqual([]);
});
test('to afsnit deler ikke et anker', () => {
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
const seen = new Set();
const dupes = [];
for (const entry of SETTINGS) {
const key = `${entry.path}#${entry.anchor}`;
if (seen.has(key)) dupes.push(key);
seen.add(key);
}
expect(dupes).toEqual([]);
});
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
const missing = [];
for (const locale of ['da', 'en']) {
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
for (const entry of SETTINGS) {
for (const key of [entry.key, entry.tab]) {
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
}
}
}
expect(missing).toEqual([]);
});
test('søgningen finder det, man ville skrive', () => {
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
const t = (key) => key;
const found = (q, admin = true) =>
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
expect(found('beacon')).toContain('/indstillinger/data#beacon');
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
expect(found('2fa')).toContain('/indstillinger#to-faktor');
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
expect(found('')).toEqual([]);
expect(found(' ')).toEqual([]);
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
// kan holde.
expect(found('backup', false)).toEqual([]);
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
});
+1861 -44
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
* der ser den.
*/
export function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
+100 -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,11 @@ 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.
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
testIgnore: /(edition|app)\.spec\.js/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
@@ -95,6 +106,34 @@ 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}` }
},
/**
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
* cookie, for det er netop det, appen ikke har.
*/
{
name: 'app-origin',
testMatch: /app\.spec\.js/,
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup']
},
{
name: 'webkit-forms',
grep: /@forms/,
@@ -103,42 +142,67 @@ 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 origin, app-origin-projektet kører fladen fra, regnet som appens
// egen — som capacitor://localhost er det i den rigtige app.
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
}
},
// 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'
}
}
}
]
});
+47 -16
View File
@@ -1,40 +1,71 @@
/**
* Renders static/icon.svg to the PNGs the manifest and iOS need.
* Renders each edition's icon SVG to the PNGs the manifest and iOS need.
*
* node scripts/generate-icons.js
*
* A script rather than a test, so CI never runs it and never rewrites a tracked
* binary. Run it when the icon changes; the smoke test fetches every icon the
* binary. Run it when an icon changes; the smoke test fetches every icon the
* manifest names, so a reference with no file behind it fails there.
*
* It drives Chromium because there is no SVG renderer on this machine — which is
* why these references were once removed rather than left pointing at files that
* did not exist. Playwright is a renderer, and it is already a dependency.
*
* Two sets, because there are two editions. Both ship in the one binary and the
* server decides which to serve at /icon.svg and friends — see
* internal/httpapi/shell.go. The alternative, rewriting the manifest to point at
* urd-icon-192.png, would have left `apple-touch-icon.png` behind: Safari looks
* for that one BY NAME and ignores the manifest entirely, so a renamed path there
* is a home-screen icon that is a screenshot of the page.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
// The three sizes, and why each is here.
const SIZES = [
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
{ file: 'static/icon-192.png', size: 192 },
{ file: 'static/icon-512.png', size: 512 },
// And the one Safari wants by name. It ignores the manifest's icons entirely
// and looks for this, which is why an installed PWA on iOS otherwise gets a
// screenshot of the page as its home-screen icon.
{ file: 'static/apple-touch-icon.png', size: 180 }
{ suffix: '-192.png', size: 192 },
{ suffix: '-512.png', size: 512 },
// And the one Safari wants by name.
{ suffix: '-apple-touch.png', size: 180 }
];
// `out` is spelled out per set rather than built from the prefix, because
// verdande's three were named before there was a second set and renaming them
// would move the paths the manifest and app.html already point at.
const SETS = [
{
source: 'static/icon.svg',
out: {
'-192.png': 'static/icon-192.png',
'-512.png': 'static/icon-512.png',
'-apple-touch.png': 'static/apple-touch-icon.png'
}
},
{
source: 'static/urd-icon.svg',
out: {
'-192.png': 'static/urd-icon-192.png',
'-512.png': 'static/urd-icon-512.png',
'-apple-touch.png': 'static/urd-apple-touch-icon.png'
}
}
];
const svg = readFileSync('static/icon.svg', 'utf8');
const browser = await chromium.launch();
const page = await browser.newPage();
for (const { file, size } of SIZES) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
for (const set of SETS) {
const svg = readFileSync(set.source, 'utf8');
for (const { suffix, size } of SIZES) {
const file = set.out[suffix];
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
}
}
await browser.close();
+45
View File
@@ -0,0 +1,45 @@
/**
* Renders the iOS app's icon and launch image from static/icon.svg.
*
* node scripts/generate-ios-assets.js
*
* Same reasoning as generate-icons.js: a script, not a test, run when the icon
* changes, with Chromium as the SVG renderer because it is already here.
*
* Two things differ from the web icons, and both are Apple's rules rather than
* taste. The app icon is square with no transparency — iOS draws the rounded
* corners itself, and rounded corners already in the image show as a dark ring
* inside its own mask — so the tile's `rx` is dropped and the result flattened.
* And the launch image is the rune small on the tile colour, the size
* Capacitor's template asks for, so the app opens on the colour it is about to
* draw instead of on white.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
const ICONS = '../app/ios/App/App/Assets.xcassets';
const svg = readFileSync('static/icon.svg', 'utf8').replace(/ rx="[^"]*"/, '');
const tile = /<rect[^>]*fill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1];
if (!tile) throw new Error('static/icon.svg has no tile colour to paint the launch image with');
const browser = await chromium.launch();
const page = await browser.newPage();
async function render(file, size, markSize) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0;background:${tile}}` +
`div{width:${size}px;height:${size}px;display:grid;place-items:center}` +
`svg{display:block;width:${markSize}px;height:${markSize}px}</style><div>${svg}</div>`
);
writeFileSync(file, await page.locator('div').screenshot({ omitBackground: false }));
console.log(`${file} ${size}×${size}`);
}
await render(`${ICONS}/AppIcon.appiconset/AppIcon-512@2x.png`, 1024, 1024);
// Capacitor's template names three files for the one launch image; all the same.
for (const name of ['splash-2732x2732.png', 'splash-2732x2732-1.png', 'splash-2732x2732-2.png']) {
await render(`${ICONS}/Splash.imageset/${name}`, 2732, 640);
}
await browser.close();
+53 -10
View File
@@ -24,6 +24,7 @@ export class ApiError extends Error {
import { t } from './i18n.svelte.js';
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
/**
* How long a request keeps retrying a network failure before giving up.
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
// In a browser: the httpOnly session cookie, same-origin, and nothing here
// handles a token. In the iOS app: its session token as a bearer. See
// platform.js.
authorize(init);
// Retries a network failure for a few seconds so a short drop does not lose the
// call; `withRetry` turns an exhausted window into the same `offline` error the
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
if (res.status === 204) return null;
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
form.append(field, file);
const res = await withRetry(() =>
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
);
const text = await res.text();
@@ -232,15 +235,39 @@ export const api = {
// --- auth
setupState: () => get('/auth/setup'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
login: async (email, password) => {
if (!isNativeApp()) return post('/auth/login', { email, password });
// The app gets its session in the answer instead of a cookie, and keeps
// it — also while a code is still owed, because the second step is sent
// with it.
const result = await post('/auth/login', {
email,
password,
client: 'app',
device_name: deviceName()
});
if (result?.session_token) appSession.setToken(result.session_token);
return result;
},
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
logout: async () => {
try {
return await post('/auth/logout');
} finally {
// Gone from the phone whether or not the server heard: a sign-out that
// left the token behind because the network was down is not one.
if (isNativeApp()) appSession.signOut();
}
},
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setNavOrder: (order) => put('/auth/nav-order', { order }),
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
// null slu00e5r den fra. Minutter i forhold til forfaldstiden, negativt for fu00f8r —
// samme fortegn som reminders.offset_min, fordi det er den kolonne, den ender i.
setDefaultReminder: (minutes) => put('/auth/default-reminder', { minutes }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
@@ -338,7 +365,11 @@ export const api = {
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
// hører til en konto her.
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
@@ -361,8 +392,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 }),
@@ -409,6 +445,9 @@ export const api = {
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
deleteToken: (id) => del(`/tokens/${id}`),
// --- live sync, for the app (see stores.svelte.js)
wsTicket: () => post('/ws-ticket'),
// --- notifications and push
listNotifications: () => get('/notifications'),
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
@@ -496,7 +535,9 @@ export const api = {
// Dagens plan om morgenen.
planSettings: () => get('/ai/plan'),
setPlanSettings: (body) => put('/ai/plan', body),
planNow: () => post('/ai/plan/now'),
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
planNow: (options) => post('/ai/plan/now', options ?? {}),
version: () => get('/version'),
@@ -505,6 +546,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. -->
+29 -2
View File
@@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { humanMessage } from '$lib/api.js';
import { findSettings } from '$lib/settingsindex.js';
let { open = $bindable(false) } = $props();
@@ -68,10 +69,27 @@
}, 140);
});
/**
* Indstillingerne, og hvorfor de ligger øverst.
*
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
* modsat en søgning i noget, man selv har skrevet.
*/
let settings = $derived(
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
);
let results = $derived([
...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,
@@ -108,7 +126,16 @@
function choose(item) {
open = false;
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
// op, den ER stedet.
if (item.kind === 'setting') return goto(item.id);
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
// liste står uændret — og de andre træffere var kun at se i den popup, man
// lige lukkede.
if (item.kind === 'note') {
const q = query.trim();
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
}
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
+243
View File
@@ -0,0 +1,243 @@
<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
*
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
* på det, og intet oprettes før man siger ja.
*
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
* lyden, den stiller feltet frem.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>
@@ -0,0 +1,95 @@
<script>
/**
* What the app shows when it could not reach the server at all.
*
* Before this, an outage rendered the sign-in screen: the boot asks who you
* are, the request never gets an answer, `user` stays null, and null means
* "logged out" everywhere else. So the app blamed the session for the network
* being down, and offered a password box that could not possibly work. That is
* worse than an error message, because it looks like it might work — people
* type the password, get nothing, and conclude their account is broken.
*
* It happened for real on 17 September 2026: the instance's DNS record was
* deleted, and what the app showed was a slow-loading login page.
*
* The text says the data is safe, because that is the question somebody
* actually has when their notes vanish behind a login box.
*/
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
let trying = $state(false);
async function again() {
trying = true;
try {
await app.load();
} finally {
trying = false;
}
}
</script>
<div class="screen">
<div class="card">
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
</button>
</div>
</div>
<style>
.screen {
height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
.card {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: var(--s4);
text-align: center;
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
h1 {
font-size: var(--text-xl);
margin: 0;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.lede {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
</style>

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