Compare commits

...
Author SHA1 Message Date
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
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
47 changed files with 2466 additions and 215 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.
+57 -1
View File
@@ -40,7 +40,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 ./...
@@ -117,9 +123,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'
+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
+17
View File
@@ -15,6 +15,23 @@
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
+6 -1
View File
@@ -26,6 +26,9 @@ var version = "dev"
func main() {
if err := run(); err != nil {
// Not the edition's name: this fires before the configuration is read, so
// the edition is not known yet — and one of the things it reports is a
// VERDANDE_EDITION the program refused.
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
os.Exit(1)
}
@@ -37,7 +40,9 @@ func run() error {
return err
}
log := httpapi.NewLogger(cfg.Dev)
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
// The edition's own name, so an urd instance's log says urd. The full edition
// still logs "starting verdande", which is what the release check reads.
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
// The data volume is the only state that matters; create the whole layout up
// front so a failure here is a startup error rather than a surprise at the
+4 -2
View File
@@ -1,6 +1,7 @@
# The install count
verdande reports that it exists. Two values, once a day:
verdande reports that it exists — and so does urd, which is the same program in
its [notes edition](editions.md). Two values, once a day:
```json
{
@@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day:
That is the whole message. No IP is stored by the collector, no hostname, no
domain, no account, no counts of anything you have made, nothing about what you
use it for. The payload is small enough to be printed in full on the settings page,
use it for — and not which of the two editions is running, so the count does not
tell the two apart either. The payload is small enough to be printed in full on the settings page,
and it is printed there — a promise about telemetry is worth exactly as much as
your ability to check it.
+1
View File
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
## Behind a proxy
+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.
+32
View File
@@ -12,3 +12,35 @@ const (
// 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"
}
+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) {
+145
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
@@ -36,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
// 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",
@@ -67,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
"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
}
@@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
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)
@@ -228,3 +294,82 @@ func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
}
}
}
// 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
}
+1 -1
View File
@@ -358,7 +358,7 @@ func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
return
}
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
for _, t := range tasks {
if t.DueDate == "" {
continue
+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
+198 -43
View File
@@ -3,6 +3,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
@@ -77,6 +78,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 +101,7 @@ type Server struct {
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
s := &Server{
cfg: cfg, db: db, log: log, web: web,
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
// Generous on purpose: the caller is the mail server, one address delivering
@@ -101,8 +116,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
// Before the CSP, deliberately: the policy has to describe the bytes that go
// out, and in the notes edition those are the renamed ones.
//
// A failure here is logged rather than fatal, and that is a judgement about
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
// refuses to start, because serving task routes on an instance set up without
// them is a different kind of wrong. A shell whose title says the sibling's
// name is cosmetic — it must be loud, and it must not take the instance down.
// What makes it loud is not this line: the end-to-end suite reads the served
// title, so an anchor that falls out of app.html goes red there.
if web != nil && cfg.NotesOnly() {
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
if err != nil {
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
"edition", cfg.Edition, "err", err)
} else {
s.shell, s.manifest = shell, manifest
}
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 +185,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 +234,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,7 +267,10 @@ 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) {
r.Use(s.requireCSRF)
@@ -312,7 +398,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
}
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)
@@ -338,7 +429,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)
@@ -359,20 +452,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å.
@@ -457,24 +554,38 @@ 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.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)
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)
})
}
if full {
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
@@ -505,7 +616,13 @@ 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.
@@ -514,8 +631,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
if full {
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
}
})
r.Group(func(r chi.Router) {
@@ -590,10 +709,12 @@ 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)
})
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) {
@@ -685,7 +806,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
}
@@ -694,6 +815,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.
@@ -706,6 +843,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"})
+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)
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
t.Cleanup(func() { db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
}
func seedUser(t *testing.T, db *store.DB) *store.User {
+19 -13
View File
@@ -24,13 +24,19 @@ import (
type Sender struct {
cfg config.SMTP
log *slog.Logger
// appName and baseURL appear in the body of every message.
// appName and baseURL appear in every message. appName is the edition's own
// name — an invite from an urd instance that said "verdande" would be a letter
// to somebody else's inbox about a program they are not being invited to.
//
// It was a field with a value and no reader until 2026-10-07: the three places
// that needed it had the word written out instead, and the comment above said
// it appeared in every message. It does now.
appName string
baseURL string
}
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
}
// Configured reports whether mail can actually be sent. Handlers use it to decide
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
what := "verdande"
what := s.appName
if projectName != "" {
what = "projektet “" + projectName + "”"
}
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
@@ -71,9 +77,9 @@ Opret din konto her:
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
— verdande
— %s
%s
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
%s
— verdande
`, name, task, link)
— %s
`, name, task, link, s.appName)
return s.send(ctx, to, subject, body)
}
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
subject := "Nulstil din adgangskode i verdande"
subject := "Nulstil din adgangskode i " + s.appName
body := fmt.Sprintf(`Hej %s
Der er bedt om at nulstille adgangskoden til din verdande-konto.
Der er bedt om at nulstille adgangskoden til din %s-konto.
Vælg en ny adgangskode her:
%s
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
— verdande
— %s
%s
`, name, link, humanDuration(ttl), s.baseURL)
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
+76
View File
@@ -0,0 +1,76 @@
package mail
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// TestEveryMessageCarriesTheEditionsOwnName.
//
// These three letters are the only thing this program sends to an address its
// operator does not control, and an invitation from an urd instance that says
// "verdande" is a letter to a stranger about a program they are not being invited
// to. The name was a field with a value and no reader until 2026-10-07 — the three
// places that needed it had the word written out instead — so the assertion that
// matters is not "it says urd" but "it does not say the other one".
//
// Measured through the unconfigured path on purpose: with no SMTP host, send()
// logs the whole message at warn level and returns, so the subject and the body
// can be read as the recipient would get them without a mail server in the test.
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
for _, tc := range []struct{ name, other string }{
{"verdande", "urd"},
{"urd", "verdande"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", tc.name,
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
ctx := context.Background()
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
t.Fatalf("SendPasswordReset: %v", err)
}
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
t.Fatalf("SendReminder: %v", err)
}
got := buf.String()
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
}
if !strings.Contains(got, tc.name) {
t.Errorf("no message mentions %q", tc.name)
}
if strings.Contains(got, tc.other) {
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
}
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
})
}
}
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
// *subject* of the sentence rather than a signature: an invitation to the instance
// itself, with no project, reads "has invited you to <program>".
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", "urd",
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if !strings.Contains(buf.String(), "inviteret dig til urd") {
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
}
}
+1
View File
@@ -44,6 +44,7 @@ extra_css:
nav:
- Home: index.md
- Setting it up:
- Two editions: editions.md
- As a Rune: rune.md
- With Docker: self-hosting.md
- Configuration: configuration.md
+152
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { watchForTrouble } from './trouble.js';
/**
* Noter-udgaven, kørt som sit eget program.
@@ -118,3 +119,154 @@ test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
// 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);
}
});
+224 -34
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
import { watchForTrouble } from './trouble.js';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
@@ -11,14 +12,6 @@ import { USER } from './user.js';
* precisely the bug this suite exists to catch.
*/
/**
* 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.
*/
/**
* Opens the project header's overflow menu and clicks one of its items.
*
@@ -93,32 +86,6 @@ async function nyNote(side) {
return felt;
}
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;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
@@ -5135,6 +5102,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en <div> og "linje to" og "linje tre" var <p>.
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
@@ -5509,3 +5555,147 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
/**
* The control for the notes edition's renaming, and it belongs here rather than
* beside it: the whole point is that the full edition does NOT go through that
* path, and that can only be measured on an instance which is not the notes one.
*
* A gate asserted in one direction is half a gate. edition.spec.js proves the
* notes edition calls itself urd; without this, a rename that fired in both
* editions would pass there and change the name of this program.
*/
test('the full edition still calls itself verdande', async ({ page }) => {
await page.goto('/');
// Both titles, for the same reason edition.spec.js checks both: the shell's,
// which the browser reads before any script runs, and the route's own, set in
// <svelte:head> once the page draws. Today has no title of its own, so the
// shell's is what stands here — and /noter has one, so it is asked separately.
const shell = await (await page.request.get('/')).text();
expect(shell).toContain('<title>verdande</title>');
await expect(page).toHaveTitle('verdande');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ð.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
await expect(page).toHaveTitle(/· verdande$/);
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
// The other half of the icon alias: this edition must still get Wunjo. Both
// sets of files are in the same binary, so a swap that fired in both editions
// would pass in edition.spec.js and quietly change this program's mark.
const icon = await (await page.request.get('/icon.svg')).text();
expect(icon).toContain('M26 15l17');
expect(icon).not.toContain('M44 36L27 10');
});
/**
* The second door.
*
* One instance, one database, one login — and two things that can be installed.
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
* verdande, not a property of an instance, so if it only worked where
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
*
* Measured on the bytes the server sends. An operating system installs a manifest,
* not a URL, so these assertions are about the manifest's `id` and the shell's
* <head> — the two things that decide whether /urd becomes a second icon on a home
* screen or a bookmark into the first app.
*/
test('urd is a second installable app on the same instance', async ({ page }) => {
const shell = await (await page.request.get('/urd')).text();
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
expect(shell, 'which manifest the browser fetches').toContain(
'<link rel="manifest" href="/urd.webmanifest" />'
);
// iOS looks for this one BY NAME and has historically ignored the manifest's
// icons, which is why the shell is served per door rather than patched by
// script after the page loads.
expect(shell, 'what iOS puts on a home screen').toContain(
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
);
// And the root is untouched: two doors, not a rename.
const root = await (await page.request.get('/')).text();
expect(root).toContain('<title>verdande</title>');
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
const res = await page.request.get('/urd.webmanifest');
// A manifest served as text/html is ignored with no error anywhere; the only
// symptom is an install prompt that never appears.
expect(res.headers()['content-type']).toContain('application/manifest+json');
const urd = await res.json();
// `id` is the install identity. Without it, two manifests on one origin are one
// app with two start urls.
expect(urd.id).toBe('/urd');
expect(urd.start_url).toBe('/urd');
// Not "/urd": a scope that did not cover the origin would open the app's own
// links in a browser tab the first time somebody navigated out of /urd.
expect(urd.scope).toBe('/');
expect(urd.name).toBe('urd');
for (const icon of urd.icons) {
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
}
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
expect(verdande.name).toBe('verdande');
expect(verdande.id ?? '/').not.toBe('/urd');
// The shared fields come from one source, so a colour changed in the build
// reaches both doors.
expect(urd.theme_color).toBe(verdande.theme_color);
expect(urd.background_color).toBe(verdande.background_color);
});
test('entering through the urd door lands on the notes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/urd');
// Asserted on what is drawn rather than on the URL: the Go binary serves the
// shell for anything it does not recognise, so a route that no longer exists
// still answers 200 here.
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
// replaceState, so the phone's back gesture does not return to a page whose
// only job is to redirect.
await expect(page).toHaveURL(/\/noter$/);
// And the window now wears urd's face: its mark, its name, its tab. This is
// chrome only — the task routes are all still there and still answer, which is
// the whole reason this is one instance.
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('urd');
await expect(brand.locator('.rune')).toHaveText('ð');
await expect(page).toHaveTitle(/· urd$/);
// Nothing is withheld: the same window can still read a task route. A face that
// hid data would be the separate instance wearing a costume.
const tasks = await page.request.get('/api/v1/tasks');
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
// And the face survives a reload — sessionStorage, because the two doors share
// an origin. Nobody passes through /urd again after the first navigation.
await page.reload();
await expect(brand.locator('.name')).toHaveText('urd');
expect(trouble).toEqual([]);
});
test('the verdande door is unaffected by the urd one', async ({ page }) => {
// The control, and it is the half that stops the face from being a rename: a
// window that never went through /urd must be verdande, in the same browser,
// on the same origin, with the other app's sessionStorage sitting beside it.
const trouble = watchForTrouble(page);
await page.goto('/noter');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
await expect(brand.locator('.rune')).toHaveText('\u16B9');
await expect(page).toHaveTitle(/· verdande$/);
expect(trouble).toEqual([]);
});
+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;
}
+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();
+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. -->
+1 -1
View File
@@ -38,7 +38,7 @@
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
+49 -17
View File
@@ -821,23 +821,54 @@
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
function keepTitleFirst(split) {
if (!editor) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
// ligesom den tager et ord tilbage.
const caret = currentBlock();
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
// nogen har valgt Titel til i menuen, og den er deres at beholde.
const lavet = split?.nextElementSibling;
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
// Og hver <div>, rettelsen ikke nåede.
//
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
// afstand, hos netop de brugere der skriver hurtigst.
//
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
const her = currentBlock();
for (const block of [...editor.children])
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
}
/**
* En blok, der ikke må stå, som den står.
*
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
* hver linje er en titel, er en note uden titel.
*/
function stray(block) {
if (block.parentNode !== editor) return false;
return (
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
);
}
/**
@@ -912,9 +943,10 @@
changed();
return;
}
// After the split, not before it.
// After the split, not before it — and with the block the split started from,
// because by the time this runs the caret may be two blocks further on.
setTimeout(() => {
keepTitleFirst();
keepTitleFirst(block);
changed();
}, 0);
return;
+14 -5
View File
@@ -73,6 +73,11 @@
// through the connector.
$effect(() => {
app.labelsChanged;
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
// drift i to merges.
if (app.notesOnly) return;
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
@@ -475,11 +480,15 @@
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
starts with in the elder futhark. One glyph, no wordmark beside it —
the name is in the tab and everywhere else already. -->
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="name">verdande</span>
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
was meant to be: uruz is already another of this author's apps and
carries that glyph. See web/static/urd-icon.svg.
Both come from the store rather than from here, because the two editions
of this program are two products with two names, and a wordmark is the
one place a reader decides which one they are looking at. -->
<span class="rune" aria-hidden="true">{app.productMark}</span>
<span class="name">{app.productSlug}</span>
</div>
<div class="views">
+3 -3
View File
@@ -145,7 +145,7 @@ export const da = {
// læserens mulighed for at efterprøve det.
'beacon.noticeTitle': 'Denne installation melder, at den findes',
'beacon.noticeBody':
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'beacon.noticeTo': 'Sendes til {url}',
'beacon.noticeKeep': 'Behold den',
'beacon.noticeStop': 'Slå den fra',
@@ -238,7 +238,7 @@ export const da = {
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'net.downTitle': 'Ingen forbindelse',
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
@@ -353,7 +353,7 @@ export const da = {
'size.default': 'Standard',
'size.large': 'Stor',
'size.xl': 'Størst',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
+3 -3
View File
@@ -142,7 +142,7 @@ export const en = {
// the reader's ability to check it.
'beacon.noticeTitle': 'This installation reports that it exists',
'beacon.noticeBody':
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'beacon.noticeTo': 'Sent to {url}',
'beacon.noticeKeep': 'Keep it on',
'beacon.noticeStop': 'Turn it off',
@@ -235,7 +235,7 @@ export const en = {
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'net.downTitle': 'No connection',
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
@@ -350,7 +350,7 @@ export const en = {
'size.default': 'Default',
'size.large': 'Large',
'size.xl': 'Largest',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
+106
View File
@@ -64,6 +64,88 @@ class AppState {
return this.edition === 'notes';
}
/**
* Hvilken dør dette vindue kom ind ad.
*
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
* filen her. De to ligner hinanden og betyder ikke det samme:
*
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
* dem, for de svarer 404.
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
* dataene er deres egne, og der er intet at holde tilbage.
*
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
* database og personen bare valgte en anden indgang.
*
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
* PWA er sit eget vindue.
*/
urdFace = $state(false);
enterUrd() {
this.urdFace = true;
try {
sessionStorage.setItem('verdande:door', 'urd');
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
// mister det.
}
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
*
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
* noget JavaScript kører, så serveren skriver dem om på vejen ud
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
/**
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
* fordi personen kom ind ad urd-døren.
*
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
* om der overhovedet er en rute at hente fra.
*/
get showsUrd() {
return this.notesOnly || this.urdFace;
}
get productName() {
return this.showsUrd ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.showsUrd ? 'urd' : 'verdande';
}
/**
* Mærket: ᚹ wunjo for Verdande, ð for Urd.
*
* Ikke to runer, selvom det var meningen. Urds oplagte mærke var ᚢ uruz,
* bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er
* wunjo — og det blev tegnet sådan først. Men uruz er allerede et af husets
* egne produkter, og dets `gen-icons.ts` tegner samme glyf i guld. 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å.
*
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
*/
get productMark() {
return this.showsUrd ? 'ð' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -130,6 +212,15 @@ class AppState {
async load() {
this.loading = true;
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
try {
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
} catch (e) {
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
}
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
@@ -314,6 +405,21 @@ class AppState {
* `completed: 'exclude'`.
*/
async loadTasks(params) {
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
// den fjerde, og det var præcis sådan den her slap igennem.
if (this.notesOnly) {
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
// looper: projektsiden har en effekt, der læser app.tasks og kalder
// herind, så et skriv ved hvert kald er en uendelig runde —
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
// fejl.
if (this.tasks.length) this.tasks = [];
return this.tasks;
}
const { tasks } = await api.listTasks({
completed: completedView.shown ? 'include' : undefined,
...params
+1 -1
View File
@@ -75,7 +75,7 @@
}
</script>
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
<div class="view">
<header>
+1 -1
View File
@@ -126,7 +126,7 @@
}
</script>
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
<div class="view">
{#if status === 'denied'}
+4 -1
View File
@@ -379,7 +379,10 @@
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<!-- The product name is passed to every look, and only one of them has a
place for it: the look named after the program. The other three
carry no placeholder, so the parameter is ignored. -->
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
@@ -141,6 +141,13 @@
let startDate = $state('');
$effect(() => {
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
// fejlbesked på indstillingssiden frem for en tom liste.
if (app.notesOnly) {
loadingTemplates = false;
return;
}
api
.listTemplates()
.then((r) => (templates = r.templates))
@@ -296,7 +296,11 @@
let mailConfigured = $state(null);
$effect(() => {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
// Feedet er en kalender af opgaver, så adressen findes ikke i
// noter-udgaven.
if (!app.notesOnly) {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
}
api
.getMailAddress()
.then((r) => {
@@ -92,6 +92,8 @@
let planBusy = $state(false);
$effect(() => {
// Dagens plan er dagens opgaver.
if (app.notesOnly) return;
api
.planSettings()
.then((p) => (plan = p))
+5 -1
View File
@@ -12,8 +12,12 @@
* and it reads the token out of the URL itself.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
<SignIn />
+1 -1
View File
@@ -933,7 +933,7 @@
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
<svelte:window
onclick={onShareOutside}
+6 -1
View File
@@ -88,9 +88,14 @@
if (!projectId) return;
status = 'loading';
try {
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
// — og det er ikke bare støj: Promise.all kaster på den første, der
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
// butikken, fordi den har tre kaldesteder her.
const [p, s] = await Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
app.loadTasks({ project_id: projectId })
]);
project = p;
+5 -1
View File
@@ -8,8 +8,12 @@
* fill in. Same shape as the invite link, and found the same way.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
<SignIn />
+33
View File
@@ -0,0 +1,33 @@
<script>
/**
* Urds dør.
*
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
* igen.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
onMount(() => {
app.enterUrd();
goto('/noter', { replaceState: true });
});
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
være et glimt af noget, ingen nåede at læse. -->
<svelte:head><title>urd</title></svelte:head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Urd's mark: ð, the eth in Urðr. Drawn in Verdande's strokes so the two read
as one family — same width, same round caps, same plate.
NOT a rune, and that is the whole story of this file. The obvious mark was
ᚢ uruz, the letter the name begins with in the elder futhark, exactly as
Verdande's is ᚹ wunjo. It was drawn that way first. Then: uruz is already
one of this author's products, and uruz/scripts/gen-icons.ts says in its own
comment that it "draws the Uruz rune (ᚢ) in rune-gold" with "a tall left
vertical, a shoulder across the top, and a shorter" one — the same glyph,
the same construction, differing only in colour. Two of one person's apps
side by side on a home screen, which is precisely where this mark is meant
to be distinguishable.
ᚦ thurisaz was considered next, for the ð itself, and rejected for the same
reason one level down: it is a stave with a triangle on its right, and so is
ᚹ. At 16px they are one shape.
So the letter rather than the rune. ð is unmistakably Nordic, nothing else
here uses it, and it is a closed bowl where both neighbours are open staves.
Centred by counting painted pixels — white on black, headless Chromium, the
plate removed so the card is not what gets measured — rather than from the
path numbers, because a centred box is not a centred glyph:
verdande ᚹ ink x 53–108 centre 80.5 · y 18–131 centre 74.5
urd ð ink x 39–110 centre 74.5 · y 15–134 centre 74.5
Verdande's +5.5 in x is its own deliberate nudge: a heavy stave beside a
light flag has to sit right of centre for the ink to look centred. ð is
symmetric enough not to need it, and the two agree exactly in y.
Legibility, measured rather than hoped: it reads as ð at 32px and above. At
16px it is a blob — and so is ᚹ, and a favicon is not what tells two
installed apps apart in a dock. -->
<rect width="64" height="64" rx="14" fill="#111312"/>
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="42" r="12"/>
<path d="M44 36L27 10"/>
<path d="M25 20h18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+31
View File
@@ -14,6 +14,37 @@ export default {
}),
alias: {
$lib: 'src/lib'
},
/**
* The build's own version string, which SvelteKit puts in
* `_app/version.json` and the service worker reads to decide whether a new
* version has been deployed.
*
* Its default is `Date.now()`, and that made every build of this frontend
* produce different bytes: the millisecond lands in version.json, in
* index.html and in service-worker.js, which changes the content hash of
* every chunk that references them, which changes those chunks' filenames.
* So two builds of the same commit had different file names in them.
*
* That is not an aesthetic problem, and it had two costs. The Dockerfile
* said `-trimpath` and an empty buildid "keep the output reproducible", and
* that sentence had been false for a year — the flags did their job and the
* frontend embedded in the binary carried a millisecond. And the Dockerfile
* builds one binary into two images, verdande and urd, where the claim is
* that they are the same program rather than a fork; with a timestamp in the
* build, that claim could not be checked, because no two builds ever
* matched. CI checks it now.
*
* Measured by building twice and diffing the tree, before and after.
*
* `dev` when nothing sets it, so a local build is stable too. The cost is
* that a local `npm run build` no longer looks like a new version to the
* service worker — which only matters on a deployed instance, where the
* release workflow passes the real tag.
*/
version: {
name: process.env.VERDANDE_VERSION ?? 'dev'
}
}
};