Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:15 +02:00
Kristian Wind 8bf4f8fbe4 Merge pull request #87 from kristianwind/et-udfald-siger-det
feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
2026-09-17 12:38:19 +02:00
kristianwindandClaude Opus 5 84af63316d feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står
`user` som null — og null betyder "logget ud" alle andre steder i
programmet. Så et totalt udfald blev vist som login-skærmen: appen gav
sessionen skylden for, at nettet var nede, og tilbød et kodeordsfelt, der
umuligt kunne virke.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

To ting på én skærm.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tre ting, der kostede en kørsel hver:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:27 +02:00
Kristian Wind 3c14906db2 Merge pull request #72 from kristianwind/dokumenter-det-der-blev-bygget
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m44s
CI / Docker image builds (push) Successful in 1m50s
CI / Go (push) Failing after 14m28s
docs: skriv det ned, der er blevet bygget
2026-09-09 09:02:58 +02:00
Kristian Wind 1cabbb7a78 Merge pull request #71 from kristianwind/hvilken-browser-aabner-linket
feat(links): vælg hvilken browser et link åbner i
2026-09-09 08:50:30 +02:00
45 changed files with 3564 additions and 189 deletions
+18 -10
View File
@@ -1,13 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# And on pull requests, where the build is the only check there is: `mkdocs build
# --strict` fails on a link to a page that does not exist, and building only after
# the merge means finding that out on `main`. Nothing is published from a pull
# request — the `deploy` job below runs on a manual dispatch and nothing else.
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -54,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+34 -3
View File
@@ -72,13 +72,44 @@ jobs:
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
+17 -1
View File
@@ -20,7 +20,8 @@ Which is what a to-do list is.
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
+15 -5
View File
@@ -77,11 +77,21 @@ never edited — the task carries `[[the note's title]]` in its description inst
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
default; pick the hour yourself. One notification about what is due today and what
has been missed, and what to start with. The counts are done in the database and
handed to the model as facts it may not recompute — a plan that says four tasks
where there are three is worse than no plan. With no model configured the counts
stand on their own, which is what you needed to know at seven in the morning anyway.
Nothing due and nothing overdue sends nothing.
has been missed, and what to start with — and the same text as a card at the top of
**I dag**, where you are looking anyway when you start the day. The notification is
the reminder; the card is where it stays.
The card shows the plan *as it was sent*, with the time it was made, rather than a
new one each time you look: a plan that says something different when you look at it
than it said when it arrived is worse than either. **Forny planen** makes a new one
when the day has moved, and it does not send a notification about something you are
already looking at. With the morning notification off, the card offers to make one
on the spot.
The counts are done in the database and handed to the model as facts it may not
recompute — a plan that says four tasks where there are three is worse than no plan.
With no model configured the counts stand on their own, which is what you needed to
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
**Mail that becomes a task you can act on.** Where a
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
+4 -3
View File
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
+9 -2
View File
@@ -26,6 +26,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
board and calendar views, and foldable groups over the projects in the sidebar.
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
settings themselves, by what they *do* rather than only by what they are called,
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
settings pages carry the same field at the top of their own list.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person — by name, by email address, or by writing `@Sofie` in
the text — or file it in a project; `#project` and `[[another note]]` are links,
@@ -36,8 +40,11 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
it is due.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
+18
View File
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
it is a document that can run script, and a note is not a place to run somebody
else's.
**Click one to see it whole.** In the note it is scaled down to the width of the
column — a photo off a phone is four thousand pixels across — so what you are
looking at is a thumbnail that does not look like one. The full-size view has a
button that copies the picture to the clipboard, and a link that opens it in its
own tab. Escape closes it and puts the cursor back where it was.
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
little else, and a photo off a phone is a JPEG — without the conversion the copy
either fails or succeeds with something nothing will paste.
### Code blocks
A fenced block is coloured for the language it names, and guesses when it does not.
@@ -128,6 +138,14 @@ Search finds a word wherever it is:
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
+2 -1
View File
@@ -11,10 +11,11 @@ the app's icon.
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose |
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
| A new version is out | Administrators only, once per version |
Not your own actions: the server does not tell anybody what they just did
+105 -8
View File
@@ -2307,7 +2307,15 @@ paths:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Who the project is shared with.
summary: Who the project is shared with, who can be added, and who has been invited.
description: >
Three lists in one answer, so the share panel opens complete in a single
round-trip. `candidates` is everybody with an account here who is not
already on the project — the instance's accounts are a closed, invited set,
so listing them to each other is the address book the feature needs, not a
disclosure. `pending` is the invitations sent to people who have no account
yet: without them an invitation sent yesterday is in neither of the other
two lists, and gets sent a second time.
responses:
"200":
description: OK
@@ -2319,6 +2327,17 @@ paths:
members:
type: array
items: { $ref: "#/components/schemas/Member" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
pending:
type: array
items:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [editor, viewer] }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members/{userID}:
@@ -2360,8 +2379,19 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
summary: Remove somebody from a project — its owner, or yourself.
description: |
Two things through one route: the owner taking somebody off, and anybody
taking themselves off. The second is what makes a share something you can
undo. You are let into a project without being asked, so a way out that
does not go through asking its owner is the other half of that.
The owner cannot leave their own project. That is a transfer of ownership,
not an exit, and there is no transfer yet.
Pointing at somebody else's membership without owning the project answers
404 rather than 403: saying "you may not" confirms the membership exists.
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
@@ -2384,13 +2414,32 @@ paths:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
user_id:
type: string
description: >
Somebody who already has an account here, picked from the
candidates in GET /members. One of user_id or email.
email:
type: string
format: email
description: >
An address. If it belongs to an account they are added at
once; otherwise an invitation is sent.
role: { type: string, enum: [editor, viewer] }
responses:
"200":
description: Added straight away — the person already had an account.
content:
application/json:
schema:
type: object
properties:
added: { type: boolean }
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [editor, viewer] }
"201":
description: Created
description: Invited. No account yet, so a link was made.
content:
application/json:
schema:
@@ -2399,8 +2448,30 @@ paths:
link: { type: string }
emailed: { type: boolean }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/invites/{inviteID}:
delete:
tags: [projects]
summary: Withdraw an invitation to a project before it is used. Its owner only.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: projectID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/activity:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
@@ -3688,7 +3759,21 @@ paths:
/api/v1/ai/plan/now:
post:
tags: [integrations]
summary: Send today's plan now, to whoever asks.
summary: Make today's plan now, to whoever asks.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
silent:
type: boolean
description: >
Make and store it without sending a notification — the card on
Today asking for a plan while somebody is looking at it. A
notification about something in front of you is the kind that
teaches people to ignore the bell.
description: |
The counts come from the database and the sentence from the model, in that
order: a model that counted for itself would sometimes count wrong, and a
@@ -4545,8 +4630,9 @@ components:
DailyPlanSettings:
type: object
description: >
One person's morning plan. Off by default — a notification at seven in the
morning that nobody asked for is not a help.
One person's morning plan, and today's plan itself. One resource, because
it is one thing: the plan is the resource and the setting is when it gets
made.
properties:
enabled: { type: boolean }
hour:
@@ -4555,6 +4641,17 @@ components:
last_sent:
type: string
description: The date it last went out, in that person's own timezone.
plan:
type: string
description: >
Today's plan as it was sent — kept rather than recomputed on every
look. A plan that says something different when you look at it than it
said when it arrived is worse than either. Absent when the stored plan
is from another day.
plan_date: { type: string }
plan_at:
type: integer
description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.
AISuggestion:
type: object
+32
View File
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
+89 -7
View File
@@ -39,6 +39,19 @@ type planSettings struct {
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
LastSent string `json:"last_sent,omitempty"`
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
//
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
// andet end den besked, man fik i morges, er værre end begge dele hver for
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
// gæt hver gang man kigger.
//
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
Plan string `json:"plan,omitempty"`
PlanDate string `json:"plan_date,omitempty"`
PlanAt int64 `json:"plan_at,omitempty"`
}
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
@@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings,
if v, ok := stored["last_sent"].(string); ok {
out.LastSent = v
}
if v, ok := stored["plan"].(string); ok {
out.Plan = v
}
if v, ok := stored["plan_date"].(string); ok {
out.PlanDate = v
}
if v, ok := stored["plan_at"].(float64); ok {
out.PlanAt = int64(v)
}
return out, nil
}
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
})
}
// handleGetPlanSettings er både indstillingen og dagens plan.
//
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
// to steder at huske at rydde en plan fra i går.
//
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
// et tomt kort.
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
}
writeJSON(w, http.StatusOK, p)
}
@@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, p)
}
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
type planNowRequest struct {
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
//
// En besked om noget, man står og ser på, er en besked, der lærer folk at
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
// hele dens formål: at vise, at push virker på det her apparat.
Silent bool `json:"silent"`
}
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
//
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
// af, er en indstilling, ingen tør slå til.
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
// lave en plan for den, der har beskeden slået fra.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
var req planNowRequest
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &req); err != nil {
return
}
}
user := userFrom(r.Context())
plan, err := s.buildPlan(r.Context(), user)
if err != nil {
s.internal(w, r, "build plan", err)
@@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
return
}
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
s.internal(w, r, "save plan", err)
return
}
sent := false
if !req.Silent {
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
sent = true
}
writeJSON(w, http.StatusOK, map[string]any{
"plan": plan, "sent": sent,
"plan_at": time.Now().Unix(),
})
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
}
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
p, err := s.planSettings(ctx, user.ID)
if err != nil {
return err
}
now := time.Now()
p.Plan = plan
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
p.PlanAt = now.Unix()
return s.savePlanSettings(ctx, user.ID, p)
}
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
@@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error {
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
// ene overskrive den andens `last_sent`.
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
}
p.LastSent = today
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
+61
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"testing"
"time"
)
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
@@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
}
}
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
}
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("lav planen: %d", resp.StatusCode)
}
if made["sent"] != false {
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
}
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if after["plan"] != made["plan"] {
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
}
if after["plan_at"] == nil {
t.Error("planen bærer ikke, hvornår den blev lavet")
}
}
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
// overskriften "i dag", er værre end et tomt kort.
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
t.Fatal("lav planen")
}
// Sat en dag tilbage, som om den var lavet i går.
user := userID(t, ts, "kristian@example.dk")
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
if err != nil {
t.Fatal(err)
}
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
t.Fatal(err)
}
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if plan, _ := out["plan"].(string); plan != "" {
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
}
}
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
})
}
// notifyProjectShared tells somebody that a project is now theirs to work in.
//
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
// opgaver til dig i det.
//
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
// handling — de skal ikke have besked om det, de lige selv gjorde.
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
if project == nil || userID == "" {
return
}
actor := userFrom(r.Context())
if actor.ID == userID {
return
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
+152 -14
View File
@@ -452,22 +452,100 @@ type memberJSON struct {
Role string `json:"role"`
}
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
// dukker op.
type projectInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleListMembers is everything the share panel needs, in one answer: who is on
// the project, who can be added, and who has been invited and not arrived.
//
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
//
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
already := map[string]bool{}
for _, m := range members {
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
already[m.UserID] = true
}
writeJSON(w, http.StatusOK, map[string]any{"members": out})
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, r, "get project", err)
return
}
already[project.OwnerID] = true
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
candidates := make([]personJSON, 0, len(people))
for _, p := range people {
if already[p.ID] {
continue
}
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list project invites", err)
return
}
pending := make([]projectInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"candidates": candidates,
"pending": pending,
})
}
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
//
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
// udløber, med et link, der virker hele tiden.
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
if err != nil {
s.storeError(w, r, "delete project invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type inviteRequest struct {
Email string `json:"email"`
Role string `json:"role"`
// UserID er den, man har peget på i listen. Email er den, man kender adressen
// på — og den er også den, der virker, når der ingen konto er endnu.
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -478,8 +556,14 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// handleInvite shares a project with somebody. An existing user is added directly;
// anybody else gets a link that creates their account and their membership at once.
// handleInvite shares a project with somebody.
//
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
// kontoen og medlemskabet på én gang.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// Somebody who already has an account is added straight away: sending them
// through a signup form they cannot complete would be a dead end.
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
if existing.ID == project.OwnerID {
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
if req.UserID == "" {
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
req.UserID = existing.ID
}
}
if req.UserID != "" {
if req.UserID == project.OwnerID {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
// tom plads, ingen kan fjerne.
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
// de kiggede — uden at noget sagde hvornår eller fra hvem.
s.notifyProjectShared(r, project, person.ID)
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
// Personen sendes med retur, fordi fladen kan have delt med en adresse
// og derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
return
}
// Et andet link til den samme indbakke hjælper ingen: det første virker
// stadig, og nu er der to at holde styr på og to at trække tilbage.
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
return
}
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
// anybody removing themselves.
//
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
//
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
// overdragelse, og den findes ikke endnu.
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
// persons medlemskab, bekræfter at medlemskabet findes.
if me.ID != owner && userID != me.ID {
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner cannot be removed from their own project")
+236
View File
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
}
}
}
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
// beskytte ved at lade være med at vise dem.
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
// Først står de i listen over dem, der kan tilføjes.
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if !hasPerson(before["candidates"], anden.ID) {
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
}
// Delt ved at pege, ikke ved at stave.
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": anden.ID, "role": "editor"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, added)
}
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
t.Errorf("user = %v, want Anden", added["user"])
}
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if hasPerson(after["candidates"], anden.ID) {
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
}
found := false
for _, raw := range after["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == anden.ID {
found = true
if m["role"] != "editor" {
t.Errorf("role = %v, want editor", m["role"])
}
}
}
if !found {
t.Errorf("Anden blev ikke medlem: %v", after["members"])
}
}
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if hasPerson(body["candidates"], me.ID) {
t.Errorf("ejeren står som kandidat til sit eget projekt")
}
}
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
//
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
// godt som det første, og så er der to at trække tilbage i stedet for et.
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invitation: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
pending, _ := body["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
}
first := pending[0].(map[string]any)
if first["email"] != "fremmed@example.dk" {
t.Errorf("email = %v", first["email"])
}
// Et andet link til den samme indbakke hjælper ingen.
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE",
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if p, _ := after["pending"].([]any); len(p) != 0 {
t.Errorf("invitationen står der stadig: %v", after["pending"])
}
}
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
// hasPerson siger, om en liste af personer indeholder et bestemt id.
func hasPerson(raw any, id string) bool {
list, _ := raw.([]any)
for _, item := range list {
if p, ok := item.(map[string]any); ok && p["id"] == id {
return true
}
}
return false
}
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anden := ts.newUser(t, "anden@example.dk", "Anden")
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": andenUser.ID, "role": "editor"})
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
var shared map[string]any
for _, raw := range inbox["notifications"].([]any) {
if n := raw.(map[string]any); n["kind"] == "project.shared" {
shared = n
}
}
if shared == nil {
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
}
if shared["project_id"] != projectID {
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
shared["project_id"], projectID)
}
// Udgangen.
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
}
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
}
}
// En udgang for en selv er ikke en dør til de andres medlemskaber.
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
en := ts.newUser(t, "en@example.dk", "En")
ts.newUser(t, "to@example.dk", "To")
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
if err != nil {
t.Fatal(err)
}
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
for _, u := range []string{enUser.ID, toUser.ID} {
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": u, "role": "editor"})
}
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if len(members["members"].([]any)) != 3 {
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
}
}
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
resp, _ := ts.do(t, "DELETE",
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("status %d, want 409", resp.StatusCode)
}
}
+4 -1
View File
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
+83
View File
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
return nil
}
// --- invitationer til ét projekt --------------------------------------------------
// ProjectInvite is somebody invited to a project who has not arrived yet.
//
// The same shape as NoteInvite and deliberately not the same type: the two rows
// differ in which column carries the target, and one struct covering both would
// need a field that is empty half the time — which is how a caller ends up
// reading the wrong one.
type ProjectInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListProjectInvites is who has been invited to a project and not yet taken it up.
//
// The panel needs them beside the members for the same reason the note panel does:
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
// second link to the same inbox, and no way to take either back. That mattered
// less while inviting meant typing an address; it matters more now that the panel
// offers a list to pick from, because the people who are *missing* from that list
// are exactly the ones already invited.
//
// Expired ones are left out, the same rule the administrator's list follows.
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, projectID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProjectInvite{}
for rows.Next() {
var i ProjectInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
// withdraw anything else.
//
// The project id is in the WHERE rather than checked beforehand, for the same
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
// established for *this* project, and a statement that can only touch this
// project's rows cannot be talked into deleting somebody else's invite by id.
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
inviteID, projectID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingProjectInvite reports whether this address already has a live invitation
// to this project, so a second attempt can say so instead of sending another link.
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
AND accepted_at IS NULL AND expires_at > ?`,
projectID, email, time.Now().Unix()).Scan(&n)
return n > 0, err
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
+67 -11
View File
@@ -819,6 +819,15 @@
color: var(--accent);
}
</style>
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
står under "It counts itself". Plausible på vores egen maskine: ingen
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
<script
defer
data-domain="verdandetodo.com"
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
></script>
</head>
<body>
@@ -1326,6 +1335,64 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.47</span>
Click a picture in a note and it opens at full size, with a button that
copies it to the clipboard. In the note it is scaled to the width of the
column — a photo off a phone is four thousand pixels across — so what you
were looking at was a thumbnail that did not look like one.
</li>
<li>
<span class="ver">0.47</span>
Search in ⌘K, pick a note, and the rest of the matches stay in the list
beside it. They used to exist only in the popup you had just closed.
</li>
<li>
<span class="ver">0.46</span>
Share a project with somebody who already has an account by picking their
name, rather than by spelling an email address you would have to know.
Invitations that have not been taken up stay visible so they do not get
sent twice, and can be withdrawn.
</li>
<li>
<span class="ver">0.46</span>
You are told when a project is shared with you, and there is a
<strong>Leave the project</strong> button. Being let in without being
asked, without being told, and with no way out was three things at once.
</li>
<li>
<span class="ver">0.45</span>
Search for a setting instead of remembering which tab it was under — in
the settings themselves and in ⌘K. It knows them by what they do, so
<em>beacon</em> finds the install count even though that is not its name.
</li>
<li>
<span class="ver">0.45</span>
Today's plan is a card on <strong>I dag</strong> rather than only a
message in the morning that was gone if you did not read it at once. And
a link can be told which browser to open in.
</li>
<li>
<span class="ver">0.44</span>
An optional AI layer, on your own key: it tidies the inbox, pulls the
tasks out of a meeting note, writes a morning plan, turns
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
for, and answers questions about your own notes with links to what the
answer rests on. Every one of them is a line you accept, edit or refuse —
it suggests, it never writes.
</li>
<li>
<span class="ver">0.44</span>
Administrators are told in the bell when a new version is out, once per
version, and the message leads to the button that restarts.
</li>
<li>
<span class="ver">0.43</span>
Anything that can call a URL can put a task in your inbox — a shortcut on
your phone, a script, a service. Notes can be handed to somebody who has
no account yet, and writing <code>@Sofie</code> in a note shares it with
her and tells her. Unread counts show on the app's icon in the Dock.
</li>
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
@@ -1362,17 +1429,6 @@
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
</ul>
</section>
+89
View File
@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
/**
* Indekset over indstillingerne skal passe på siderne.
*
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
*
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
* ville melde fejl på det, der med rette var skjult.
*/
const here = dirname(fileURLToPath(import.meta.url));
const routes = join(here, '..', 'src', 'routes');
const locales = join(here, '..', 'src', 'lib', 'locales');
/** `/indstillinger/data` → filen, den side tegnes af. */
function pageFile(path) {
return join(routes, path, '+page.svelte');
}
test('hvert afsnit i indekset findes på den side, det peger på', () => {
const missing = [];
for (const entry of SETTINGS) {
const src = readFileSync(pageFile(entry.path), 'utf8');
const hits = src.split(`id="${entry.anchor}"`).length - 1;
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
}
expect(missing).toEqual([]);
});
test('to afsnit deler ikke et anker', () => {
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
const seen = new Set();
const dupes = [];
for (const entry of SETTINGS) {
const key = `${entry.path}#${entry.anchor}`;
if (seen.has(key)) dupes.push(key);
seen.add(key);
}
expect(dupes).toEqual([]);
});
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
const missing = [];
for (const locale of ['da', 'en']) {
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
for (const entry of SETTINGS) {
for (const key of [entry.key, entry.tab]) {
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
}
}
}
expect(missing).toEqual([]);
});
test('søgningen finder det, man ville skrive', () => {
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
const t = (key) => key;
const found = (q, admin = true) =>
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
expect(found('beacon')).toContain('/indstillinger/data#beacon');
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
expect(found('2fa')).toContain('/indstillinger#to-faktor');
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
expect(found('')).toEqual([]);
expect(found(' ')).toEqual([]);
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
// kan holde.
expect(found('backup', false)).toEqual([]);
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
});
+1031 -14
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -338,7 +338,11 @@ export const api = {
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
// hører til en konto her.
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
@@ -496,7 +500,9 @@ export const api = {
// Dagens plan om morgenen.
planSettings: () => get('/ai/plan'),
setPlanSettings: (body) => put('/ai/plan', body),
planNow: () => post('/ai/plan/now'),
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
planNow: (options) => post('/ai/plan/now', options ?? {}),
version: () => get('/version'),
+24 -1
View File
@@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { humanMessage } from '$lib/api.js';
import { findSettings } from '$lib/settingsindex.js';
let { open = $bindable(false) } = $props();
@@ -68,7 +69,20 @@
}, 140);
});
/**
* Indstillingerne, og hvorfor de ligger øverst.
*
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
* modsat en søgning i noget, man selv har skrevet.
*/
let settings = $derived(
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
);
let results = $derived([
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
@@ -108,7 +122,16 @@
function choose(item) {
open = false;
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
// op, den ER stedet.
if (item.kind === 'setting') return goto(item.id);
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
// liste står uændret — og de andre træffere var kun at se i den popup, man
// lige lukkede.
if (item.kind === 'note') {
const q = query.trim();
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
}
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
@@ -0,0 +1,95 @@
<script>
/**
* What the app shows when it could not reach the server at all.
*
* Before this, an outage rendered the sign-in screen: the boot asks who you
* are, the request never gets an answer, `user` stays null, and null means
* "logged out" everywhere else. So the app blamed the session for the network
* being down, and offered a password box that could not possibly work. That is
* worse than an error message, because it looks like it might work — people
* type the password, get nothing, and conclude their account is broken.
*
* It happened for real on 17 September 2026: the instance's DNS record was
* deleted, and what the app showed was a slow-loading login page.
*
* The text says the data is safe, because that is the question somebody
* actually has when their notes vanish behind a login box.
*/
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
let trying = $state(false);
async function again() {
trying = true;
try {
await app.load();
} finally {
trying = false;
}
}
</script>
<div class="screen">
<div class="card">
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
</button>
</div>
</div>
<style>
.screen {
height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
.card {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: var(--s4);
text-align: center;
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
h1 {
font-size: var(--text-xl);
margin: 0;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.lede {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
</style>
+261 -21
View File
@@ -13,7 +13,7 @@
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
@@ -133,20 +133,78 @@
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
// out of the block from the menu. Do any conversion that touches a <pre> by
// hand, replacing the element rather than reformatting inside it.
const block = currentBlock();
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
replaceBlock(block, style.tag);
//
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
const blocks = selectedBlocks();
if (!blocks.length) return apply('formatBlock', style.tag);
if (style.tag === 'pre') {
mergeToMono(blocks);
return;
}
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
const pres = blocks.filter((b) => b.tagName === 'PRE');
for (const b of pres) swapTag(b, style.tag);
if (pres.length === blocks.length) {
readState();
changed();
return;
}
apply('formatBlock', style.tag);
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
*
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
* som før.
*/
function replaceBlock(block, tag) {
function selectedBlocks() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount || !editor) return [];
const range = selection.getRangeAt(0);
return [...editor.children].filter((b) => range.intersectsNode(b));
}
/**
* Lægger flere blokke sammen til én monotypeblok.
*
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
* de er.
*/
function mergeToMono(blocks) {
const pre = document.createElement('pre');
// blockText og ikke textContent: et <br> har den tomme streng som
// textContent, og det er netop <br>, der står for et linjeskift inde i en
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
if (!pre.textContent) pre.appendChild(document.createElement('br'));
blocks[0].replaceWith(pre);
for (const b of blocks.slice(1)) b.remove();
const range = document.createRange();
range.selectNodeContents(pre);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
function swapTag(block, tag) {
// The copy button lives inside the <pre> so it can sit in its corner; it is
// not text and must not travel into a paragraph. addCopyButtons puts it back
// on the next render for any <pre> that still needs one.
@@ -156,6 +214,16 @@
while (block.firstChild) el.appendChild(block.firstChild);
if (!el.firstChild) el.appendChild(document.createElement('br'));
block.replaceWith(el);
return el;
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
*/
function replaceBlock(block, tag) {
const el = swapTag(block, tag);
const range = document.createRange();
range.selectNodeContents(el);
@@ -380,7 +448,7 @@
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeTextOf(pre);
const text = blockText(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
@@ -394,19 +462,16 @@
}
/**
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
*
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
* væk fra den. På skærmen, ikke kun i filen.
*/
function codeTextOf(pre) {
const from = pre.querySelector('code') ?? pre;
return [...from.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
}
function colourCode() {
if (!editor) return;
@@ -419,7 +484,7 @@
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
// spiste den kode, den var sat der for at kopiere.
const code = codeTextOf(pre);
const code = blockText(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -640,9 +705,69 @@
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
return;
}
// Et billede i en note er et billede, man har indsat for at kunne se det. I
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
// der ikke ser ud som et.
const image = event.target.closest?.('img');
if (image) {
event.preventDefault();
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
copied = false;
copyError = '';
return;
}
colourCode();
}
// --- billedet i fuld størrelse ------------------------------------------------
let showing = $state(null);
let copied = $state(false);
let copyError = $state('');
function closeImage() {
showing = null;
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
// findes længere, og den næste tast går ingen steder.
editor?.focus();
}
/**
* Billedet som PNG, uanset hvad det kom som.
*
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
*/
async function asPNG(src) {
const blob = await (await fetch(src)).blob();
if (blob.type === 'image/png') return blob;
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
async function copyImage() {
copyError = '';
try {
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (e) {
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
copyError = t('notes.copyFailed');
}
}
/** Replaces the half-typed tag with the whole thing. */
function accept(item) {
const partial = partialTag();
@@ -728,6 +853,17 @@
});
}
/**
* Står markøren i en punktliste eller en nummereret liste?
*
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
* ikke blive uenige om, hvad der er en liste.
*/
const inList = () =>
document.queryCommandState('insertUnorderedList') ||
document.queryCommandState('insertOrderedList');
function onkeydown(event) {
// The suggestion list owns the arrows and return while it is open, the way
// every other completion does. Escape closes it without choosing.
@@ -756,6 +892,15 @@
}
}
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
if (event.key === 'Tab' && inList()) {
event.preventDefault();
apply(event.shiftKey ? 'outdent' : 'indent');
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
// A code block is the one place the browser keeps the caret inside on
// Enter rather than starting a new block. Step out of it ourselves, or a
@@ -1068,7 +1213,102 @@
</p>
</div>
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
af netop det ark, det skal lægge sig over. -->
{#if showing}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="shot" onclick={(e) => e.stopPropagation()}>
<img src={showing.src} alt={showing.alt} />
<div class="tools">
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
</div>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
{/if}
<svelte:window
onkeydown={(e) => {
if (showing && e.key === 'Escape') {
e.stopPropagation();
closeImage();
}
}}
/>
<style>
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
står alene — det er hele grunden til at åbne det. */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
}
.shot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
max-width: 100%;
max-height: 100%;
}
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
passe sig ind i den. */
.shot img {
min-height: 0;
max-width: 100%;
max-height: calc(100vh - 8rem);
object-fit: contain;
border-radius: var(--radius);
}
.tools {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
.tools button,
.tools a {
padding: var(--s2) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
text-decoration: none;
}
.tools button:hover,
.tools a:hover {
border-color: var(--accent);
}
.bad {
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
}
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
.page :global(img) {
cursor: zoom-in;
}
.wrap {
display: flex;
flex-direction: column;
+57 -3
View File
@@ -20,10 +20,36 @@
let open = $state(false);
let wrap = $state(null);
/**
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
*
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
* klippet. Der stod tre en halv linje tekst uden for skærmen.
*
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
* kantafstand til højre for klokken — men så ville den her fil kende sine
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
* skærmen med en kant i hver side.
*/
let below = $state(0);
function place() {
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
}
function toggle() {
open = !open;
if (open) place();
}
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'project.shared': 'notif.projectShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
@@ -56,6 +82,9 @@
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
// til at gøre noget ved den sidder.
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
// Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er
// påmindelsen, kortet er stedet.
if (n.kind === 'daily.plan') return goto('/');
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
@@ -68,6 +97,7 @@
<svelte:window
onclick={outside}
onresize={() => open && place()}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
@@ -77,7 +107,7 @@
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
onclick={toggle}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
@@ -97,7 +127,7 @@
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
@@ -111,7 +141,10 @@
<li>
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
<span class="what">{headline(n)}</span>
{#if n.body}<span class="about">{n.body}</span>{/if}
<!-- Dagens plan er tre linjer og ikke en overskrift. Klippet af
efter én linje sagde den "2 forfalder i dag, 1 er sprun…",
hvilket er den halve oplysning og hele støjen. -->
{#if n.body}<span class="about" class:full={n.kind === 'daily.plan'}>{n.body}</span>{/if}
<span class="ago">{when(n.created_at)}</span>
</button>
</li>
@@ -186,6 +219,21 @@
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
toplinjens egen menuknap, så de to skifter form samtidig. */
@media (max-width: 820px) {
.panel {
position: fixed;
top: calc(var(--below) + 6px);
left: var(--s3);
right: var(--s3);
width: auto;
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
overscroll-behavior: contain;
}
}
.panel header {
display: flex;
align-items: center;
@@ -260,6 +308,12 @@
white-space: nowrap;
}
.about.full {
white-space: pre-line;
text-overflow: clip;
line-height: 1.5;
}
.ago {
font-size: var(--text-xs);
color: var(--ink-faint);
+56 -2
View File
@@ -171,6 +171,32 @@
* took is said out loud, because a title that edits itself without a word is
* worse than one that does nothing.
*/
/**
* Lader et felt være lige så højt som sin tekst.
*
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
* højde.
*/
function grow(node) {
const fit = () => {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
};
fit();
node.addEventListener('input', fit);
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
// tre på en smallere skærm uden at der er rørt ved den.
const watch = new ResizeObserver(fit);
watch.observe(node);
return {
destroy() {
node.removeEventListener('input', fit);
watch.disconnect();
}
};
}
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
@@ -418,13 +444,27 @@
<div class="scroll">
<div class="field">
<input
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
halvdelen af overskriften uden for kanten. -->
<textarea
class="title"
rows="1"
use:focusOnMount
use:grow
bind:value={content}
onblur={saveContent}
onkeydown={(e) => {
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
// uanset hvor mange den fylder på skærmen.
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={t('detail.text')}
/>
></textarea>
</div>
<div class="field">
@@ -894,6 +934,16 @@
resize: vertical;
font-family: inherit;
line-height: 1.5;
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
ser ikke ud som rulning; det ser ud som manglende bogstaver.
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
lektion er betalt én gang. */
overflow-wrap: break-word;
}
/* The title is the one field that should not look like a field until you are in
@@ -905,6 +955,10 @@
background: transparent;
border-color: transparent;
padding-left: 0;
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
holde: trækker man i det, retter det næste bogstav det tilbage. */
resize: none;
overflow: hidden;
}
.title:hover {
+24 -1
View File
@@ -31,6 +31,7 @@ export const da = {
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.projectShared': '{name} delte et projekt med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.update': 'Der er kommet en ny version',
@@ -96,6 +97,10 @@ export const da = {
'ai.planHint':
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
'ai.planHour': 'Klokken',
'ai.planMade': 'lavet {time}',
'ai.planAgain': 'Forny planen',
'ai.planMake': 'Lav dagens plan',
'ai.planDismiss': 'Skjul planen i dag',
'ai.planNow': 'Send den nu',
'ai.planSent': 'Sendt — kig i klokken.',
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
@@ -168,6 +173,10 @@ export const da = {
'notes.copyCode': 'Kopiér',
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.imageTitle': 'Billede',
'notes.imageCopy': 'Kopiér billedet',
'notes.imageCopied': 'Kopieret',
'notes.imageOpen': 'Åbn i nyt faneblad',
'notes.strike': 'Gennemstreget',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
@@ -206,6 +215,7 @@ export const da = {
'palette.project': 'Projekt',
'palette.task': 'Opgave',
'palette.note': 'Note',
'palette.setting': 'Indstilling',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
@@ -216,7 +226,7 @@ export const da = {
'nav.signOutConfirm': 'Tryk igen for at logge ud',
'nav.main': 'Hovedmenu',
'nav.search': 'Søg',
'nav.searchLong': 'Søg i opgaver og projekter',
'nav.searchLong': 'Søg i opgaver, noter, projekter og indstillinger',
'nav.noResults': 'Ingen resultater.',
'nav.showSidebar': 'Vis sidebjælken',
'nav.hideSidebar': 'Skjul sidebjælken',
@@ -227,6 +237,9 @@ export const da = {
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'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.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
@@ -297,6 +310,8 @@ export const da = {
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Indstillinger',
'settings.search': 'Søg i indstillinger',
'settings.searchNone': 'Ingen indstilling hedder det.',
'settings.tab.account': 'Konto',
'settings.tab.notifications': 'Notifikationer',
'settings.tab.integrations': 'Integrationer',
@@ -901,6 +916,14 @@ export const da = {
'project.canEdit': 'Kan redigere',
'project.canView': 'Kan kun se',
'project.invite': 'Inviter',
'project.pickPerson': 'Vælg en person…',
'project.leave': 'Forlad projektet',
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
'project.roleForPicked': 'Rolle for den valgte',
'project.shareWith': 'Del',
'project.orByEmail': 'Eller en, der ikke er her endnu:',
'project.invited': 'Inviteret, ikke kommet endnu',
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
'project.inviteByEmail': 'Inviter via e-mail',
'project.emailAddress': 'e-mailadresse',
'project.role': 'Rolle',
+24 -1
View File
@@ -28,6 +28,7 @@ export const en = {
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.projectShared': '{name} shared a project with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.update': 'A new version is out',
@@ -93,6 +94,10 @@ export const en = {
'ai.planHint':
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
'ai.planHour': 'At',
'ai.planMade': 'made at {time}',
'ai.planAgain': 'Make it again',
'ai.planMake': "Make today's plan",
'ai.planDismiss': 'Hide the plan for today',
'ai.planNow': 'Send it now',
'ai.planSent': 'Sent — look in the bell.',
'ai.planNothing': 'There is nothing to plan for today.',
@@ -165,6 +170,10 @@ export const en = {
'notes.copyCode': 'Copy',
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.imageTitle': 'Image',
'notes.imageCopy': 'Copy the image',
'notes.imageCopied': 'Copied',
'notes.imageOpen': 'Open in a new tab',
'notes.strike': 'Strikethrough',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',
@@ -203,6 +212,7 @@ export const en = {
'palette.project': 'Project',
'palette.task': 'Task',
'palette.note': 'Note',
'palette.setting': 'Setting',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
@@ -213,7 +223,7 @@ export const en = {
'nav.signOutConfirm': 'Click again to sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks and projects',
'nav.searchLong': 'Search tasks, notes, projects and settings',
'nav.noResults': 'Nothing found.',
'nav.showSidebar': 'Show the sidebar',
'nav.hideSidebar': 'Hide the sidebar',
@@ -224,6 +234,9 @@ export const en = {
'nav.offlineHint': 'Changes from other people are not showing right now',
'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.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
@@ -294,6 +307,8 @@ export const en = {
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Settings',
'settings.search': 'Search settings',
'settings.searchNone': 'No setting goes by that name.',
'settings.tab.account': 'Account',
'settings.tab.notifications': 'Notifications',
'settings.tab.integrations': 'Integrations',
@@ -895,6 +910,14 @@ export const en = {
'project.canEdit': 'Can edit',
'project.canView': 'Can only view',
'project.invite': 'Invite',
'project.pickPerson': 'Pick somebody…',
'project.leave': 'Leave the project',
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
'project.roleForPicked': 'Role for the person picked',
'project.shareWith': 'Share',
'project.orByEmail': 'Or somebody who is not here yet:',
'project.invited': 'Invited, not arrived yet',
'project.withdrawInvite': 'Withdraw the invitation to {email}',
'project.inviteByEmail': 'Invite by email',
'project.emailAddress': 'email address',
'project.role': 'Role',
+44 -8
View File
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i et blok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
* tiden.
*
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
export function blockText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += blockText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
const code = blockText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
+140
View File
@@ -0,0 +1,140 @@
/**
* Hvor hver indstilling står, så den kan findes ved at søge efter den.
*
* Indstillingerne er ti sider med en snes afsnit i alt, og der var ingen vej til
* et bestemt af dem andet end at huske, hvilken fane det lå under. ⌘K kunne
* opgaver, projekter og noter — altså alt det, man selv har skrevet, og intet af
* det, programmet består af.
*
* Ét indeks og ikke to søgninger. Feltet øverst i indstillingerne og paletten
* stiller det samme spørgsmål, og to lister over de samme afsnit ville være to
* steder at glemme et nyt et.
*
* Overskrifterne står ikke her. Hver post peger på den nøgle, afsnittets egen
* overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig
* selv — og søgningen kan begge sprog uden at kende nogen af dem.
*
* `words` er derimod skrevet her, og det er med vilje: det er søgeord, ingen får
* at se. De er de tekniske navne — `imap`, `webcal`, `2fa`, `webhook` — som er de
* samme på dansk og engelsk, og som er dem, man taster, når man leder efter det,
* man kender andetsteds fra. Det, der skal oversættes, er overskriften, og den
* ligger i sprogfilen.
*/
/** Hver post: siden, ankeret på den, overskriftens nøgle, fanens nøgle. */
export const SETTINGS = [
// Konto
{ path: '/indstillinger', anchor: 'profil', key: 'account.profile', tab: 'settings.tab.account', words: 'profil profile navn name email mail tidszone timezone sprog language' },
{ path: '/indstillinger', anchor: 'udseende', key: 'account.appearance', tab: 'settings.tab.account', words: 'tema theme dark light farve color' },
{ path: '/indstillinger', anchor: 'look', key: 'account.look', tab: 'settings.tab.account', words: 'serif terminal' },
{ path: '/indstillinger', anchor: 'stoerrelser', key: 'account.sizes', tab: 'settings.tab.account', words: 'skrift font zoom' },
{ path: '/indstillinger', anchor: 'links', key: 'link.title', tab: 'settings.tab.account', words: 'browser safari chrome firefox' },
{ path: '/indstillinger', anchor: 'adgangskode', key: 'account.password', tab: 'settings.tab.account', words: 'password kodeord' },
{ path: '/indstillinger', anchor: 'to-faktor', key: 'account.totp', tab: 'settings.tab.account', words: '2fa totp authenticator to-faktor' },
{ path: '/indstillinger', anchor: 'passkey', key: 'passkey.title', tab: 'settings.tab.account', words: 'passkey webauthn touch id face' },
{ path: '/indstillinger', anchor: 'enheder', key: 'account.devices', tab: 'settings.tab.account', words: 'session sessioner devices log ud' },
{ path: '/indstillinger', anchor: 'stoette', key: 'account.support', tab: 'settings.tab.account', words: 'donate kaffe coffee' },
// Notifikationer
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
// Integrationer
{ path: '/indstillinger/integrationer', anchor: 'gmail', key: 'int.gmail', tab: 'settings.tab.integrations', words: 'gmail google oauth' },
{ path: '/indstillinger/integrationer', anchor: 'kalender', key: 'int.calendar', tab: 'settings.tab.integrations', words: 'google calendar' },
{ path: '/indstillinger/integrationer', anchor: 'abonnementer', key: 'int.subscriptions', tab: 'settings.tab.integrations', words: 'ics webcal abonnement subscribe' },
{ path: '/indstillinger/integrationer', anchor: 'kalenderfeed', key: 'int.feed', tab: 'settings.tab.integrations', words: 'ical ics feed apple thunderbird' },
{ path: '/indstillinger/integrationer', anchor: 'mail-til-opgave', key: 'int.mailToTask', tab: 'settings.tab.integrations', words: 'mail email forward videresend' },
{ path: '/indstillinger/integrationer', anchor: 'krog', key: 'int.hookToTask', tab: 'settings.tab.integrations', words: 'webhook hook curl shortcut genvej' },
{ path: '/indstillinger/integrationer', anchor: 'caldav-server', key: 'int.caldav', tab: 'settings.tab.integrations', words: 'caldav reminders' },
{ path: '/indstillinger/integrationer', anchor: 'postkasser', key: 'int.mailboxes', tab: 'settings.tab.integrations', words: 'imap icloud fastmail postkasse' },
// AI
{ path: '/indstillinger/ai', anchor: 'ai', key: 'ai.title', tab: 'settings.tab.ai', words: 'ai model anthropic openai claude gpt gemini api key' },
{ path: '/indstillinger/ai', anchor: 'ugentlig', key: 'ai.weekly', tab: 'settings.tab.ai', words: 'uge weekly opsamling' },
// Tokens
{ path: '/indstillinger/tokens', anchor: 'tokens', key: 'tokens.title', tab: 'settings.tab.tokens', words: 'token api mcp connector bearer' },
// Brugere, historik, fejl — kun for administratorer
{ path: '/indstillinger/brugere', anchor: 'invitation', key: 'users.invite', tab: 'settings.tab.users', words: 'invite invitation bruger user', admin: true },
{ path: '/indstillinger/brugere', anchor: 'afventende', key: 'users.pending', tab: 'settings.tab.users', words: 'pending afventer', admin: true },
{ path: '/indstillinger/brugere', anchor: 'konti', key: 'users.accounts', tab: 'settings.tab.users', words: 'konti accounts admin', admin: true },
{ path: '/indstillinger/historik', anchor: 'historik', key: 'history.title', tab: 'settings.tab.history', words: 'aktivitet activity log', admin: true },
{ path: '/indstillinger/fejl', anchor: 'fejl', key: 'errors.title', tab: 'settings.tab.errors', words: 'error 500 log', admin: true },
// Data
{ path: '/indstillinger/data', anchor: 'import', key: 'data.import', tab: 'settings.tab.data', words: 'import todoist csv json' },
{ path: '/indstillinger/data', anchor: 'faerdige', key: 'done.title', tab: 'settings.tab.data', words: 'done completed oprydning' },
{ path: '/indstillinger/data', anchor: 'papirkurv', key: 'data.trash', tab: 'settings.tab.data', words: 'trash slettet deleted' },
{ path: '/indstillinger/data', anchor: 'sikkerhedskopier', key: 'data.backups', tab: 'settings.tab.data', words: 'backup sikkerhedskopi', admin: true },
{ path: '/indstillinger/data', anchor: 'eksport', key: 'data.export', tab: 'settings.tab.data', words: 'export eksport zip markdown' },
{ path: '/indstillinger/data', anchor: 'skabeloner', key: 'data.templates', tab: 'settings.tab.data', words: 'template skabelon' },
{ path: '/indstillinger/data', anchor: 'beacon', key: 'beacon.title', tab: 'settings.tab.data', words: 'beacon tæller collector telemetri' }
];
/**
* Samme foldning som serverens `fold`-kolonne.
*
* `remove_diacritics` i SQLite gør intet ved ø, æ og å — de er egne bogstaver og
* ikke accenter — og en dansk søgning, der ikke kan finde "sikkerhedskopier" fra
* "sikkerhedskopier" skrevet på et fremmed tastatur, er brudt for sine egne
* brugere. Derfor samme oversættelse her som der, og `normalize` ovenpå til alt
* det, Unicode faktisk regner for accenter.
*/
export function fold(text) {
return (text ?? '')
.toLowerCase()
.replace(/ø/g, 'o')
.replace(/æ/g, 'ae')
.replace(/å/g, 'aa')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
/**
* Indstillingerne, der svarer til det skrevne.
*
* Alle ordene skal findes, hvert af dem som begyndelsen af et ord — "sik kop"
* finder sikkerhedskopierne. Den, der søger, skriver forfra; en søgning, der også
* matcher midt i et ord, finder "opgave" i "hovedopgaven" og larmer.
*
* `t` gives med udefra frem for at blive importeret. Det holder filen fri af
* Sveltes runes, så den kan læses af en almindelig prøve uden en komponent
* omkring sig.
*/
export function findSettings(query, { t, isAdmin = false, limit = 8 } = {}) {
const terms = fold(query).split(/\s+/).filter(Boolean);
if (!terms.length) return [];
const hits = [];
for (const entry of SETTINGS) {
if (entry.admin && !isAdmin) continue;
const title = t(entry.key);
const tab = t(entry.tab);
const words = fold(`${title} ${tab} ${entry.words}`)
.split(/[\s/·,-]+/)
.filter(Boolean);
// Alle ordene skal findes, og hvert af dem forfra i et ord: "sikker" finder
// sikkerhedskopierne, "kopier" gør ikke. Den, der søger, skriver
// begyndelsen af det, den leder efter — en søgning, der også rammer midt i
// et ord, finder "opgave" inde i "hovedopgaven" og larmer.
if (!terms.every((term) => words.some((word) => word.startsWith(term)))) continue;
// Det, der hedder det, man søgte efter, før det, der blot nævner det. Uden
// den her ligger rækkefølgen fast som filens, og "beacon" ville få hele
// Data-siden ned over sig, fordi den står først.
const folded = fold(title);
const rank = folded.startsWith(terms[0]) ? 0 : folded.includes(terms[0]) ? 1 : 2;
hits.push({ rank, entry: { ...entry, title, tab, href: `${entry.path}#${entry.anchor}` } });
}
return hits
.sort((a, b) => a.rank - b.rank)
.slice(0, limit)
.map((hit) => hit.entry);
}
+19
View File
@@ -29,6 +29,20 @@ class AppState {
people = $state([]);
tasks = $state([]);
loading = $state(true);
/**
* True when the boot never reached the server — as opposed to reaching it and
* being told the session is gone.
*
* The two are indistinguishable from `user === null`, and they are opposite
* things to the person looking at the screen: one means "log ind", the other
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
* total outage used to show the login screen, so the app blamed the user's
* session for the network being down — and a login page that cannot possibly
* work is worse than an error, because it looks like it might.
*/
unreachable = $state(false);
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -96,6 +110,7 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
this.unreachable = false;
// The interface's language follows the account, so it is set the moment
// the session is known — before anything has drawn a string.
i18n.set(this.user.locale);
@@ -111,6 +126,10 @@ class AppState {
this.loadNotifications();
} catch (e) {
this.user = null;
// `offline` is the one code the request layer makes up itself: it is what
// is thrown when the retry window ran out without any status ever
// arriving. A 401 is the server answering, and that is a real sign-out.
this.unreachable = e?.code === 'offline';
} finally {
this.loading = false;
}
+5
View File
@@ -8,6 +8,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
@@ -166,6 +167,10 @@
<div class="booting"></div>
{:else if standalone}
{@render children()}
{:else if app.unreachable}
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
kodeord hjælper ikke mod en manglende forbindelse. -->
<NoConnection />
{:else if !app.user}
<SignIn />
{:else}
+165
View File
@@ -5,6 +5,7 @@
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import { t, tag } from '$lib/i18n.svelte.js';
import { humanMessage } from '$lib/api.js';
let loaded = $state(false);
@@ -17,6 +18,72 @@
).padStart(2, '0')}`;
})();
/**
* Dagens plan, dér hvor man kigger, når man starter dagen.
*
* Hentet og ikke regnet forfra: det er den samme tekst, beskeden i morges bar.
* En plan, der siger noget andet, når man kigger på den, end den sagde, da den
* kom, er værre end begge dele hver for sig — og en model pr. sidevisning er
* dyr for noget, der ikke har ændret sig.
*
* Lukket for i dag frem for slettet. Kortet er en påmindelse, ikke en opgave,
* og den, der har læst den, skal kunne få den af vejen uden at miste den —
* derfor står lukningen i localStorage med dagens dato og ikke på kontoen.
*/
let plan = $state(null);
let planBusy = $state(false);
let planError = $state('');
const DISMISS_KEY = 'verdande:plan-dismissed';
let dismissed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem(DISMISS_KEY) === isoToday
);
function dismissPlan() {
dismissed = true;
try {
localStorage.setItem(DISMISS_KEY, isoToday);
} catch {
// Privat vindue; kortet er væk til næste indlæsning.
}
}
async function makePlan() {
if (planBusy) return;
planBusy = true;
planError = '';
try {
// Stille: man står og kigger på den. En besked om noget, man har foran
// sig, er den slags, der lærer folk at ignorere klokken.
const made = await api.planNow({ silent: true });
plan = { ...plan, plan: made.plan, plan_at: made.plan_at };
dismissed = false;
} catch (e) {
planError = humanMessage(e);
} finally {
planBusy = false;
}
}
$effect(() => {
api
.planSettings()
.then((p) => (plan = p))
.catch(() => {
// Et kort, der ikke kunne hentes, må ikke stå i vejen for listen.
});
});
const planMade = $derived(
plan?.plan_at
? new Date(plan.plan_at * 1000).toLocaleTimeString(tag(), {
hour: '2-digit',
minute: '2-digit'
})
: ''
);
async function load() {
const data = await api.today();
app.tasks = [...data.overdue, ...data.today];
@@ -54,6 +121,33 @@
<QuickAdd />
{#if plan?.plan && !dismissed}
<!-- Teksten som den blev sendt, og klokkeslættet den blev lavet: en plan fra
klokken syv er en anden slags oplysning klokken fire end klokken otte. -->
<section class="plan">
<header>
<h2>{t('ai.plan')}</h2>
{#if planMade}<span class="made">{t('ai.planMade', { time: planMade })}</span>{/if}
<button class="close" onclick={dismissPlan} aria-label={t('ai.planDismiss')}>×</button>
</header>
<p>{plan.plan}</p>
<button class="again" onclick={makePlan} disabled={planBusy}>
{planBusy ? t('ai.thinking') : t('ai.planAgain')}
</button>
</section>
{:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)}
<!-- Ingen plan i dag: enten er beskeden slået fra, eller også er timen ikke
kommet endnu. Knappen frem for ingenting, så den, der har slået
morgenbeskeden fra, stadig kan bede om planen. Kun når der er noget at
lægge en plan om — en tom dag har ikke brug for en knap. -->
<p class="planask">
<button class="again" onclick={makePlan} disabled={planBusy}>
{planBusy ? t('ai.thinking') : t('ai.planMake')}
</button>
{#if planError}<span class="planerror">{planError}</span>{/if}
</p>
{/if}
{#if liveOverdue.length}
<section>
<h2 class="overdue">{t('view.overdue')}</h2>
@@ -93,6 +187,77 @@
margin-bottom: var(--s5);
}
/* Kortet ligger mellem feltet og listen: under det, man skriver i, og over det,
man skal se på. Dæmpet frem for fremhævet — det er en påmindelse om, hvad
dagen indeholder, ikke en opgave, der skal laves. */
.plan {
margin: 0 0 var(--s5);
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-raised, var(--surface));
}
.plan header {
display: flex;
align-items: baseline;
gap: var(--s2);
margin: 0 0 var(--s2);
}
.plan h2 {
font-size: var(--text-sm);
margin: 0;
}
.plan .made {
color: var(--ink-faint);
font-size: var(--text-xs);
}
.plan .close {
margin-left: auto;
border: none;
background: none;
color: var(--ink-faint);
cursor: pointer;
line-height: 1;
font-size: var(--text-md);
}
/* Linjeskift bevaret: planen er to-tre linjer, og de er skrevet som linjer. */
.plan p {
margin: 0 0 var(--s2);
font-size: var(--text-sm);
line-height: 1.6;
white-space: pre-line;
}
.again {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--text-xs);
color: var(--accent);
cursor: pointer;
}
.again:disabled {
color: var(--ink-faint);
cursor: default;
}
.planask {
margin: 0 0 var(--s5);
}
.planerror {
margin-left: var(--s2);
font-size: var(--text-xs);
color: var(--ink-muted);
}
h1 {
font-size: var(--text-2xl);
}
+210 -1
View File
@@ -11,8 +11,10 @@
* is six places for them to drift apart.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { findSettings } from '$lib/settingsindex.js';
let { children } = $props();
@@ -37,6 +39,69 @@
]);
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
/**
* Søgningen efter en indstilling.
*
* Ti faner med en snes afsnit i alt, og den eneste vej til et bestemt af dem
* var at huske, hvilken fane det lå under. Feltet her og ⌘K spørger det samme
* indeks — se settingsindex.js — så et nyt afsnit kun skal skrives ét sted for
* at kunne findes to.
*/
let query = $state('');
let hits = $derived(findSettings(query, { t, isAdmin: app.user?.is_admin ?? false }));
function go(hit) {
query = '';
goto(hit.href);
}
function onkeydown(event) {
if (event.key === 'Escape') {
query = '';
return;
}
if (event.key === 'Enter' && hits.length) {
event.preventDefault();
go(hits[0]);
}
}
/**
* Ruller hen til afsnittet, når adressen nævner det, og blinker det.
*
* Browserens egen springen til et anker kan ikke bruges her: siden hentes af
* ruteren, og afsnittet findes først, når dens data er hjemme — så et spring,
* der sker med det samme, lander på en side, der endnu er tom. Derfor prøves
* det, indtil elementet er der, og gives op efter to sekunder frem for at
* prøve for evigt.
*
* Og blinket er ikke pynt. Man er landet et sted, man ikke selv rullede hen
* til, midt i en side med otte afsnit, der ligner hinanden; uden det skal man
* læse sig frem til, hvor man er.
*/
$effect(() => {
const anchor = $page.url.hash.slice(1);
if (!anchor) return;
let tries = 0;
let timer;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const look = () => {
const el = document.getElementById(anchor);
if (!el) {
if (tries++ < 40) timer = setTimeout(look, 50);
return;
}
el.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' });
el.classList.add('landed');
timer = setTimeout(() => el.classList.remove('landed'), 1600);
};
look();
return () => clearTimeout(timer);
});
</script>
<div class="settings">
@@ -44,6 +109,34 @@
<h1>{t('settings.title')}</h1>
</header>
<div class="find">
<input
type="search"
bind:value={query}
{onkeydown}
placeholder={t('settings.search')}
aria-label={t('settings.search')}
autocomplete="off"
spellcheck="false"
/>
{#if query.trim()}
{#if hits.length}
<ul class="hits">
{#each hits as hit (hit.href)}
<li>
<button onclick={() => go(hit)}>
<span class="where">{hit.tab}</span>
<span class="what">{hit.title}</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="nohits">{t('settings.searchNone')}</p>
{/if}
{/if}
</div>
<nav aria-label={t('settings.title')}>
{#each sections as section (section.href)}
<a href={section.href} class:active={current === section.href}>{section.label}</a>
@@ -109,9 +202,108 @@
gap: var(--s5);
}
.find {
position: relative;
margin-bottom: var(--s3);
}
.find input {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.find input:focus {
border-color: var(--accent);
}
/* Over indholdet frem for at skubbe det ned: listen kommer og går for hvert
bogstav, og en side, der hopper under det, man læser, er svær at sigte i. */
.hits {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin: var(--s1) 0 0;
padding: var(--s1);
list-style: none;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hits button {
display: flex;
align-items: baseline;
gap: var(--s2);
width: 100%;
padding: var(--s2);
border-radius: var(--radius-sm);
text-align: left;
color: var(--ink);
}
.hits button:hover {
background: var(--surface-sunken);
}
/* Fanen står før navnet, fordi svaret på "hvor ligger det?" er halvdelen af
det, man søgte efter. */
.where {
flex: none;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.what {
font-size: var(--text-sm);
}
.nohits {
margin: var(--s2) 0 0;
font-size: var(--text-sm);
color: var(--ink-muted);
}
.settings :global(h3[id]) {
scroll-margin-top: var(--s5);
}
/* Blinket, når man er landet et sted, man ikke selv rullede hen til. Kanten og
ikke baggrunden: en baggrund, der skifter, gør teksten svær at læse i netop
det øjeblik, man er kommet for at læse den. */
.settings :global(.landed) {
animation: landed 1.6s var(--ease);
}
@keyframes landed {
0%,
60% {
box-shadow: 0 0 0 2px var(--accent);
}
100% {
box-shadow: 0 0 0 2px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.settings :global(.landed) {
animation: none;
}
}
/* --- shared form chrome, for the child routes ------------------------------- */
.settings :global(section.panel) {
/* Der er en fast linje øverst på skærmen. Uden den her lander afsnittets
overskrift bag den, når man springer hertil fra en søgning. */
scroll-margin-top: var(--s5);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
@@ -151,7 +343,8 @@
.settings :global(.field input),
.settings :global(.field select),
.settings :global(.field textarea) {
.settings :global(.field textarea),
.settings :global(.field output) {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
@@ -168,6 +361,22 @@
border-color: var(--accent);
}
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
er tekst, der ombryder.
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
.settings :global(.field output) {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: all;
line-height: 1.5;
}
/* The field error sits under its own input rather than in a summary at the top:
the server answers with a map of field to message, and putting each one where
it belongs is the whole point of that shape. */
+10 -10
View File
@@ -276,7 +276,7 @@
*/
</script>
<section class="panel">
<section class="panel" id="profil">
<header>
<h2>{t('account.profile')}</h2>
<p class="hint">
@@ -332,7 +332,7 @@
</form>
</section>
<section class="panel">
<section class="panel" id="udseende">
<header>
<h2>{t('account.appearance')}</h2>
<p class="hint">
@@ -366,7 +366,7 @@
<!-- The second axis. A theme says how bright, a look says how it reads — and
they are separate settings because they are separate questions. -->
<div class="looks">
<h3>{t('account.look')}</h3>
<h3 id="look">{t('account.look')}</h3>
<p class="hint">{t('account.lookHint')}</p>
<div class="look-row">
{#each LOOKS as option (option.id)}
@@ -390,7 +390,7 @@
Separate on purpose — one is a glance down a list of names, the other is a
paragraph you sit and read. -->
<div class="sizes">
<h3>{t('account.sizes')}</h3>
<h3 id="stoerrelser">{t('account.sizes')}</h3>
<p class="hint">{t('account.sizesHint')}</p>
<div class="size-row">
<div class="size-field">
@@ -421,7 +421,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="links">
<header>
<h2>{t('link.title')}</h2>
<p class="hint">{t('link.hint')}</p>
@@ -464,7 +464,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="adgangskode">
<header>
<h2>{t('account.password')}</h2>
<p class="hint">
@@ -506,7 +506,7 @@
</form>
</section>
<section class="panel">
<section class="panel" id="to-faktor">
<header>
<h2>{t('account.totp')}</h2>
<p class="hint">{t('account.totpHint')}</p>
@@ -616,7 +616,7 @@
<!-- Between two-factor and the device list, because it belongs to both: a passkey
is a way in, and it is a thing on a device you might later want to revoke. -->
<section class="panel">
<section class="panel" id="passkey">
<header>
<h2>{t('passkey.title')}</h2>
<p class="hint">{t('passkey.hint')}</p>
@@ -655,7 +655,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="enheder">
<header>
<h2>{t('account.devices')}</h2>
<p class="hint">
@@ -687,7 +687,7 @@
work. A plain anchor with an inline SVG — no third-party script, no webfont,
no tracking — so it works offline and cannot phone home. See the coffee-cup
snippet for why not their generated button. -->
<section class="panel">
<section class="panel" id="stoette">
<header>
<h2>{t('account.support')}</h2>
<p class="hint">{t('account.supportHint')}</p>
+2 -2
View File
@@ -83,7 +83,7 @@
}
</script>
<section class="panel">
<section class="panel" id="ai">
<header>
<h2>{t('ai.title')}</h2>
<p class="hint">
@@ -157,7 +157,7 @@
</section>
{#if settings?.provider && settings?.has_key}
<section class="panel">
<section class="panel" id="ugentlig">
<header>
<h2>{t('ai.weekly')}</h2>
<p class="hint">{t('ai.weeklyHint')}</p>
@@ -128,7 +128,7 @@
const expires = shortDate;
</script>
<section class="panel">
<section class="panel" id="invitation">
<header>
<h2>{t('users.invite')}</h2>
<p class="hint">
@@ -163,7 +163,7 @@
</section>
{#if invites.length}
<section class="panel">
<section class="panel" id="afventende">
<header>
<h2>{t('users.pending')}</h2>
<p class="hint">
@@ -191,7 +191,7 @@
</section>
{/if}
<section class="panel">
<section class="panel" id="konti">
<header>
<h2>{t('users.accounts')}</h2>
<p class="hint">{t('users.accountsHint')}</p>
@@ -234,7 +234,7 @@
}
</script>
<section class="panel">
<section class="panel" id="import">
<header>
<h2>{t('data.import')}</h2>
<p class="hint">
@@ -279,7 +279,7 @@
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
to the four that are read every day, and it belongs with the other "where did
that go" questions rather than above them. -->
<section class="panel">
<section class="panel" id="faerdige">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
@@ -289,7 +289,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="papirkurv">
<header>
<h2>{t('data.trash')}</h2>
<p class="hint">
@@ -322,7 +322,7 @@
<!-- Administrators only, like the error log and for a sharper reason: a backup file
is a complete copy of the database, so this panel hands out everybody's data. -->
{#if app.user?.is_admin}
<section class="panel">
<section class="panel" id="sikkerhedskopier">
<header>
<h2>{t('data.backups')}</h2>
<p class="hint">
@@ -380,7 +380,7 @@
</section>
{/if}
<section class="panel">
<section class="panel" id="eksport">
<header>
<h2>{t('data.export')}</h2>
<p class="hint">
@@ -428,7 +428,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="skabeloner">
<header>
<h2>{t('data.templates')}</h2>
<p class="hint">
@@ -512,7 +512,7 @@
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
dem, ikke gemt væk under en fane, ingen åbner. -->
<section class="panel">
<section class="panel" id="beacon">
<header>
<h2>{t('beacon.title')}</h2>
<p class="hint">{t('beacon.hint')}</p>
@@ -28,7 +28,7 @@
</script>
<section class="panel">
<section class="panel" id="fejl">
<header>
<h2>{t('errors.title')}</h2>
<p class="hint">
@@ -106,7 +106,7 @@
}
</script>
<section class="panel">
<section class="panel" id="historik">
<header>
<h2>{t('history.title')}</h2>
<p class="hint">
@@ -416,7 +416,7 @@
}
</script>
<section class="panel">
<section class="panel" id="gmail">
<header>
<h2>{t('int.gmail')}</h2>
<p class="hint">
@@ -460,7 +460,7 @@
it has to match Google's copy exactly, and the error Google gives
when it does not names neither value. Google afviser desuden
private IP-adresser over http — det skal være https. -->
<input id="redirect" readonly value={client.redirect_uri} />
<output id="redirect">{client.redirect_uri}</output>
</div>
<form onsubmit={saveClient}>
@@ -520,7 +520,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="kalender">
<header>
<h2>{t('int.calendar')}</h2>
<p class="hint">{t('int.calendarHint')}</p>
@@ -546,7 +546,7 @@
{#if app.user?.is_admin}
<div class="field">
<label for="calendar-redirect">{t('int.redirectURI')}</label>
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
<p class="hint">{t('int.calendarSameClient')}</p>
</div>
{:else if !calendar.has_client}
@@ -605,7 +605,7 @@
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
{#if calendar !== null}
<div class="subs">
<h3>{t('int.subscriptions')}</h3>
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
<p class="hint">{t('int.subscriptionsHint')}</p>
{#if calendar.subscriptions?.length}
@@ -643,7 +643,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="kalenderfeed">
<header>
<h2>{t('int.feed')}</h2>
<p class="hint">
@@ -653,7 +653,7 @@
<div class="field">
<label for="feed">{t('int.address')}</label>
<input id="feed" class="mono" value={feedURL} readonly />
<output id="feed" class="mono">{feedURL}</output>
</div>
<div class="row">
@@ -665,7 +665,7 @@
</p>
</section>
<section class="panel">
<section class="panel" id="mail-til-opgave">
<header>
<h2>{t('int.mailToTask')}</h2>
<p class="hint">
@@ -681,7 +681,7 @@
<div class="field">
<label for="mail">{t('int.yourAddress')}</label>
<input id="mail" class="mono" value={mailAddress} readonly />
<output id="mail" class="mono">{mailAddress}</output>
</div>
<div class="row">
@@ -690,7 +690,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="krog">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
@@ -700,7 +700,7 @@
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly />
<output id="hook" class="mono">{hookURL}</output>
<p class="hint">
{t('int.hookHow')}
</p>
@@ -711,8 +711,8 @@
kroppen bare er den linje, opgaven skal hedde. -->
<div class="field">
<label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
<output id="hookcurl" class="mono"
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
>
</div>
@@ -722,7 +722,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="caldav-server">
<header>
<h2>{t('int.caldav')}</h2>
<p class="hint">
@@ -732,7 +732,7 @@
<div class="field">
<label for="caldav">{t('int.server')}</label>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
<p class="hint">
{t('int.caldavAuth')}
</p>
@@ -743,7 +743,7 @@
rather than a second mode inside the Gmail one, because they are not the same
thing wearing different clothes — one is an app you sign in through, the other
is a password you hold. -->
<section class="panel">
<section class="panel" id="postkasser">
<header>
<h2>{t('int.mailboxes')}</h2>
<p class="hint">{t('int.mailboxesHint')}</p>
@@ -817,7 +817,7 @@
color: var(--ink-muted);
}
.setup input[readonly] {
.setup output {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
@@ -884,4 +884,38 @@
a {
color: var(--accent);
}
/* The bullet list this was is why the page scrolled sideways on a phone. A
subscription address is one word with nothing to break at, so it cannot wrap
on its own — and the widest element on a page sets the width of the page, not
of its own line. Hence `anywhere`, which breaks mid-word when there is no
other opportunity, plus `min-width: 0` so the flex item is allowed to be
narrower than its content in the first place. */
.subs-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s3);
}
.subs-list li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s1);
min-width: 0;
}
.subs-list .url {
font-size: var(--text-xs);
color: var(--ink-muted);
overflow-wrap: anywhere;
max-width: 100%;
}
.subs-list button {
margin-top: var(--s1);
}
</style>
@@ -213,7 +213,7 @@
});
</script>
<section class="panel">
<section class="panel" id="dagens-plan">
<header>
<h2>{t('ai.plan')}</h2>
<p class="hint">{t('ai.planHint')}</p>
@@ -252,7 +252,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="push">
<header>
<h2>{t('push.title')}</h2>
<p class="hint">
@@ -296,7 +296,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="seneste">
<header>
<h2>{t('push.recent')}</h2>
{#if unread > 0}
@@ -335,7 +335,7 @@
</section>
{#if version}
<section class="panel">
<section class="panel" id="version">
<header>
<h2>{t('push.version')}</h2>
</header>
@@ -93,7 +93,7 @@
const expired = (iso) => iso && new Date(iso) < new Date();
</script>
<section class="panel">
<section class="panel" id="tokens">
<header>
<h2>{t('tokens.title')}</h2>
<p class="hint">
+112 -16
View File
@@ -14,7 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { afterNavigate, goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -483,23 +484,95 @@
load(query);
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
/**
* En søgning, man kommer med.
*
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
* træffere frem for den almindelige liste.
*
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
*
* En markør, der skal holde to retninger enige, er en markør, der taber
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
* den eneste, der læser den.
*/
let cameWith = $state(null);
$effect(() => {
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
const inURL = $page.url.searchParams.get('q') ?? '';
if (inURL === untrack(() => cameWith)) return;
cameWith = inURL;
draftQuery = inURL;
query = inURL;
});
/**
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
* den igen.
*
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
* sprang tilbage til den første.
*
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
* ved hver ændring.
*/
function openFromURL(url) {
const id = url?.searchParams.get('note');
if (!id || id === selectedId) return;
const found = notes.find((n) => n.id === id);
if (found) open(found);
else api.note(id).then(open).catch(() => {});
}
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
// den anden.
//
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
// eller fulgt udefra åbnede så ingenting.
//
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
// programmet, monteres siden ikke om.
onMount(() => openFromURL($page.url));
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
/**
* Hvilken hentning der gælder.
*
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
* ikke gøre det umuligt.
*/
let newest = 0;
async function load(q) {
const mine = ++newest;
try {
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).notes ?? [];
const got = (await api.notes(params)).notes ?? [];
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
// det er bare ikke længere svaret på det, der blev spurgt om.
if (mine !== newest) return;
notes = got;
status = 'ready';
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
if (mine !== newest) return;
status = 'failed';
app.toast(humanMessage(e));
}
@@ -516,7 +589,17 @@
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
* at vente; teksten kommer, når den er der.
*/
async function open(note) {
/**
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
*
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
* skrevet der forsvandt sporløst.
*/
async function open(note, { whole = false } = {}) {
clearTimeout(timer);
if (!note) {
selectedId = null;
@@ -532,9 +615,9 @@
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
// sidde med en tom note, der lige havde haft en titel.
selectedId = note.id;
if (note.body) {
if (whole || note.body) {
selected = note;
draft = note.body;
draft = note.body ?? '';
return;
}
selected = null;
@@ -646,7 +729,8 @@
try {
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
open(note);
// Svaret er hele noten, så den skal ikke hentes igen — se open().
await open(note, { whole: true });
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));
@@ -1279,7 +1363,7 @@
lovet i en besked, der ikke kom af sted. -->
<p class="invitelink">
{t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
<output>{shareInviteLink}</output>
</p>
{/if}
</section>
@@ -2216,11 +2300,23 @@
line-height: 1.5;
}
.invitelink input {
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
god til — ét tryk tager hele linket. */
.invitelink output {
display: block;
width: 100%;
margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink);
overflow-wrap: anywhere;
user-select: all;
}
.addshare {
+150 -7
View File
@@ -41,6 +41,13 @@
let inviteRole = $state('editor');
let inviteLink = $state('');
let inviteError = $state('');
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
// medlemmerne, så panelet åbner færdigt i ét kald.
let candidates = $state([]);
let pending = $state([]);
let sharePick = $state('');
let pickRole = $state('editor');
let id = $derived($page.params.id);
@@ -101,7 +108,7 @@
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
api.listMembers(project.id).then(readMembers);
}
});
@@ -512,23 +519,61 @@
}
}
async function invite(event) {
event.preventDefault();
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
function readMembers(r) {
members = r.members;
candidates = r.candidates ?? [];
pending = r.pending ?? [];
}
async function share(who, role) {
inviteError = '';
inviteLink = '';
try {
const result = await api.invite(project.id, inviteEmail, inviteRole);
inviteEmail = '';
const result = await api.invite(project.id, who, role);
if (result.link && !result.emailed) {
// No mail server, or delivery failed. The link is the only way this
// invite reaches anybody, so it is shown rather than swallowed.
inviteLink = result.link;
}
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
} catch (e) {
inviteError = humanMessage(e);
}
}
async function invite(event) {
event.preventDefault();
const email = inviteEmail.trim();
if (!email) return;
await share({ email }, inviteRole);
if (!inviteError) inviteEmail = '';
}
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
// være en vej ud, der ikke går gennem at bede ejeren om det.
async function leave() {
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
try {
await api.removeMember(project.id, app.user.id);
// Væk fra en side, man ikke længere må se: bliver man stående, er det
// næste, siden gør, at hente den og få 404.
await goto('/');
await app.refreshProjects();
} catch (e) {
app.toast(humanMessage(e));
}
}
async function withdraw(invite) {
try {
await api.withdrawInvite(project.id, invite.id);
readMembers(await api.listMembers(project.id));
} catch (e) {
app.toast(humanMessage(e));
}
}
</script>
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
@@ -765,7 +810,7 @@
class="remove"
onclick={async () => {
await api.removeMember(project.id, member.user_id);
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
}}
aria-label={t('project.removeMember', { name: member.name })}>×</button
>
@@ -774,7 +819,66 @@
{/each}
</ul>
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
sendt i går usynlig: personen har ingen konto, så de er hverken
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
{#if pending.length}
<ul class="members invited">
{#each pending as invite (invite.id)}
<li>
<span class="avatar waiting">@</span>
<span class="member-name">{invite.email}</span>
<span class="role">{t('project.invited')}</span>
{#if isOwner}
<button
class="remove"
onclick={() => withdraw(invite)}
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
>
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
projekt — det ville være en overdragelse, ikke en udgang. -->
{#if !isOwner}
<button class="leave" onclick={leave}>{t('project.leave')}</button>
{/if}
{#if isOwner}
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
deles ved at stave en e-mailadresse, altså ved at kende adressen på
en kollega, man kender navnet på — og konti her bliver kun til ved
invitation, så listen er en adressebog og ikke en udlevering. -->
{#if candidates.length}
<div class="addshare">
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
<option value="">{t('project.pickPerson')}</option>
{#each candidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
<option value="editor">{t('project.canEdit')}</option>
<option value="viewer">{t('project.canView')}</option>
</select>
<button
disabled={!sharePick}
onclick={async () => {
const id = sharePick;
sharePick = '';
await share({ user_id: id }, pickRole);
}}>{t('project.shareWith')}</button
>
</div>
{/if}
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
adressen en konto, bliver det en deling med det samme, og ellers en
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
<p class="byemail-hint">{t('project.orByEmail')}</p>
<form onsubmit={invite}>
<input
bind:value={inviteEmail}
@@ -1396,6 +1500,45 @@
font-size: var(--text-sm);
}
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
.invited {
border-top: 1px solid var(--line);
padding-top: var(--s2);
}
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
.avatar.waiting {
background: var(--surface-sunken);
border: 1px dashed var(--line);
color: var(--ink-muted);
}
.addshare {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
.leave {
align-self: flex-start;
padding: var(--s2) var(--s3);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: var(--danger);
font-size: var(--text-sm);
}
.byemail-hint {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.avatar {
width: 22px;
height: 22px;