Compare commits

...
16 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 a2a39c4e17 feat(notes): en flade at skrive i, og noten dukker op i projektet
Release / Build and publish image (push) Failing after 9m55s
Tredje etape. En liste til venstre, noten til højre — ikke et gitter af kort: en
note læses langt oftere på sin første linje end i sin helhed, og en liste viser
tyve af dem, hvor et gitter viser seks.

Gemmes når man holder op med at skrive. En note er det ene sted i programmet, hvor
en times skrivning kan gå tabt på ét fejltryk, og en Gem-knap er en knap, der
findes for at blive glemt. Teksten holdes adskilt fra den note, den kom fra, så en
gemning, der lander sent, ikke kan overskrive det, der er skrevet siden.

Og udbetalingen: skriver man #GarageRisteriet i en note, står den på projektets
side. Ingen har filet den derhen — den dukker op, fordi nogen nævnte projektet
mens de skrev. Slået op på navnet og ikke på et id, for et #tag skrives i hånden,
og et menneske skriver navnet.

Markdown i et almindeligt tekstfelt indtil videre. Live preview, hvor mærkerne kun
vises i den linje markøren står i, er næste stykke og det dyre; det her er den
form, den skal sidde inde i, og alt andet er allerede sandt uden den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:07:49 +02:00
Kristian WindandClaude Opus 5 fbfc98e217 feat(notes): noter kan nås — listet, søgt og fundet baglæns
Anden etape. Handlere, ruter og OpenAPI.

Adgang følger projektet, for det er der, noten bor: en note i et delt projekt kan
læses af alle, der kan læse projektet, og ændres af en redaktør. En note uden
projekt er sin forfatters alene — samme svar som en opgave i indbakken får, nået
frem til på samme måde frem for ved en regel, noter fandt på for sig selv.

Listning og søgning er samme endepunkt, fordi de svarer på det samme spørgsmål
med forskellige ord. Filtreringen sker EFTER indekset: FTS5 svarer om tekst, og
at lære indekset om roller ville lægge en adgangsregel et sted, ingen ville kigge
den dag den var forkert. Der er en prøve for, at en fremmeds søgning ikke finder
noget, og at en fremmed får 404 og ikke 403 — så et id ikke kan afprøves.

Den baglæns rute hænger på det, der spørges om: et opgave-id giver hver note, der
nævner den; et projektnavn giver hver note med det #tag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:00:41 +02:00
Kristian WindandClaude Opus 5 98ea08b0bb feat(notes): fundamentet — Markdown, søgning og links begge veje
Release / Build and publish image (push) Failing after 10m3s
Første etape af noter. Tabel, CRUD, fuldtekstsøgning og de links, en note bærer.

Markdown i en kolonne, ikke rich text og ikke et format af vores eget. Eksport
var et krav, før der var skrevet en linje, og er den gemte form den eksporterede
form, er der ingen konvertering tilbage at tabe noget i — noten kan læses af
enhver anden editor den dag programmet ikke findes.

Et #tag i en note er DET SAMME tag som i en opgave. Ikke en anden slags, der
ligner. Det er hele svaret på "tag en note og se den i projektet": referencen er
den samme, så der ikke opstår to systemer, brugeren selv skal holde i sync.

Links gemmes som rækker, udledt af teksten ved hver gemning. Teksten er stadig
sandheden; tabellen er et indeks over den. Uden det ville "hvad peger på denne
opgave" betyde en gennemsøgning af al prosa, og den rude ville være for langsom
til at lade stå åben. De rives ned og bygges op frem for at blive sammenlignet:
en sammenligning er en anden beskrivelse af samme kendsgerning, og den kan komme
til at være uenig med teksten.

Noter ligger under projekter og arver deling, roller, papirkurv og historik. En
mappestruktur ved siden af ville være ét hierarki for meget.

MatchExpr fik navngivne kolonner. Den var skrevet til opgavernes og gav
"no such column" mod noternes — hvilket i det mindste er højlydt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:47:50 +02:00
Kristian WindandClaude Opus 5 c44099cac0 feat(site): vælg lys eller mørk, og skærmbilledet følger med
Siden fulgte systemet og kunne ikke andet. Nu er der en knap, valget huskes, og
skærmbilledet øverst skifter med — der ligger både en mørk og en lys udgave af
det samme, og det er kejtet at vise et mørkt skærmbillede på en lys side.

Farverne står to gange med vilje. Mediaforespørgslen er hvad en førstegangsbesøgende
får; attributten er hvad en, der har valgt, får — og den skal vinde begge veje, både
et valgt lyst tema på et mørkt system og omvendt. Én regel kan ikke gøre begge dele.

Begge skærmbilleder ligger i markuppen og skjules med CSS, så ingen af dem skal
hentes i det øjeblik nogen trykker på knappen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:36:11 +02:00
Kristian WindandClaude Opus 5 86ec0ce2d9 fix(imap): markøren er vores, ikke serverens
Samme mail blev en opgave hver tiende minut, og markøren stod stille på 1271.
Målt i produktion: en manuel synk lavede én opgave og flyttede ikke last_uid en
eneste plads.

Årsagen er ikke vores regnestykke, men hvad søgningen svarer. Vi beder om
1272:* — og en rigtig server læser det som 0:1272 og sender hele postkassen
tilbage. Alt, hvad den finder, ligger så under markøren, og markøren kan derfor
aldrig komme videre.

Uid'er ved eller under markøren frasorteres nu hos os. Det koster ét gennemløb
af nogle hundrede tal og afhænger ikke af nogen andens læsning af specifikationen.

De to tidligere forsøg var ikke forkerte, de var bare ikke årsagen: at tage
uid'en fra søgningen frem for fra fetch-svaret, og at give hver postkasse sin
egen lås, var begge reelle fejl. Denne er den, der fik den til at gentage sig.

Prøven læser en postkasse med markøren over alt i den og kræver nul tilbage —
og gentager det for hver enkelt besked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:33:30 +02:00
Kristian WindandClaude Opus 5 62498c2765 feat(sidebar): tallet ved et projekt er opgaver, og folden har en tast
"Hvorfor står der 2 ud for et tomt projekt?" Fordi tallet var member_count — hvor
mange mennesker. Den samme grå kolonne betød tre ting: personer ved projekter,
projekter ved grupper, opgaver ved etiketter. I en opgaveapp læses et tal ved
siden af et projekt som opgaver, og det gjorde manden, der har skrevet appen.

Nu er det åbne opgaver, og det vises når der er noget tilbage frem for når
projektet er delt — som var den gamle betingelse, og grunden til at netop de tre
delte havde et tal. Hvem der ellers er med står på projektets egen side.

Tællingen hentes fra serveren igen, når opgaver flytter sig. Det ville være
hurtigere at lægge til og trække fra lokalt, og det ville være forkert inden for
en dag: en opgave flyttet mellem projekter, en lukket i en anden fane, en
gentagende der lukker og åbner sig selv.

Og sidebjælken kunne allerede foldes — der var bare kun en knap med et rektangel
med en streg i, og det er ikke et ord. Nu gør ⌘B det samme, knappen siger det, og
genvejssiden nævner det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:28:43 +02:00
Kristian WindandClaude Opus 5 997dffe01c docs: skriv det, der blev bygget, og ret det, der ikke passer længere
To nye sider. Postkasser: at stjerne i Gmail eller flage i iCloud og Fastmail
bliver til opgaver, at hver postkasse hører til den, der forbandt den, at
app-kodeord er noget andet end ens rigtige, og at Office 365 ikke kan nås ad den
vej — sidstnævnte skrevet ned frem for at blive opdaget ved at prøve. Og at logge
ind: kodeord, totrinskode og passkeys, med hvorfor en passkey ikke kan laves på
en bar IP-adresse.

Konfigurationen manglede VERDANDE_SECRET_KEY, som er den, det koster mest ikke at
vide noget om: mister man nøglen, skal postkasserne forbindes forfra. Begrundelsen
står med — det er kopierne, ikke serveren, der er problemet, for de kan hentes
gennem fladen.

Og genvejsafsnittet i quick-add beskrev q, t og u, som ikke findes længere. En
dokumentation, der lover taster, der ikke gør noget, er værre end ingen: man
prøver, der sker intet, og så tror man appen er i stykker.

Bygget med --strict; alle krydshenvisninger rammer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:25:54 +02:00
Kristian WindandClaude Opus 5 d02d8edfe9 feat(site): klik et skærmbillede og se det i læsbar størrelse
Release / Build and publish image (push) Failing after 10m5s
Et skærmbillede af en brugerflade siger ingenting ved 281 pixels, og det er der,
de fleste af dem sidder i gitteret. Et klik åbner nu det samme billede omkring
tre en halv gang større.

<dialog> frem for et selvbygget lag: Escape lukker, fokus bliver inde i den,
resten af siden bliver inert — tre ting browseren gør gratis og som et selvbygget
overlay skal have kode for hver af. Billedet får rolle og tabindex, så det også
kan åbnes fra tastaturet; en kontrol, kun en mus kan nå, er ikke en kontrol alle
har.

To ting rettet undervejs, som først kunne ses ved at kigge: billedet fik lov at
blive højere end skærmen, så billedtekst og lukkeknap røg op over kanten — og
lukkeknappen er den synlige vej ud. Og dialogen fyldte hele bredden i stedet for
at slutte om billedet, så skærmbilledet stod op ad venstre kant med knappen ude i
det modsatte hjørne. Nu er den fit-content og centreret, og alt er inden for
vinduet ned til en telefonbredde.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:14:13 +02:00
Kristian WindandClaude Opus 5 134fe8918e chore: hold overleveringen uden for repoet
Den navngiver panelet, server-id'et og produktionsadressen — et kort over én
persons opsætning, og det hører ikke til i et offentligt repo. Ingen adgangskoder,
men et server-id og en panel-adresse er præcis det, et lækket token skal bruge for
at være farligt.

Filen er fjernet fra hele historikken, ikke kun fra spidsen: at slette den i en ny
commit ville have efterladt den læselig i alle de gamle. Den ligger stadig på
disken, for det er sådan en session afleverer til den næste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:08:08 +02:00
Kristian WindandClaude Opus 5 3fd16f71df fix(reminders): en påmindelse, ingen modtager, er ikke en påmindelse
Påmindelser blev sendt i appen og på mail. Har man fanen lukket og ingen
mailserver sat op — hvilket er præcis det, docker-linjen i dokumentationen giver
— kom der ingenting, mens indstillingssiden sagde at notifikationer var slået til
for enheden.

Push-halvdelen var aldrig hægtet på. Notifikationer fra andres handlinger gik ud;
kun de tidsstyrede gjorde ikke, og det er dem, man har slået til for at slippe for
at holde øje selv. Jobbet har nu en vej til at sende, leveret af HTTP-laget, som
ejer VAPID-nøglerne og listen over enheder — samme mønster som synkroniseringerne.

Prøven ser efter, at den når frem, og at den kun når frem én gang: en påmindelse,
der ankommer ved hver fejning, er værre end en, der ikke ankommer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:00:48 +02:00
Kristian WindandClaude Opus 5 37605ba684 feat(site): landingssiden siger hvad der findes, og vejer en femtedel
Skærmbillederne lå som PNG i fuld skærmopløsning — 2,1 MB for syv billeder på en
side, der viser dem under 900 pixels bred. Som WebP i dobbelt visningsbredde
fylder de 424 kB. Samme billeder, samme skarphed, en femtedel af vægten.

Indholdet fangede aldrig op på det, der blev bygget: postkasser over IMAP ved
siden af Gmail, passkeys, at man kan skrive hvor som helst for at fange noget, at
hemmeligheder ligger krypteret med nøglen uden for databasen, og historikken.
Ni felter nu, hvilket også fylder gitteret ud i stedet for at efterlade et tomt.

Og en fejl, jeg selv lavede undervejs: jeg lagde en description-meta ind, fordi
min søgning ikke så den, der lå i forvejen — den var delt over flere linjer.
Dubletten er væk, og den oprindelige er opdateret i stedet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:52:20 +02:00
Kristian WindandClaude Opus 5 6e941f9928 fix(mailboxes): to kørsler af samme postkasse må ikke læse den samme markør
Fyrre kopier af én mail ankom i samme sekund. Én kørsel kan højst lave femogtyve,
så der var to i gang: et tryk på "Hent nu" mens ti-minutters-fejningen allerede
var inde i den samme postkasse. Begge læste markøren som den stod, begge hentede
de samme beskeder, begge lavede opgaver ud af dem.

Markør-rettelsen fra i går var altså rigtig, men ikke nok — den virker, når der
kun er én læser. Rækken læses nu igen under låsen, og låsen holdes hele vejen
igennem: slap man den, før markøren var skrevet, ville hullet stå åbent præcis
der, hvor det skulle lukkes.

Et kort pr. postkasse frem for én lås. To menneskers postkasser har intet at sige
hinanden, og én fælles lås ville gøre én langsom vært til alles problem.

Prøven kører to kørsler oven i hinanden med vilje og tæller, hvor mange der er
inde ad gangen — en lås er kun værd at have, hvis noget prøver døren. Den holder
også øje med, at to forskellige postkasser ikke venter på hinanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:57:59 +02:00
Kristian WindandClaude Opus 5 993822f6fd fix(panel): bed om en planlagt genstart, ikke en der dør med afsenderen
Release / Build and publish image (push) Failing after 10m6s
Knappen tog produktionen ned og lod den ligge. Panelets almindelige restart
udfører arbejdet mens forespørgslen står åben: den stopper containeren — hvilket
slår denne proces ihjel — og panelet, der ser afsenderen forsvinde, når aldrig
til den halvdel, der starter igen. Fire minutters nedetid den 18. august, og det
kan ikke lappes herfra: enhver knap, der synkront beder om sin egen død, har
samme form.

safe-restart planlægges i stedet. Panelet svarer "Restart scheduled" og gør
arbejdet bagefter for egen regning, så intet afhænger af, at denne proces stadig
er i live til at høre svaret.

Prøven hang bagefter, og det var prøvens skyld: dens falske panel læste aldrig
forespørgslens krop, og der er en nu. En server, der ikke læser hvad den fik,
opdager aldrig at afsenderen har lagt på — så httptest ventede på en handler, der
aldrig vendte tilbage, og hele binæren løb tør for tid i stedet for den ene
forespørgsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 23:15:10 +02:00
Kristian WindandClaude Opus 5 b0f78d1549 feat(gmail): Gmail flytter ind i postkasse-tabellen
Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser,
etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af
dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og
skifter vej efter rækkens kind.

PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind,
ikke til en postkasse, og de skal væk igen når forsøget er ovre.

Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en
arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have.
Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op
med at gælde, ikke afvise sletningen eller tage postkassen med sig.

Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget
i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet
koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med
samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller
ikke har noget at gøre med at holde.

Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet
token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med
token, udløser, adresse, målprojekt og set-liste i behold.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:59:40 +02:00
Kristian WindandClaude Opus 5 f3fecbd0b4 fix(imap): samme mail blev en ny opgave hver gang, og uddraget var rå MIME
To fejl fra ét skærmbillede.

Markøren stod stille. Den blev sat ud fra uid'en i fetch-svaret, og den kommer
ikke fra alle servere — så next-run læste fra nul hver gang. Den sættes nu ud fra
hvad SØGNINGEN fandt, når kørslen kom hele vejen igennem: så er alt op til det
punkt betragtet, uanset hvad fetch-svaret bar med. En afbrudt kørsel flytter kun
til den sidste, den faktisk lavede en opgave af. Prøven mod hukommelsesserveren
ville aldrig have fanget det, for den svarer altid med uid.

Og uddraget hentede BODY[TEXT], som ved multipart er grænser og dele-hoveder og
det hele — derfor stod der "--=_be71cb39… Content-Transfer-Encoding: 8bit" foran
teksten i hver eneste opgave. Nu hentes hele beskeden og MIME-strukturen gås
igennem: text/plain foretrækkes, HTML er reserven med mærkerne pillet af. Groft,
for det er linjen under en overskrift og ikke en gengivelsesmotor.

Multipart-prøven er sat på prøve: med den gamle hentning fejler den med præcis
den tekst, skærmbilledet viste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:44:00 +02:00
Kristian WindandClaude Opus 5 30adc69cb5 feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en
migration, der tømte den tabel før de var skrevet om, ville koble en fungerende
postkasse fra. Det additive først; flytningen når koden på den anden side er klar
til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret,
så den ikke kan køre ved et uheld.

Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon.
En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser
forkert senere — og en etiket med kolon i navnet ville gøre det på første dag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:38:45 +02:00
71 changed files with 3124 additions and 214 deletions
+6
View File
@@ -60,3 +60,9 @@ node_modules.nosync/
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
# it right back.
/shots
# The working handover. It names the panel, the server id and the production
# address — a map of one person's setup, which does not belong in a public repo.
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
+1
View File
@@ -75,6 +75,7 @@ func run() error {
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.SyncMailbox = api.SyncMailbox
runner.Push = api.PushToUser
runner.Start(jobCtx)
srv := &http.Server{
+36 -2
View File
@@ -55,6 +55,36 @@ One registration serves every user on the instance; each person authorises their
own mailbox from **Settings → Gmail**. The redirect URI is derived from
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
## Secrets
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SECRET_KEY` | — | Encrypts the mail tokens and mailbox passwords in the database. 32 bytes of base64. |
Mail tokens and [mailbox](mailboxes.md) passwords are encrypted at rest, and the
key does not live in the database. That is the whole point: **backups can be
downloaded through the interface**, so a copy that ends up somewhere it should
not would otherwise open your mail.
Leave the variable empty and verdande writes a key to `secret.key` in the data
directory on first start, mode `0600`. Backups are a `VACUUM INTO` of the
database alone, so the key file is not in them.
Generate one yourself if you would rather keep it out of the volume entirely:
```
openssl rand -base64 32
```
!!! warning "Lose the key and the mailboxes must be reconnected"
Nothing else is lost — tasks, projects, files and history are not encrypted —
but a token that cannot be decrypted cannot be used, and there is no way back
from a key that is gone. If you restore a database somewhere new, bring
`secret.key` with it, or set `VERDANDE_SECRET_KEY` to the same value.
A database written before there was a key is read as it stands and encrypted the
next time each value is written, so turning this on costs nobody a reconnection.
## Update checks
| Variable | Default | What it does |
@@ -64,9 +94,13 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
All three together let **Indstillinger → Notifikationer** restart this instance,
All three together let **Settings → Notifications** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
image, so the panel recreates it and pulls `:latest` on the way. The request asks
the panel for a *scheduled* restart rather than an immediate one — an immediate
one is carried out while the request is still open, and stopping the container
kills the very process waiting for the answer, so the panel never gets to the
half that starts it again. Any one of the
three alone does nothing, and the page names the ones that are missing.
The token is a panel credential with control over that server. Anybody who can
+87
View File
@@ -0,0 +1,87 @@
# Mailboxes
Star a mail and it becomes a task.
This is the other direction from [mail to task](mail.md): instead of forwarding
something, Verdande reads a mailbox you connect and makes a task out of anything
you have flagged. Nothing else in the mailbox is touched, and nothing is ever
sent.
Each mailbox belongs to the person who connected it. Two people on one instance
connect their own and see only their own — there is no shared inbox and no
administrator who can read yours.
## Gmail
**Settings → Integrations → Gmail → Connect.**
Gmail needs an OAuth client, which is the instance's registration with Google
rather than anybody's account — see [Configuration](configuration.md#gmail).
Once it is set up, each person connects their own mailbox through it.
Choose what makes a task:
| Trigger | What it reads |
|---|---|
| Starred | Anything you have starred |
| Label | Anything carrying a label you name |
!!! note "The app stays Internal if your client is Internal"
An OAuth client registered as *Internal* in a Google Workspace can only be
used by accounts in that workspace. A personal `@gmail.com` address will be
refused with `org_internal`, and that is the client working as configured.
Switching to *External* means refresh tokens that expire every seven days
until Google has verified the app — for one personal account, forwarding to
your [mail-to-task address](mail.md) is the shorter road.
## iCloud, Fastmail and anything else over IMAP
**Settings → Integrations → Mailboxes → Add a mailbox.**
You need three things:
| Field | Example |
|---|---|
| IMAP server | `imap.mail.me.com:993` |
| Username | your address |
| App password | not your ordinary password — see below |
The connection is tested before it is saved. A mailbox that cannot be read is
refused while you are still looking at the form, rather than accepted and then
failing quietly every ten minutes.
Flag a message — the flag, not unread — and it becomes a task on the next sweep.
Unread is a state you are still using yourself, and a sync that consumed it would
be taking something away.
### App passwords
Every host worth naming issues a separate password for applications, which you
can withdraw on its own without changing the one you sign in with. Use that.
- **iCloud** — [appleid.apple.com](https://appleid.apple.com) → Sign-In and
Security → App-Specific Passwords
- **Fastmail** — Settings → Privacy & Security → Integrations → New app password
The password is [encrypted at rest](configuration.md#secrets), and it is never
returned by the API once saved.
### Office 365 does not work here
Microsoft has closed IMAP to passwords; it wants its own OAuth flow. There is no
app password that will get you in, and a mailbox pointed at `outlook.office365.com`
will be refused. Forward to your [mail-to-task address](mail.md) instead.
## How often
Every ten minutes, in the background. **Fetch now** does it immediately, which is
for when you have just connected something and want to see it work.
A run reads at most twenty-five messages, so a mailbox with years of flagged mail
in it fills up over several sweeps rather than in one.
## Disconnecting
**Disconnect** on the mailbox. The tasks it made stay — they are your work now,
not the mailbox's. Nothing is read after that, and the stored credential is
deleted rather than merely disabled.
+138
View File
@@ -2477,6 +2477,116 @@ paths:
created: { type: integer }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/notes:
get:
tags: [notes]
summary: Notes — listed, narrowed to a project, or searched.
description: >
With no parameters: the notes filed under no project, which are the
person's own. With `project`: that project's, which needs read access to
it. With `q`: a full-text search across every note they can see, matching
Danish spelling either way round — "grøn" finds "gron" and the reverse.
parameters:
- name: q
in: query
schema: { type: string }
- name: project
in: query
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
notes:
type: array
items: { $ref: "#/components/schemas/Note" }
post:
tags: [notes]
summary: Write a note.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NoteInput" }
responses:
"201":
description: Written
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/{noteID}:
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
get:
tags: [notes]
summary: One note.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [notes]
summary: Change a note. Every field is optional; what is sent is what changes.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NoteInput" }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [notes]
summary: Put a note in the trash. It can be brought back.
responses:
"204": { description: Deleted }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/linking/{kind}/{targetID}:
get:
tags: [notes]
summary: What has been written about this.
description: >
The backwards direction. A task's id gives every note that mentions it,
a project's name gives every note carrying that #tag.
parameters:
- name: kind
in: path
required: true
schema: { type: string, enum: [task, note, project] }
- name: targetID
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
notes:
type: array
items: { $ref: "#/components/schemas/Note" }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/mailboxes:
get:
tags: [integrations]
@@ -3162,6 +3272,34 @@ components:
items: { type: string }
schemas:
Note:
type: object
description: A page of Markdown. The body is the stored form and the exported form.
properties:
id: { type: string }
project_id: { type: string }
title: { type: string, description: "Taken from the first line when not given." }
body: { type: string, description: Markdown }
pinned: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
links:
type: array
description: What the body points at, read out of the text on the way in.
items:
type: object
properties:
kind: { type: string, enum: [task, note, project] }
target_id: { type: string }
NoteInput:
type: object
properties:
project_id: { type: string, description: "Empty files it under no project." }
title: { type: string }
body: { type: string }
pinned: { type: boolean }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
+18 -3
View File
@@ -70,7 +70,22 @@ an error message and a lost thought.
write it, and guessing month-first would silently move a date by months rather
than failing visibly.
## The shortcuts
## Just start typing
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
There is no shortcut to remember. Press a letter anywhere in the app and you are
writing a task, with the letter you pressed already in the field. The thought does
not have to survive a journey to find somewhere to put it.
It only fires when nothing else has focus — a key pressed at a field, a button or
a link belongs to that thing.
Under the field is a line saying what the parser reads: `#` project, `/` section,
`@` label, `p1` priority, and an example date. It appears while you are typing,
which is the one moment a placeholder cannot help you.
++cmd+k++ opens the palette — search, and navigation to anywhere in the app.
!!! note "The single letters used to navigate"
++t++ for Today and ++u++ for Upcoming are gone. One keyboard cannot both
spell *tal med Anders* and jump to Today on the same ++t++, and capturing is
the thing done fifty times a day. The palette does the rest.
+39
View File
@@ -0,0 +1,39 @@
# Signing in
Three ways in, and you can have all of them at once.
## A password
What the first account is created with. Nothing is emailed, nothing is registered
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
app reads the QR code. Once it is on, the password alone is not enough.
Recovery codes are shown once, when you turn it on. They are the way back if the
phone is gone, and they are not shown again.
## A passkey
**Settings → Account → Passkeys → Add a passkey.**
A key on a device instead of a secret in your head. The private half never leaves
the device — a phone's secure element, a laptop's keychain, a hardware key — and
the signature is bound to the address it was made for, so a convincing copy of the
sign-in page at a different address gets nothing.
Give each one a name you will recognise later. *min bærbare* is what makes a list
of keys reviewable, and a list nobody can read is a list nobody revokes from.
Passkeys need the address in `VERDANDE_BASE_URL` to be a hostname. Browsers refuse
to make one for a bare IP address, so an instance reached at `http://192.168.1.10:8080`
will show the passkey section as unavailable rather than failing at the moment
somebody tries.
!!! note "One is enough, two is better"
Lose the only device holding your only passkey and you are back to the
password. Keep the password, or register a second key — a phone as well as a
laptop.
+54 -47
View File
@@ -179,15 +179,27 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
delete(settings, "pkce_state")
delete(settings, "pkce_expires")
settings["refresh_token"] = token.RefreshToken
settings["access_token"] = token.AccessToken
settings["expires_at"] = token.ExpiresAt.Unix()
settings["email"] = email
if _, ok := settings["trigger"]; !ok {
settings["trigger"] = "starred"
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, r, "clear gmail handshake", err)
return
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
// The mailbox itself, kept beside the ones read over IMAP. Connecting again
// writes the same row: Gmail is one per person, because the OAuth flow is.
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "read gmail mailbox", err)
return
}
if box == nil {
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
}
box.Name = email
box.Username = email
box.RefreshToken = token.RefreshToken
box.AccessToken = token.AccessToken
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail tokens", err)
return
}
@@ -240,36 +252,40 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// Returns how many were made. Also used by the background job, which is why it
// takes a context and a user rather than a request.
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
if err != nil {
return 0, err
}
str := func(key string) string {
v, _ := settings[key].(string)
return v
if box != nil {
// The same guard as the mailboxes read over IMAP, for the same reason: a
// press of "Fetch now" during the sweep would otherwise make two runs from
// one marker.
unlock := s.lockMailbox(box.ID)
defer unlock()
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
box = fresh
}
}
refresh := str("refresh_token")
if refresh == "" {
if box == nil || box.RefreshToken == "" {
return 0, nil // not connected; nothing to do and nothing to complain about
}
query := gmail.Query(str("trigger"), str("label"))
query := gmail.Query(box.Trigger, box.Label)
if query == "" {
return 0, nil // connected but no trigger chosen
}
refresh := box.RefreshToken
// The access token is refreshed only when it has actually expired.
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
if err != nil {
return 0, err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(ctx, box); err != nil {
return 0, err
}
}
@@ -281,7 +297,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// The person revoked access, or changed their password. Forget the
// tokens rather than retrying every ten minutes forever.
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
}
return 0, err
}
@@ -290,19 +306,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// rather than as a table: it is bounded by the query's own 30-day window, and
// a table would be one more thing to migrate for a feature this small.
seen := map[string]bool{}
if raw, ok := settings["seen"].([]any); ok {
for _, v := range raw {
if id, ok := v.(string); ok {
seen[id] = true
}
}
for _, id := range box.Seen {
seen[id] = true
}
inbox, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
projectID := str("project_id")
projectID := box.ProjectID
if projectID == "" {
projectID = inbox
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
@@ -352,15 +364,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if created > 0 {
// Only the ids Gmail still returns are kept, so the list cannot grow
// without bound as messages fall out of the 30-day window.
keep := make([]any, 0, len(ids))
keep := make([]string, 0, len(ids))
for _, id := range ids {
if seen[id] {
keep = append(keep, id)
}
}
settings["seen"] = keep
settings["last_sync"] = time.Now().Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.Seen = keep
box.LastSyncAt = time.Now()
if err := s.db.SaveMailbox(ctx, box); err != nil {
return created, err
}
}
@@ -483,30 +495,25 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
// natural place to learn the address, and it is also the one moment that cannot be
// retried — so the status endpoint asks again when it finds nothing, and the answer
// corrects itself on the next look.
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
str := func(key string) string {
v, _ := settings[key].(string)
return v
}
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
if err != nil {
return "", err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
}
email, err := s.gmailClient(access).Profile(ctx)
if err != nil {
return "", err
}
settings["email"] = email
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.Name = email
box.Username = email
if err := s.db.SaveMailbox(ctx, box); err != nil {
return "", err
}
return email, nil
+6 -5
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"net/http"
"net/http/httptest"
"net/url"
@@ -38,8 +39,8 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
t.Fatal(err)
}
// Connected, with a refresh token that will be refused.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
"refresh_token": "spent", "trigger": "starred",
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
}); err != nil {
t.Fatal(err)
}
@@ -111,9 +112,9 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
}
// A token that has not expired, so the run goes straight to the message list
// rather than stopping at a refresh.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
"refresh_token": "r", "access_token": "a", "trigger": "starred",
"expires_at": time.Now().Add(time.Hour).Unix(),
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
+42 -26
View File
@@ -370,27 +370,28 @@ type gmailSettings struct {
}
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
str := func(key string) string {
v, _ := values[key].(string)
return v
if box == nil {
writeJSON(w, http.StatusOK, gmailSettings{})
return
}
// The mailbox address, fetched again if it is missing.
//
// It is stored at connect time, and the call that fetches it can fail — a
// slow token, a scope Google had not applied yet. That failure used to be a
// log line and an empty string, so the page said "an unknown account" and went
// on saying it forever: the only way back was to disconnect and reconnect,
// which nobody would guess. Asking again here costs one request on a page
// nobody opens often, and it means the wrong answer lasts until the next look
// rather than until somebody reconnects.
email := str("email")
if email == "" && str("refresh_token") != "" {
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
// It is stored at connect time, and the call that fetches it can fail — a slow
// token, a scope Google had not applied yet. That failure used to be a log line
// and an empty string, so the page said "an unknown account" and went on saying
// it forever: the only way back was to disconnect and reconnect, which nobody
// would guess. Asking again here costs one request on a page nobody opens
// often, and it means the wrong answer lasts until the next look.
email := box.Username
if email == "" && box.RefreshToken != "" {
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
email = fetched
} else {
s.log.Warn("gmail profile", "err", err)
@@ -398,10 +399,10 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, gmailSettings{
Connected: str("refresh_token") != "",
Connected: box.RefreshToken != "",
Email: email,
Trigger: str("trigger"),
Label: str("label"),
Trigger: box.Trigger,
Label: box.Label,
})
}
@@ -412,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
}
user := userFrom(r.Context())
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
values["trigger"] = req.Trigger
values["label"] = req.Label
if box == nil {
// Choosing what makes a task before connecting is allowed, and the choice is
// kept: the row is the mailbox with no credential in it yet, which reads as
// not connected everywhere that asks and is skipped by the sweep.
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
}
box.Trigger = req.Trigger
box.Label = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDisconnectGmail forgets the connection, tokens and all.
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
// stay: they are the person's work now, not the mailbox's.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
s.internal(w, r, "disconnect gmail", err)
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box != nil {
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
s.internal(w, r, "disconnect gmail", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
+30 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -139,6 +140,18 @@ func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
// Exported because the background job calls it too, on the same terms and without
// a budget: nobody is waiting there.
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
// One reader of this mailbox at a time. Held for the whole run, marker and all:
// releasing before the marker is written would leave exactly the gap this is
// here to close.
unlock := s.lockMailbox(m.ID)
defer unlock()
// Re-read under the lock. The row that came in may have been fetched before the
// run that just finished moved the marker.
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
m = fresh
}
client, err := imap.Dial(imap.Account{
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
})
@@ -147,7 +160,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
}
defer client.Close()
messages, err := client.Since(m.LastUID, 25)
messages, searched, err := client.Since(m.LastUID, 25)
if err != nil {
return 0, err
}
@@ -197,6 +210,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// A run that got all the way through moves the marker to the whole of what the
// search considered, not merely to the last message it happened to read. That
// is what stops the same mail becoming a task again on the next run when the
// server did not put a uid in the fetch reply.
if ctx.Err() == nil && searched > highest {
highest = searched
}
// Written even when the loop was cut short, and outside the request's context
// so a cancelled request still records what it did.
if highest > m.LastUID {
@@ -209,3 +230,11 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
}
return created, nil
}
// lockMailbox serialises one mailbox against itself and returns the release.
func (s *Server) lockMailbox(id string) func() {
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
+212
View File
@@ -0,0 +1,212 @@
package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Notes.
//
// Access follows the project, because that is where a note lives: a note in a
// shared project is readable by everybody who can read the project and writable
// by an editor. A note with no project is its author's alone — the same answer a
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
// mayReadNote reports whether this person may see the note, and mayWriteNote
// whether they may change it. Split, because a viewer on a shared project can do
// the first and not the second.
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
user := userFrom(r.Context())
if n.ProjectID == "" {
return n.CreatedBy == user.ID
}
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
return err == nil
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
// Searching and listing are the same endpoint, because they answer the same
// question with different words. ?q= searches; ?project= narrows; neither gives
// the loose ones.
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
found, err := s.db.SearchNotes(r.Context(), q, 50)
if err != nil {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
return
}
if projectID := r.URL.Query().Get("project"); projectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
s.storeError(w, r, "notes in project", err)
return
}
found, err := s.db.NotesInProject(r.Context(), projectID)
if err != nil {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
return
}
found, err := s.db.LooseNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
}
// readable drops what this person may not see.
//
// Filtered after the search rather than inside it: FTS5 answers about text, and
// teaching the index about roles would put an access rule in a place nobody would
// think to look when it was wrong.
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
out := make([]store.Note, 0, len(notes))
for _, n := range notes {
if s.mayTouchNote(r, &n, store.RoleViewer) {
out = append(out, n)
}
}
return out
}
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
// The same answer for "no such note" and "not yours", so an id cannot be probed.
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
writeJSON(w, http.StatusOK, n)
}
type noteBody struct {
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
return
}
user := userFrom(r.Context())
n := &store.Note{CreatedBy: user.ID}
if body.ProjectID != nil && *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
s.storeError(w, r, "create note", err)
return
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
if body.Pinned != nil {
n.Pinned = *body.Pinned
}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "create note", err)
return
}
writeJSON(w, http.StatusCreated, n)
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "update note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if body.ProjectID != nil {
// Moving it means being allowed on both sides: you cannot file somebody
// else's note into your project, nor yours into theirs.
if *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
s.storeError(w, r, "move note", err)
return
}
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
if body.Pinned != nil {
n.Pinned = *body.Pinned
}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "update note", err)
return
}
writeJSON(w, http.StatusOK, n)
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "delete note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
s.internal(w, r, "delete note", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleNotesLinking answers the backwards question for a task or a project:
// what has somebody written about this.
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
if kind != "task" && kind != "project" && kind != "note" {
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
return
}
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
if err != nil {
s.internal(w, r, "notes linking", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
}
+115
View File
@@ -0,0 +1,115 @@
package httpapi
import (
"net/http"
"testing"
)
// A note in a project is the project's; a note in no project is its author's.
// Both answers come from the project's own roles rather than from a rule notes
// invented for themselves, which is the point of filing them there.
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Noget privat om #Firma",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
id, _ := body["id"].(string)
if id == "" {
t.Fatal("no id came back")
}
// The tags were read out of the text on the way in, without being asked for.
links, _ := body["links"].([]any)
if len(links) != 1 {
t.Fatalf("the note recorded %v", body["links"])
}
// Somebody else gets the same answer as for a note that does not exist, so an
// id cannot be probed for existence.
other := ts.newUser(t, "[email protected]", "Anden")
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
}
}
// Searching must not become a way to read what you cannot open. The filter is
// applied after the index, and this is what says so.
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Hemmeligt kodeord til hjemmesiden",
}); resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(mine["notes"].([]any)); got != 1 {
t.Fatalf("the author found %d of their own notes", got)
}
other := ts.newUser(t, "[email protected]", "Anden")
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(theirs["notes"].([]any)); got != 0 {
t.Errorf("a stranger's search returned %d notes", got)
}
}
// The backwards question, which is what makes notes worth linking at all.
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til Anders",
})
taskID, _ := task["id"].(string)
if taskID == "" {
t.Fatalf("no task id: %v", task)
}
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
}); resp.StatusCode != http.StatusCreated {
t.Fatal("the note was not written")
}
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
notes, _ := found["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("%d notes point at the task, want 1", len(notes))
}
}
// Editing the text moves the links with it. A link that outlives its mention
// claims a connection that is not there.
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
id, _ := note["id"].(string)
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if len(before["notes"].([]any)) != 1 {
t.Fatal("the first save recorded nothing")
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
"body": "Om #Regnskab i stedet",
}); resp.StatusCode != http.StatusOK {
t.Fatal("the note was not changed")
}
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if got := len(after["notes"].([]any)); got != 0 {
t.Errorf("%d notes still point at Firma", got)
}
}
@@ -98,6 +98,12 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// PushToUser is pushToUser for callers outside this package — the reminder job,
// which has the schedule but not the keys.
func (s *Server) PushToUser(userID, title, body, projectID string) {
s.pushToUser(userID, title, body, projectID)
}
func (s *Server) pushToUser(userID, title, body, projectID string) {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
+16 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
@@ -72,13 +73,26 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
// safe-restart rather than restart, and the difference is not politeness.
//
// The plain endpoint does its work while the request is open: it stops the
// container — which kills this process — and the panel, seeing the caller
// vanish, never gets to the starting half. That took production down and left
// it down for four minutes on 18 August, and it cannot be fixed from this side:
// any button that synchronously asks for its own death has the same shape.
//
// safe-restart is scheduled. The panel answers "Restart scheduled" and does the
// work on its own afterwards, so nothing depends on this process still being
// alive to hear the reply.
url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID)
body := strings.NewReader(`{"backup_first":false,"target_id":""}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
s.internal(w, r, "build panel request", err)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
+74
View File
@@ -1,9 +1,11 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -15,6 +17,11 @@ import (
// request has to end either way, or whatever sits in front answers for it.
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Drained first. A real server reads what it was sent, and one that does not
// never learns the caller has hung up — httptest.Server.Close then waits for
// a handler that will never return, and the whole binary times out instead
// of the one request.
_, _ = io.Copy(io.Discard, r.Body)
<-r.Context().Done()
}))
defer panel.Close()
@@ -56,6 +63,7 @@ func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
// And a panel that says no must say so in this server's own words, not in a proxy's.
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusForbidden)
}))
defer panel.Close()
@@ -75,3 +83,69 @@ func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
t.Errorf("the reply does not say what the panel answered: %v", body)
}
}
// Forty copies of one mail arrived in a single second because two runs read the
// same marker before either wrote a new one — somebody pressing "Fetch now" while
// the ten-minute sweep was already inside the same mailbox.
//
// The lock is what stops it, and a lock is only worth having if something tries
// the door: this runs the two concurrently on purpose.
func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
// A bare server: the lock needs nothing but its own zero value, and building a
// database and a session around it would only make the test slower to read.
srv := &Server{}
var inside, most int
var mu sync.Mutex
release := make(chan struct{})
// Stands in for a run: takes the lock, notes how many are inside, and holds it
// until told to let go.
run := func(done chan<- struct{}) {
unlock := srv.lockMailbox("box-1")
mu.Lock()
inside++
if inside > most {
most = inside
}
mu.Unlock()
<-release
mu.Lock()
inside--
mu.Unlock()
unlock()
done <- struct{}{}
}
first, second := make(chan struct{}), make(chan struct{})
go run(first)
go run(second)
// Long enough that both would be inside if nothing kept them apart.
time.Sleep(100 * time.Millisecond)
close(release)
<-first
<-second
if most != 1 {
t.Errorf("%d runs were inside the same mailbox at once; that is how one mail "+
"becomes forty tasks", most)
}
// And two different mailboxes must not wait for each other: one slow host is
// not everybody's problem.
a := srv.lockMailbox("box-a")
done := make(chan struct{})
go func() {
srv.lockMailbox("box-b")()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("a second mailbox waited for the first")
}
a()
}
+2 -1
View File
@@ -23,6 +23,7 @@ type projectJSON struct {
SortOrder float64 `json:"sort_order"`
Role string `json:"role"`
MemberCount int `json:"member_count"`
OpenCount int `json:"open_count"`
Shared bool `json:"shared"`
// GroupID is the sidebar group the asking user has filed it under, empty for
// none. Always empty on a project shared with them: grouping is a column on
@@ -34,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
return projectJSON{
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount,
Shared: p.MemberCount > 1, GroupID: p.GroupID,
}
}
+25
View File
@@ -11,6 +11,7 @@ import (
"os"
"path"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -40,6 +41,18 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
// syncing serialises reads of one mailbox against itself. Two runs at once —
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
// the same mailbox — both start from the marker as it was, both fetch the same
// messages, and both make tasks out of them. That is how forty copies of one
// mail arrived in a single second.
//
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
// say to each other, and a single lock would make one slow host everybody's
// problem. Never cleaned out, because the key is a mailbox id and there are as
// many of those as there are mailboxes.
syncing sync.Map
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
@@ -263,6 +276,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
// belongs to the person who connected it, so there is no admin gate
// here and no shared registration: the credential is theirs.
// Notes. Listing and searching are the same endpoint because they answer
// the same question with different words; the backwards one — what has
// been written about this task — hangs off the thing being asked about.
r.Route("/notes", func(r chi.Router) {
r.Get("/", s.handleListNotes)
r.Post("/", s.handleCreateNote)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
})
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", s.handleListMailboxes)
r.Post("/", s.handleAddMailbox)
+135 -22
View File
@@ -16,15 +16,18 @@
package imap
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message"
)
// Message is one mail, reduced to what a task needs.
@@ -108,9 +111,13 @@ func (c *Client) Close() error {
// that consumed it would be taking something away. Flagging a mail is a deliberate
// act with no other meaning, which is exactly what is wanted — the same reason the
// Gmail side reads stars.
func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
// The second return is the highest uid the search considered. A caller that got
// through every message can move its marker to that, and then it does not matter
// whether the server put a uid in each fetch reply — which some do not, and which
// is how the same mail became a task again on every run.
func (c *Client) Since(uid uint32, limit int) ([]Message, uint32, error) {
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return nil, fmt.Errorf("open %s: %w", c.folder, err)
return nil, 0, fmt.Errorf("open %s: %w", c.folder, err)
}
// From uid+1 to the end. A mailbox with nothing new answers with no uids
@@ -124,12 +131,27 @@ func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
}
found, err := c.c.UIDSearch(criteria, nil).Wait()
if err != nil {
return nil, fmt.Errorf("search %s: %w", c.folder, err)
return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
}
uids := found.AllUIDs()
// Anything at or below the marker is dropped here rather than trusted to the
// range in the query. A server that reads `1272:*` as `0:1272` — and one does,
// which is how the same mail became a task every ten minutes with the marker
// standing still at 1271 — hands back the whole mailbox. The marker is ours;
// enforcing it here costs one pass over a list of at most a few hundred
// numbers and does not depend on anybody else's reading of the spec.
kept := make([]imap.UID, 0, len(uids))
for _, u := range uids {
if uint32(u) > uid {
kept = append(kept, u)
}
}
uids = kept
if len(uids) == 0 {
return nil, nil
return nil, uid, nil
}
if limit > 0 && len(uids) > limit {
// The newest ones. A mailbox flagged over years would otherwise make the
@@ -139,29 +161,51 @@ func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
want := imap.UIDSetNum(uids...)
fetch := &imap.FetchOptions{
Envelope: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{{Specifier: imap.PartSpecifierText, Peek: true}},
Envelope: true,
UID: true,
// The whole message, headers included, so the MIME structure can be walked.
// TEXT alone gives the body as bytes — boundaries and part headers and all —
// which is what put "--=_be71cb39… Content-Transfer-Encoding: 8bit" at the
// front of every task made from a multipart mail.
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}
var highest uint32
for _, u := range uids {
if uint32(u) > highest {
highest = uint32(u)
}
}
msgs, err := c.c.Fetch(want, fetch).Collect()
if err != nil {
return nil, fmt.Errorf("fetch from %s: %w", c.folder, err)
return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
}
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
for i, m := range msgs {
if m.Envelope == nil {
continue
}
// The uid comes from the search when the fetch reply does not carry one.
// A missing uid is not cosmetic: it is the marker the next run starts from,
// and a marker stuck at zero makes every message arrive again on every run.
// That is what happened against a real server while the tests stayed green,
// because the server they run against always answers with it.
msgUID := uint32(m.UID)
if msgUID == 0 && i < len(uids) {
msgUID = uint32(uids[i])
}
out = append(out, Message{
UID: uint32(m.UID),
UID: msgUID,
From: address(m.Envelope.From),
Subject: strings.TrimSpace(m.Envelope.Subject),
Snippet: snippet(m),
Date: m.Envelope.Date,
})
}
return out, nil
return out, highest, nil
}
// address renders the first sender as "Name <addr>", or just the address.
@@ -177,23 +221,92 @@ func address(list []imap.Address) string {
return a.Name + " <" + addr + ">"
}
// snippet is the first readable line or so of the body.
// snippet is the first readable text in the mail.
//
// No MIME walk and no HTML stripping yet: this is what shows under a task's title,
// and a wrong-but-short line is a smaller problem than a parser that has to be
// right about every mail ever sent. The full mail stays where it is.
// It walks the MIME structure rather than taking the body as bytes. text/plain is
// preferred and HTML is the fallback with its tags stripped — crudely, because
// this is the line under a task's title and not a rendering engine. The mail
// itself stays where it is.
const snippetLimit = 400
func snippet(m *imapclient.FetchMessageBuffer) string {
for _, body := range m.BodySection {
text := strings.TrimSpace(string(body.Bytes))
if text == "" {
continue
if text := readable(body.Bytes); text != "" {
return cut(text)
}
if len(text) > snippetLimit {
text = text[:snippetLimit] + "…"
}
return text
}
return ""
}
func readable(raw []byte) string {
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
// Not something this parser recognises. The raw bytes read badly but read,
// and a mail is not worth losing over its own headers.
return strings.TrimSpace(string(raw))
}
return strings.TrimSpace(fromEntity(entity, 0))
}
// fromEntity descends into a multipart mail. The depth guard is for the mail that
// nests parts inside parts inside parts — rare, and cheaper to bound than to trust.
func fromEntity(entity *message.Entity, depth int) string {
if depth > 4 {
return ""
}
if parts := entity.MultipartReader(); parts != nil {
var html string
for {
part, err := parts.NextPart()
if err != nil {
break
}
if text := fromEntity(part, depth+1); text != "" {
kind, _, _ := part.Header.ContentType()
if kind != "text/html" {
return text
}
if html == "" {
html = text
}
}
}
return html
}
body, _ := io.ReadAll(entity.Body)
kind, _, _ := entity.Header.ContentType()
if kind == "text/html" {
return stripTags(string(body))
}
return strings.TrimSpace(string(body))
}
// stripTags removes markup without pretending to understand it. Enough for one
// line of preview, and honest about being no more than that.
func stripTags(html string) string {
var b strings.Builder
depth := 0
for _, r := range html {
switch {
case r == '<':
depth++
case r == '>':
if depth > 0 {
depth--
}
case depth == 0:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func cut(text string) string {
runes := []rune(text)
if len(runes) > snippetLimit {
return string(runes[:snippetLimit]) + "…"
}
return text
}
+139 -4
View File
@@ -37,7 +37,7 @@ func TestFlaggedMailComesBackAsMessages(t *testing.T) {
}
defer client.Close()
msgs, err := client.Since(0, 25)
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
@@ -59,7 +59,7 @@ func TestFlaggedMailComesBackAsMessages(t *testing.T) {
// And reading on from a marker returns only what is newer, which is the whole
// of the deduplication.
rest, err := client.Since(msgs[0].UID, 25)
rest, _, err := client.Since(msgs[0].UID, 25)
if err != nil {
t.Fatal(err)
}
@@ -92,6 +92,9 @@ func TestAnUntrustedCertificateIsRefused(t *testing.T) {
type mail struct {
subject, from, body string
flagged bool
// raw replaces the whole message when set, which is the only way to get a
// genuinely multipart one in.
raw string
}
// serveMailbox starts an in-process IMAP server over TLS and returns its address
@@ -112,8 +115,11 @@ func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
if m.flagged {
flags = append(flags, imap.FlagFlagged)
}
raw := "From: " + m.from + "\r\nSubject: " + m.subject +
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
raw := m.raw
if raw == "" {
raw = "From: " + m.from + "\r\nSubject: " + m.subject +
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
}
if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{
Flags: flags,
}); err != nil {
@@ -181,3 +187,132 @@ func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// The bug this was written for: every press of "Fetch now" made the same mail
// into a task again. The marker the next run starts from is the uid, and if it
// comes back as zero the marker never moves.
func TestEveryMessageCarriesAUID(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "En", from: "[email protected]", body: "en", flagged: true},
{subject: "To", from: "[email protected]", body: "to", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.UID == 0 {
t.Fatalf("%q came back without a uid; the marker would never move and "+
"every run would make it into a task again", m.Subject)
}
}
if msgs[0].UID == msgs[1].UID {
t.Error("two messages share a uid")
}
}
// The other half of the same report: the snippet under a task's title read
// "--=_be71cb39… Content-Transfer-Encoding: 8bit Content-Type: text/plain".
func TestAMultipartMailReadsAsItsText(t *testing.T) {
const boundary = "=_be71cb3975bdfbc60a16327cb2f49c23"
body := "This is a multi-part message.\r\n" +
"--" + boundary + "\r\n" +
"Content-Transfer-Encoding: 8bit\r\n" +
"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n" +
"Hej igen,\r\n\r\nVi har normalt 3 slags. :)\r\n" +
"--" + boundary + "\r\n" +
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
"<html><body><p>Hej igen</p></body></html>\r\n" +
"--" + boundary + "--\r\n"
addr, pool := serveMailbox(t, []mail{{
flagged: true,
raw: "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n" +
"From: [email protected]\r\nSubject: Re: Kaffebestilling\r\n" +
"Date: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + body,
}})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages", len(msgs))
}
got := msgs[0].Snippet
for _, junk := range []string{boundary, "Content-Transfer-Encoding", "Content-Type", "<html>", "<p>"} {
if strings.Contains(got, junk) {
t.Errorf("the snippet still carries %q: %q", junk, got)
}
}
if !strings.HasPrefix(got, "Hej igen") {
t.Errorf("the snippet does not start with the text of the mail: %q", got)
}
}
// The marker is ours, not the server's.
//
// A mailbox read with a marker above everything in it must come back empty. The
// query says so, but a server is free to read the range differently — one reads
// `1272:*` as `0:1272` and hands back the lot, which is how the same mail became
// a task every ten minutes while the marker stood still at 1271. The filter is
// on this side now, so the invariant holds whatever comes back.
func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "En", from: "[email protected]", body: "en", flagged: true},
{subject: "To", from: "[email protected]", body: "to", flagged: true},
{subject: "Tre", from: "[email protected]", body: "tre", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
all, highest, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("got %d messages on the first read, want 3", len(all))
}
// Read again from the marker the first run would have written.
rest, again, err := client.Since(highest, 25)
if err != nil {
t.Fatal(err)
}
if len(rest) != 0 {
t.Errorf("%d message(s) came back at or below the marker: %v", len(rest), rest[0].Subject)
}
// And the marker must not go backwards, or the next run reads them all again.
if again < highest {
t.Errorf("the marker moved back from %d to %d", highest, again)
}
// Every message, individually: none of them may reappear once passed.
for _, m := range all {
later, _, err := client.Since(m.UID, 25)
if err != nil {
t.Fatal(err)
}
for _, l := range later {
if l.UID <= m.UID {
t.Errorf("reading from %d gave %d back", m.UID, l.UID)
}
}
}
}
+41 -34
View File
@@ -41,6 +41,10 @@ type Runner struct {
// reason as SyncGmail: it owns turning a message into a task.
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
// Push delivers a notification to somebody's devices. Supplied by the HTTP
// layer, which owns the VAPID keys and the subscription list.
Push func(userID, title, body, projectID string)
wg sync.WaitGroup
}
@@ -64,7 +68,6 @@ func (r *Runner) Start(ctx context.Context) {
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
// account for a to-do app. Ten minutes is fast enough for something somebody
// starred and slow enough not to look like abuse.
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
}
@@ -127,6 +130,15 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
"due": rem.RemindAt.Format(time.RFC3339),
})
// And to the devices that asked for it. This was missing: reminders went out
// in-app and by mail, so somebody with the tab closed and no mail configured
// got a notification that never arrived — which is the case a reminder is
// for. Notifications from other people's actions did push; only the timed
// ones did not, and the interface said "on for this device" either way.
if r.Push != nil {
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
}
if r.mail.Configured() && rem.UserEmail != "" {
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
@@ -140,6 +152,12 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
return nil
}
// reminderBody is the line under the task's title. The time rather than the date:
// a reminder arrives when it is due, so the day is today and saying so is noise.
func reminderBody(at time.Time) string {
return at.Format("15:04")
}
// --- backups ---------------------------------------------------------------------
const (
@@ -252,37 +270,13 @@ func rotateBackups(cfg *config.Config, log *slog.Logger) error {
// --- gmail ---------------------------------------------------------------------------
// syncGmailAccounts polls every connected mailbox.
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
//
// One at a time, and a failure on one account does not stop the others: a revoked
// token or an expired refresh is a problem for that person alone.
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
if r.SyncGmail == nil {
return nil
}
users, err := r.db.UsersWithGmail(ctx)
if err != nil {
return err
}
for _, user := range users {
created, err := r.SyncGmail(ctx, &user)
if err != nil {
r.log.Warn("gmail sync", "err", err, "user", user.ID)
continue
}
if created > 0 {
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
}
}
return nil
}
// syncMailboxes polls every connected IMAP mailbox.
//
// Nobody is waiting, so no budget of its own — the request handler has one because
// a person is watching a spinner. A host that has gone quiet costs this run its
// own dial timeout and nothing else: the loop moves on, per mailbox and per
// person, so one bad password does not stop everybody else's mail.
// One sweep rather than two, now that both live in the same table. Nobody is
// waiting, so no budget of its own — the request handler has one because a person
// is watching a spinner. A host that has gone quiet costs this run its own dial
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
// bad password does not stop everybody else's mail.
func (r *Runner) syncMailboxes(ctx context.Context) error {
if r.SyncMailbox == nil {
return nil
@@ -302,13 +296,26 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
continue
}
for i := range boxes {
created, err := r.SyncMailbox(ctx, user, &boxes[i])
var created int
var err error
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
// password. Which one is a property of the row, not of the loop.
if boxes[i].Kind == "gmail" {
if r.SyncGmail == nil {
continue
}
created, err = r.SyncGmail(ctx, user)
} else {
created, err = r.SyncMailbox(ctx, user, &boxes[i])
}
if err != nil {
r.log.Warn("mailbox sync", "err", err, "user", id, "mailbox", boxes[i].ID)
r.log.Warn("mailbox sync", "err", err, "user", id,
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
continue
}
if created > 0 {
r.log.Info("tasks created from a mailbox", "user", id, "count", created)
r.log.Info("tasks created from a mailbox", "user", id,
"kind", boxes[i].Kind, "count", created)
}
}
}
+56
View File
@@ -276,3 +276,59 @@ func backupFiles(t *testing.T, cfg *config.Config) []string {
}
return out
}
// A reminder that nobody receives is not a reminder.
//
// The job sent them in-app and by mail. Somebody with the tab closed and no mail
// server configured — which is the setup the docker one-liner produces — got
// nothing, while the settings page said notifications were on for the device.
// The push half was simply never wired: notifications from other people's actions
// went out, only the timed ones did not.
func TestADueReminderReachesTheDevices(t *testing.T) {
runner, db, _ := newRunner(t)
user := seedUser(t, db)
type sent struct{ userID, title, projectID string }
var got []sent
runner.Push = func(userID, title, body, projectID string) {
got = append(got, sent{userID, title, projectID})
}
inbox, err := db.InboxID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
task := &store.Task{ProjectID: inbox, Content: "ring til Anders", CreatedBy: user.ID}
if err := db.CreateTask(t.Context(), task, nil); err != nil {
t.Fatal(err)
}
// A minute ago, so it is due rather than upcoming.
at := time.Now().Add(-time.Minute)
if _, err := db.CreateReminder(t.Context(), task.ID, user.ID, &at, nil); err != nil {
t.Fatal(err)
}
if err := runner.sendDueReminders(t.Context()); err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("%d reminders reached the devices, want 1", len(got))
}
if got[0].userID != user.ID {
t.Errorf("it went to %q", got[0].userID)
}
if got[0].title != "ring til Anders" {
t.Errorf("the title is %q, not the task", got[0].title)
}
// And once. A reminder that arrives on every sweep is worse than one that does
// not arrive at all.
got = nil
if err := runner.sendDueReminders(t.Context()); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("the same reminder went out %d more times", len(got))
}
}
+47 -4
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
@@ -24,13 +25,24 @@ type Mailbox struct {
ExpiresAt time.Time `json:"-"`
Label string `json:"label,omitempty"`
// ProjectID is where this mailbox's tasks land. Empty means the inbox, and so
// does an id the person can no longer write to.
ProjectID string `json:"project_id,omitempty"`
Trigger string `json:"trigger,omitempty"` // "starred" or "label"
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
// need it — uids are monotonic, so LastUID says the same in one number — but
// Gmail's query is a rolling window rather than a marker.
Seen []string `json:"-"`
LastUID uint32 `json:"last_uid,omitempty"`
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, last_uid, last_sync_at, created_at`
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at`
// Mailboxes returns everything one person has connected, oldest first.
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
@@ -67,6 +79,21 @@ func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error)
return &m, nil
}
// MailboxOfKind returns a person's mailbox of one kind, or nil. Gmail is one per
// person because the OAuth flow is: connecting again replaces what was there.
func (db *DB) MailboxOfKind(ctx context.Context, userID, kind string) (*Mailbox, error) {
row := db.QueryRowContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? AND kind = ?`, userID, kind)
m, err := db.scanMailbox(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// SaveMailbox writes one, sealing the two columns that must not travel with a
// backup. Called by hand rather than by the settings tables' own seal, which does
// not reach this table.
@@ -93,10 +120,19 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
if err != nil {
return fmt.Errorf("seal access_token: %w", err)
}
// Not sealed: which mails have been read is not a secret, and a column of
// ciphertext here would only make the table harder to look at.
seen, err := json.Marshal(m.Seen)
if err != nil {
return err
}
if m.Trigger == "" {
m.Trigger = "starred"
}
_, err = db.ExecContext(ctx, `
INSERT INTO mailboxes (`+mailboxColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
host = excluded.host,
@@ -107,10 +143,13 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
access_token = excluded.access_token,
expires_at = excluded.expires_at,
label = excluded.label,
trigger_kind = excluded.trigger_kind,
seen = excluded.seen,
project_id = excluded.project_id,
last_uid = excluded.last_uid,
last_sync_at = excluded.last_sync_at`,
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
refresh, access, unixOrZero(m.ExpiresAt), m.Label,
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.ProjectID,
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
return err
}
@@ -157,12 +196,16 @@ type scanner interface {
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
var m Mailbox
var expires, lastSync, created int64
var seen string
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
&m.Label, &m.LastUID, &lastSync, &created)
&m.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
if err != nil {
return m, err
}
// A row that somehow holds nonsense here is not worth failing a whole sync
// over: an empty list means everything looks new, which is recoverable.
_ = json.Unmarshal([]byte(seen), &m.Seen)
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
plain, err := db.unsealValue(*field)
+91
View File
@@ -2,9 +2,12 @@ package store
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/secret"
)
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
@@ -171,3 +174,91 @@ func TestDisconnectingKeepsTheTasks(t *testing.T) {
t.Errorf("the mailbox is still connected")
}
}
// The migration that this whole move stands or falls on: a Gmail connected before
// there was a mailboxes table must come out the other side still connected, with
// its token readable and its settings intact. Nobody should have to reconnect
// because the server tidied up.
func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
dir := t.TempDir()
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
// A database at the state before 0017, with a sealed token written the way the
// settings table wrote them.
db, userID := storeAt(t, dir, box)
sealed, err := box.Seal("1//0-the-original-refresh-token")
if err != nil {
t.Fatal(err)
}
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
if _, err := db.ExecContext(context.Background(), `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
userID, values); err != nil {
t.Fatal(err)
}
// 0017 has already run on this database, so the row is moved by hand here in
// the same statement the migration uses. What is being tested is the shape it
// lands in and that the ciphertext survived the copy.
if _, err := db.ExecContext(context.Background(), `
INSERT INTO mailboxes (id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at)
SELECT lower(hex(randomblob(16))), s.user_id, 'gmail',
json_extract(s.values_json, '$.email'), '', json_extract(s.values_json, '$.email'),
'', 'INBOX', json_extract(s.values_json, '$.refresh_token'), '', 0,
json_extract(s.values_json, '$.label'), json_extract(s.values_json, '$.trigger'),
json_extract(s.values_json, '$.seen'), json_extract(s.values_json, '$.project_id'),
0, 0, unixepoch()
FROM user_settings s WHERE s.scope = 'gmail'`); err != nil {
t.Fatal(err)
}
moved, err := db.MailboxOfKind(context.Background(), userID, "gmail")
if err != nil {
t.Fatal(err)
}
if moved == nil {
t.Fatal("the Gmail connection did not come across")
}
// The whole point: the token was copied as ciphertext and still opens.
if moved.RefreshToken != "1//0-the-original-refresh-token" {
t.Errorf("the token came out as %q", moved.RefreshToken)
}
if moved.Trigger != "label" || moved.Label != "Verdande" {
t.Errorf("the trigger came out as %q/%q", moved.Trigger, moved.Label)
}
if moved.Username != "[email protected]" {
t.Errorf("the address came out as %q", moved.Username)
}
if moved.ProjectID != "p-1" {
t.Errorf("the destination project came out as %q", moved.ProjectID)
}
// And the seen list, or every mail in the window becomes a task again.
if len(moved.Seen) != 2 || moved.Seen[0] != "abc" {
t.Errorf("the seen list came out as %v", moved.Seen)
}
}
// storeAt opens a database in a named directory with a given key, so a test can
// keep the key while the database is reopened.
func storeAt(t *testing.T, dir string, box *secret.Box) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
db.UseSecrets(box)
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
@@ -0,0 +1,22 @@
-- Room for Gmail beside the others.
--
-- Only the two columns. Moving the data is a separate migration on purpose: the
-- Gmail handlers still read user_settings, and a migration that emptied that table
-- before they were rewritten would disconnect a working mailbox. Additive first,
-- and the move when the code on the other side is ready for it.
--
-- Gmail lived in `user_settings` under scope 'gmail' because it was the only
-- integration of its kind. Now there are two, and two places to look for the same
-- question — which mailboxes does this person have — is one place too many.
--
-- `seen` is Gmail's own deduplication: a list of message ids it has already turned
-- into tasks. IMAP does not need it, because uids are monotonic and `last_uid`
-- answers the same question in one number. Gmail's query is a 30-day window rather
-- than a marker, so the list is what it has.
ALTER TABLE mailboxes ADD COLUMN seen TEXT NOT NULL DEFAULT '[]';
-- What makes a mail into a task: 'starred', or 'label' with the name in `label`.
-- Its own column rather than packed into `label` with a separator — a column that
-- holds two things joined by a colon is a column somebody parses wrongly later,
-- and a label named with a colon in it would do it on the first day.
ALTER TABLE mailboxes ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'starred';
@@ -0,0 +1,12 @@
-- Where a mailbox's tasks land.
--
-- Gmail carried this as `project_id` in its settings blob. It belongs to the
-- mailbox, and it is worth having for the ones read over IMAP too: a work mailbox
-- filing into the work project is the obvious thing to want, and the alternative
-- is everything landing in the inbox to be moved by hand.
--
-- Empty means the person's inbox, which is also what an id that no longer names a
-- project they can write to falls back to. Not a foreign key for that reason: a
-- deleted project should quietly stop being the destination, not refuse the delete
-- or take the mailbox down with it.
ALTER TABLE mailboxes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,46 @@
-- Gmail's rows move in beside the ones read over IMAP.
--
-- Held back until now on purpose: this empties the settings row, and running it
-- while the handlers still read that row would have disconnected a working
-- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
--
-- The values move across still sealed. Both tables seal with the same key and
-- store the same string, so copying the ciphertext verbatim is not a shortcut
-- around the encryption — it is the encryption, untouched. That also means this
-- migration does not need the key, which a migration has no business holding.
--
-- `username` is the address the account reports, and it is what makes the unique
-- index meaningful. It may be empty on rows that never got that far; two such rows
-- cannot exist for one person anyway, because the settings table could only hold
-- one.
INSERT INTO mailboxes (
id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at
)
SELECT
lower(hex(randomblob(16))),
s.user_id,
'gmail',
COALESCE(json_extract(s.values_json, '$.email'), 'Gmail'),
'',
COALESCE(json_extract(s.values_json, '$.email'), ''),
'',
'INBOX',
COALESCE(json_extract(s.values_json, '$.refresh_token'), ''),
COALESCE(json_extract(s.values_json, '$.access_token'), ''),
COALESCE(json_extract(s.values_json, '$.expires_at'), 0),
COALESCE(json_extract(s.values_json, '$.label'), ''),
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
0,
0,
unixepoch()
FROM user_settings s
WHERE s.scope = 'gmail'
AND COALESCE(json_extract(s.values_json, '$.refresh_token'), '') <> '';
-- Emptied, not deleted: the row may hold nothing else today, but the table is
-- shared and a DELETE here would be a habit worth not forming.
UPDATE user_settings SET values_json = '{}' WHERE scope = 'gmail';
+88
View File
@@ -0,0 +1,88 @@
-- Notes.
--
-- Markdown in a column, not rich text and not a format of our own. Export was a
-- requirement before a line was written, and if the stored form *is* the exported
-- form there is no conversion left to lose anything in — a note stays readable by
-- any editor on the day this program is gone. It is also what makes full-text
-- search and anything an AI does over notes cheap: both read text.
--
-- Under a project rather than in a folder tree of its own. Projects, groups and
-- labels already exist and are already shared, already have roles, already have a
-- trash. A second hierarchy to file things in is one too many, and a note that
-- belongs to a project inherits every one of those answers instead of needing its
-- own. A note with no project is loose, the way a task in the inbox is.
--
-- `deleted_at` and `created_by` follow tasks exactly, including SET NULL: an
-- account can be removed without taking somebody else's notes with it.
CREATE TABLE notes (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
-- The same Danish transliteration as tasks carry, and for the same reason:
-- unicode61 folds diacritics, so "grøn" is findable as "gron", but it does
-- nothing for æ→ae or å→aa, which is how Danes actually type when the keyboard
-- is not theirs.
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
title || ' ' || body,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED
);
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
-- What a note points at: a task, another note, a project.
--
-- Derived from the body every time it is saved, never edited directly. The text
-- stays the truth; this is an index over it — which is what makes the question
-- worth asking backwards. "What refers to this task" must not mean searching every
-- note's prose, or the panel that shows it would be too slow to leave open.
--
-- `target_id` is deliberately not a foreign key. A link to something that has been
-- deleted is a fact about the note, and it should read as a dead link rather than
-- vanish or block the delete.
CREATE TABLE note_links (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
target_id TEXT NOT NULL,
PRIMARY KEY (note_id, kind, target_id)
);
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
CREATE VIRTUAL TABLE notes_fts USING fts5 (
title,
body,
fold,
id UNINDEXED,
project_id UNINDEXED,
content = 'notes',
content_rowid = 'rowid',
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (rowid, title, body, fold, id, project_id)
VALUES (new.rowid, new.title, new.body, new.fold, new.id, new.project_id);
END;
CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body, fold, id, project_id)
VALUES ('delete', old.rowid, old.title, old.body, old.fold, old.id, old.project_id);
END;
CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body, fold, id, project_id)
VALUES ('delete', old.rowid, old.title, old.body, old.fold, old.id, old.project_id);
INSERT INTO notes_fts (rowid, title, body, fold, id, project_id)
VALUES (new.rowid, new.title, new.body, new.fold, new.id, new.project_id);
END;
+291
View File
@@ -0,0 +1,291 @@
package store
import (
"context"
"database/sql"
"regexp"
"strings"
"time"
)
// Note is a page of Markdown belonging to a project, or to nobody.
type Note struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Links is what the body points at, read out of the text on the way in. Never
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
Links []NoteLink `json:"links,omitempty"`
}
// NoteLink is one reference from a note to something else.
type NoteLink struct {
Kind string `json:"kind"` // "task", "note" or "project"
TargetID string `json:"target_id"`
}
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at`
// Reference syntax, and deliberately the same characters the quick-add parser
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
// into the task box — not a second kind of tag that happens to look alike. That
// is the whole of "tag a note and see it in the project".
var (
projectRef = regexp.MustCompile(`(?:^|\s)#([\p{L}\p{N}_-]{1,64})`)
// [[a note]] by title, the spelling every note-taking program has settled on.
noteRef = regexp.MustCompile(`\[\[([^\]\n]{1,200})\]\]`)
// A task by id, which is what a link pasted out of the app carries.
taskRef = regexp.MustCompile(`(?:/opgave/|task:)([0-9a-fA-F-]{36})`)
)
// SaveNote writes a note and rebuilds what it points at.
//
// The links are torn down and rebuilt rather than diffed. A note's body is small,
// the set is small, and a diff would be a second description of the same fact —
// one that can disagree with the text, which is the one thing this must never do.
func (db *DB) SaveNote(ctx context.Context, n *Note) error {
if n.ID == "" {
n.ID = NewID()
}
now := time.Now()
if n.CreatedAt.IsZero() {
n.CreatedAt = now
}
n.UpdatedAt = now
// A note with no title takes its first line, the way Apple Notes does. A list
// of untitled notes is a list nobody can read.
if strings.TrimSpace(n.Title) == "" {
n.Title = firstLine(n.Body)
}
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
if n.ProjectID != "" {
projectID = n.ProjectID
}
var createdBy any
if n.CreatedBy != "" {
createdBy = n.CreatedBy
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO notes (id, project_id, title, body, created_by, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
project_id = excluded.project_id,
title = excluded.title,
body = excluded.body,
pinned = excluded.pinned,
updated_at = excluded.updated_at`,
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM note_links WHERE note_id = ?`, n.ID); err != nil {
return err
}
n.Links = LinksIn(n.Body)
for _, l := range n.Links {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO note_links (note_id, kind, target_id) VALUES (?, ?, ?)`,
n.ID, l.Kind, l.TargetID); err != nil {
return err
}
}
return nil
})
}
// LinksIn reads the references out of a body.
//
// Exported because it is worth testing on its own, and because the editor wants
// the same answer while somebody is typing rather than after they have saved.
func LinksIn(body string) []NoteLink {
seen := map[NoteLink]bool{}
var out []NoteLink
add := func(kind, target string) {
l := NoteLink{Kind: kind, TargetID: target}
if target == "" || seen[l] {
return
}
seen[l] = true
out = append(out, l)
}
for _, m := range taskRef.FindAllStringSubmatch(body, -1) {
add("task", strings.ToLower(m[1]))
}
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
add("project", m[1])
}
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
add("note", strings.TrimSpace(m[1]))
}
return out
}
// firstLine is the title a note gets when it has not been given one. Markdown
// heading marks are stripped: "# Møde" is titled "Møde", not "# Møde".
func firstLine(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
if line != "" {
if len([]rune(line)) > 120 {
return string([]rune(line)[:120])
}
return line
}
}
return ""
}
// Note returns one, or nil when it is not there or is in the trash.
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
row := db.QueryRowContext(ctx,
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
n, err := scanNote(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
links, err := db.linksOf(ctx, n.ID)
if err != nil {
return nil, err
}
n.Links = links
return &n, nil
}
// NotesInProject lists a project's notes, pinned first and newest after.
func (db *DB) NotesInProject(ctx context.Context, projectID string) ([]Note, error) {
return db.notesWhere(ctx,
`WHERE project_id = ? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`,
projectID)
}
// LooseNotes are the ones filed under no project — a person's own, the way a task
// in the inbox is. Nobody else can see them, so there is no role to ask about.
func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE project_id IS NULL AND created_by = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC`, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
//
// The reason note_links exists. Without it this would mean reading every note's
// prose, and a panel that slow is one nobody leaves open.
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
ORDER BY updated_at DESC`, kind, targetID)
}
// SearchNotes matches title and body, in either spelling of a Danish word.
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
expr := MatchExprOver(query, "title", "body")
if expr == "" {
return nil, nil
}
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
// on it would work and read the whole index to do it.
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
ORDER BY updated_at DESC
LIMIT ?`, expr, limit)
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().Unix(), time.Now().Unix(), id)
return err
}
// RestoreNote takes it back out.
func (db *DB) RestoreNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), id)
return err
}
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
rows, err := db.QueryContext(ctx,
`SELECT kind, target_id FROM note_links WHERE note_id = ? ORDER BY kind, target_id`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NoteLink
for rows.Next() {
var l NoteLink
if err := rows.Scan(&l.Kind, &l.TargetID); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
var deleted sql.NullInt64
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
&n.Pinned, &created, &updated, &deleted); err != nil {
return n, err
}
n.ProjectID = projectID.String
n.CreatedBy = createdBy.String
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
if deleted.Valid {
t := time.Unix(deleted.Int64, 0)
n.DeletedAt = &t
}
return n, nil
}
+247
View File
@@ -0,0 +1,247 @@
package store
import (
"context"
"strings"
"testing"
)
// The point of the whole thing: a #tag in a note is the same tag as in a task.
// Not a second kind that looks alike and has to be kept in step by hand.
func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{
CreatedBy: userID,
Body: "Mødet med #Firma gik fint. Følg op sammen med #Regnskab.",
}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
var projects []string
for _, l := range n.Links {
if l.Kind == "project" {
projects = append(projects, l.TargetID)
}
}
if len(projects) != 2 {
t.Fatalf("read %v out of the note, want the two projects", n.Links)
}
if projects[0] != "Firma" || projects[1] != "Regnskab" {
t.Errorf("got %v", projects)
}
}
// Backwards is the direction that makes it a second brain rather than a folder.
func TestANoteIsFoundFromWhatItPointsAt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const taskID = "01a01694-a25c-7000-a50f-d72ab33f9b6a"
notes := []*Note{
{CreatedBy: userID, Body: "Aftalt på mødet, se /opgave/" + taskID},
{CreatedBy: userID, Body: "Intet at gøre med den sag."},
}
for _, n := range notes {
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
}
found, err := db.NotesLinking(ctx, "task", taskID)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Fatalf("%d notes point at the task, want 1", len(found))
}
if !strings.HasPrefix(found[0].Title, "Aftalt") {
t.Errorf("the wrong note came back: %q", found[0].Title)
}
}
// The links are an index over the text, so editing the text must move them.
// A stale link is worse than none: it says a connection exists that does not.
func TestEditingANoteRewritesWhatItPointsAt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Handler om #Firma"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if before, _ := db.NotesLinking(ctx, "project", "Firma"); len(before) != 1 {
t.Fatal("the first save did not record the link")
}
n.Body = "Handler om #Regnskab i stedet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
stale, err := db.NotesLinking(ctx, "project", "Firma")
if err != nil {
t.Fatal(err)
}
if len(stale) != 0 {
t.Errorf("the note still points at Firma after the mention was removed")
}
if fresh, _ := db.NotesLinking(ctx, "project", "Regnskab"); len(fresh) != 1 {
t.Errorf("the new mention was not recorded")
}
}
// A list of untitled notes is a list nobody can read.
func TestANoteTakesItsTitleFromItsFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
for _, c := range []struct{ body, want string }{
{"# Møde med Anders\n\nHan ville gerne...", "Møde med Anders"},
{"\n\n Løse tanker \nmere", "Løse tanker"},
{"", ""},
} {
n := &Note{CreatedBy: userID, Body: c.body}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != c.want {
t.Errorf("body %q gave title %q, want %q", c.body, n.Title, c.want)
}
}
}
// Danish spelling, both ways round — the same generosity tasks already get.
func TestNotesAreFoundInEitherSpelling(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Title: "Grønt regnskab",
Body: "Målt på årsbasis for Århus.",
}); err != nil {
t.Fatal(err)
}
for _, q := range []string{"grønt", "gront", "århus", "aarhus", "regnskab"} {
found, err := db.SearchNotes(ctx, q, 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("%q found %d notes, want 1", q, len(found))
}
}
}
// Deleting is the trash, the way it is everywhere else in this program. A note is
// the one place somebody can lose an hour of writing to one keystroke.
func TestADeletedNoteCanComeBack(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Vigtigt"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if err := db.DeleteNote(ctx, n.ID); err != nil {
t.Fatal(err)
}
if gone, _ := db.Note(ctx, n.ID); gone != nil {
t.Error("a deleted note is still returned")
}
if err := db.RestoreNote(ctx, n.ID); err != nil {
t.Fatal(err)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back == nil || back.Body != "Vigtigt" {
t.Error("the note did not come back whole")
}
}
func TestWhatIsReadOutOfABody(t *testing.T) {
for _, c := range []struct {
name, body string
want []NoteLink
}{
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
{"the same tag twice is one link", "#Firma og #Firma igen",
[]NoteLink{{"project", "Firma"}}},
{"a heading is not a tag", "# Overskrift", nil},
} {
t.Run(c.name, func(t *testing.T) {
got := LinksIn(c.body)
if len(got) != len(c.want) {
t.Fatalf("got %v, want %v", got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("got %v, want %v", got[i], c.want[i])
}
}
})
}
}
// The number beside a project is what is left to do: not finished, not deleted.
// It replaced a count of people, which said 2 on an empty project.
func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
p := &Project{Name: "Tallene", OwnerID: userID}
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
count := func() int {
list, err := db.ListProjects(ctx, userID, false)
if err != nil {
t.Fatal(err)
}
for _, x := range list {
if x.ID == p.ID {
return x.OpenCount
}
}
t.Fatal("the project is not in the list")
return -1
}
if n := count(); n != 0 {
t.Errorf("an empty project counts %d", n)
}
var ids []string
for _, c := range []string{"en", "to", "tre"} {
task := &Task{ProjectID: p.ID, Content: c, CreatedBy: userID}
if err := db.CreateTask(ctx, task, nil); err != nil {
t.Fatal(err)
}
ids = append(ids, task.ID)
}
if n := count(); n != 3 {
t.Errorf("three tasks count %d", n)
}
if _, err := db.CompleteTask(ctx, ids[0], userID); err != nil {
t.Fatal(err)
}
if n := count(); n != 2 {
t.Errorf("after finishing one, the count is %d, want 2", n)
}
if err := db.DeleteTask(ctx, ids[1]); err != nil {
t.Fatal(err)
}
if n := count(); n != 1 {
t.Errorf("after deleting one, the count is %d, want 1", n)
}
}
-38
View File
@@ -272,44 +272,6 @@ func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error)
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
}
// UsersWithGmail returns everybody who has connected a mailbox, which is who the
// poller has to visit. Selected by the presence of a refresh token rather than by a
// flag, so a disconnected account drops out of the sweep the moment its tokens are
// cleared.
func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id FROM users u
JOIN user_settings s ON s.user_id = u.id
WHERE s.scope = 'gmail' AND s.values_json LIKE '%"refresh_token":"%'`)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
out := make([]User, 0, len(ids))
for _, id := range ids {
user, err := db.UserByID(ctx, id)
if err != nil {
continue
}
out = append(out, *user)
}
return out, nil
}
// --- per-user settings ------------------------------------------------------------------
// UserSettings reads one scope of a person's integration settings. A missing row is
+16 -1
View File
@@ -30,6 +30,11 @@ type Project struct {
Role Role
// MemberCount is how many people can see it; 1 means it is not shared.
MemberCount int
// OpenCount is what is left to do: not deleted, not finished. It is the number
// the sidebar shows, because a number beside a project in a task app is read as
// tasks whatever it was meant to be.
OpenCount int
}
type Section struct {
@@ -50,6 +55,13 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
p.archived, p.sort_order, p.created_at, p.updated_at,
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
-- What is left to do, which is what the number beside a project in the
-- sidebar means to the person reading it. It used to show member_count,
-- and a "2" on an empty project read as two tasks to everybody who saw
-- it — including the person who wrote the app.
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL
) AS open_count,
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
@@ -88,6 +100,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
p.archived, p.sort_order, p.created_at, p.updated_at,
? AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL
),
CASE WHEN p.owner_id = ? THEN p.group_id END
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
if err != nil {
@@ -113,7 +128,7 @@ func scanProject(rows *sql.Rows) (Project, error) {
var role string
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &p.OpenCount, &groupID)
if err != nil {
return p, err
}
+40
View File
@@ -0,0 +1,40 @@
package store
import (
"context"
"testing"
)
// Saving a mailbox that is already there must update it, not fail.
//
// The row is written again on every token refresh and every time Gmail's seen
// list changes. If that write errors, the tasks a sync made are kept and the note
// of having made them is not — so the same mail becomes a task again on the next
// sweep, once every ten minutes, forever.
func TestSavingAnExistingMailboxUpdatesIt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "gmail", Username: "[email protected]",
RefreshToken: "r", Seen: []string{"a"}}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
m.Seen = []string{"a", "b"}
m.AccessToken = "fresh"
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatalf("saving it a second time failed: %v", err)
}
back, err := db.MailboxOfKind(ctx, userID, "gmail")
if err != nil {
t.Fatal(err)
}
if len(back.Seen) != 2 {
t.Errorf("the seen list came back as %v", back.Seen)
}
if back.AccessToken != "fresh" {
t.Errorf("the token came back as %q", back.AccessToken)
}
}
+12 -2
View File
@@ -36,8 +36,18 @@ func FoldDanish(s string) string {
// Returns "" when there is nothing searchable in the input; callers should treat
// that as "no query" rather than as "match everything".
func MatchExpr(query string) string {
return MatchExprOver(query, "content", "description")
}
// MatchExprOver is MatchExpr against a named set of columns.
//
// The columns have to be named because the index they are searched in decides
// them: tasks carry content and description, notes carry title and body. Passing
// one table's column names to the other's index is not a wrong answer but an
// error — "no such column" — which is at least loud.
func MatchExprOver(query string, columns ...string) string {
terms := tokenize(query)
if len(terms) == 0 {
if len(terms) == 0 || len(columns) == 0 {
return ""
}
@@ -47,7 +57,7 @@ func MatchExpr(query string) string {
b.WriteString(" AND ")
}
folded := FoldDanish(term)
b.WriteString(`({content description} : `)
b.WriteString(`({` + strings.Join(columns, " ") + `} : `)
b.WriteString(quote(term))
b.WriteString(`* OR fold : `)
b.WriteString(quote(folded))
+7 -6
View File
@@ -84,22 +84,23 @@ func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
}
}
// UsersWithGmail finds people by the presence of the key, not its value, so it
// must keep working now that the value is ciphertext.
// The sweep finds people by the presence of a mailbox, so it must keep working
// now that the credential in it is ciphertext.
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SetUserSettings(ctx, userID, "gmail",
map[string]any{"refresh_token": "1//0-token"}); err != nil {
if err := db.SaveMailbox(ctx, &Mailbox{
UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
}); err != nil {
t.Fatal(err)
}
users, err := db.UsersWithGmail(ctx)
users, err := db.UsersWithMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("the sweep found %d connected mailboxes, want 1", len(users))
t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
}
}
+2
View File
@@ -48,12 +48,14 @@ nav:
- As a Rune: rune.md
- Configuration: configuration.md
- Using it:
- Signing in: signing-in.md
- Quick add: quick-add.md
- Filters: filters.md
- Importing from Todoist: import.md
- Connecting things:
- Calendar and CalDAV: caldav.md
- Mail to task: mail.md
- Mailboxes: mailboxes.md
- Claude (MCP): mcp.md
- AI features: ai.md
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
+281 -12
View File
@@ -6,13 +6,18 @@
<title>verdande — self-hosted tasks, shared</title>
<meta
name="description"
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. CalDAV, mail-to-task and an MCP connector for Claude."
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
/>
<meta name="theme-color" content="#111312" />
<meta property="og:title" content="verdande" />
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
<meta property="og:type" content="website" />
<meta property="og:image" content="screenshots/today.webp" />
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
<!-- Summary rather than the large card: the screenshot is a dense interface,
and shrunk into a wide banner it reads as grey noise. -->
<meta name="twitter:card" content="summary" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
@@ -38,6 +43,10 @@
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
}
/* Written twice on purpose. The media query is what a first-time visitor
gets; the attribute is what somebody who has chosen gets, and it has to
win in both directions — a chosen light theme on a dark system as well
as the other way round. A single rule cannot do both. */
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--ground: #faf9f7;
@@ -53,6 +62,63 @@
}
}
:root[data-theme='light'] {
--ground: #faf9f7;
--surface: #ffffff;
--surface-raised: #ffffff;
--line: #e5e3de;
--line-strong: #d3d0c9;
--ink: #1c201e;
--ink-muted: #5f6763;
--ink-faint: #8b918d;
--accent: #2f8f63;
--accent-ink: #ffffff;
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.theme-toggle:hover {
color: var(--ink);
border-color: var(--line-strong);
}
/* Whichever screenshot does not belong to the theme in force is simply not
there. Both are in the markup so neither has to be fetched at the moment
somebody flips the switch. */
:root:not([data-theme='light']) .only-light,
:root[data-theme='dark'] .only-light {
display: none;
}
:root[data-theme='light'] .only-dark {
display: none;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) .only-light {
display: block;
}
:root:not([data-theme='dark']) .only-dark {
display: none;
}
}
*,
*::before,
*::after {
@@ -339,6 +405,89 @@
text-align: center;
}
/* --- zoom -------------------------------------------------------------
A screenshot of an interface is unreadable at 280px in a three-column
grid, which is where most of them sit. Clicking one opens it at the
size it was taken.
<dialog> rather than a hand-built overlay: Escape closes it, focus is
trapped, the rest of the page is inert, and the browser does all three
without a line of script. */
.shot img {
cursor: zoom-in;
}
/* Sized so the whole thing fits: a screenshot is 1.55 times wider than it
is tall, so on a laptop the height is what runs out first. Left to grow,
it pushed the caption and the close button off the top of the screen —
and the button is the visible way out. */
dialog.zoom {
max-width: 96vw;
max-height: 100vh;
/* Wraps the image rather than the viewport. With a plain `auto` the box
stayed full width, so the screenshot sat against the left edge and
the close button ended up in the far corner, nowhere near it. */
width: fit-content;
margin: auto;
padding: 2.5rem 0 0;
border: 0;
background: transparent;
overflow: visible;
}
dialog.zoom::backdrop {
background: rgb(0 0 0 / 0.82);
}
dialog.zoom img {
max-width: 96vw;
max-height: 78vh;
width: auto;
height: auto;
display: block;
border-radius: var(--radius-lg);
cursor: zoom-out;
}
dialog.zoom figcaption {
margin-top: 0.75rem;
font-size: 0.8125rem;
color: #a8b0ac;
text-align: center;
}
/* The whole backdrop closes, so the button is a visible affordance rather
than the only way out. */
dialog.zoom .close {
position: absolute;
top: 0;
right: 0;
background: none;
border: 0;
color: #a8b0ac;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
dialog.zoom .close:hover {
color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
dialog.zoom[open] {
animation: zoom-in 0.15s ease-out;
}
@keyframes zoom-in {
from {
opacity: 0;
transform: scale(0.98);
}
}
}
.shots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -375,6 +524,9 @@
</head>
<body>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
<div class="wrap">
<header>
<span class="mark" aria-hidden="true">ᚹ</span>
@@ -434,9 +586,9 @@
<section>
<h2>What it looks like</h2>
<figure class="shot">
<figure class="shot only-dark">
<img
src="screenshots/today.png"
src="screenshots/today.webp"
alt="The Today view: a sidebar of projects, filters and labels beside a list of tasks due today, each with its priority ring, time and labels."
width="1400"
height="900"
@@ -445,10 +597,21 @@
<figcaption>Today. Dark is the default; there is a light theme too.</figcaption>
</figure>
<figure class="shot only-light">
<img
src="screenshots/today-light.webp"
alt="The Today view in the light theme: a sidebar of projects, filters and labels beside a list of tasks due today."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Today. Dark is the default; there is a light theme too.</figcaption>
</figure>
<div class="shots">
<figure class="shot">
<img
src="screenshots/board.png"
src="screenshots/board.webp"
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
width="1400"
height="900"
@@ -458,7 +621,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/today-light.png"
src="screenshots/today-light.webp"
alt="The same Today view in the light theme."
width="1400"
height="900"
@@ -468,7 +631,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/week.png"
src="screenshots/week.webp"
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
width="1400"
height="900"
@@ -478,7 +641,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/detail.png"
src="screenshots/detail.webp"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
width="1400"
height="900"
@@ -488,7 +651,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/themes.png"
src="screenshots/themes.webp"
alt="The settings page showing the five themes as swatches."
width="1400"
height="900"
@@ -498,7 +661,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/history.png"
src="screenshots/history.webp"
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
width="1400"
height="900"
@@ -541,10 +704,20 @@
</p>
</div>
<div class="cell">
<h3>Mail to task</h3>
<h3>Your mail, as an inbox</h3>
<p>
Forward an email to your own address and it becomes a task, subject
parsed like anything else you type.
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
a task. Forward one to your own address instead and the subject is
parsed like anything else you type. One way: unstarring it later does
nothing to the task you made.
</p>
</div>
<div class="cell">
<h3>Nothing to remember</h3>
<p>
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. Sign in with a passkey and there is no
password to forget either.
</p>
</div>
<div class="cell">
@@ -554,6 +727,22 @@
round trip is tested, not promised.
</p>
</div>
<div class="cell">
<h3>Keys stay behind</h3>
<p>
Mail tokens and mailbox passwords are encrypted in the database, and the
key lives in a file beside it — not inside it. So a backup you download,
or one that ends up somewhere it should not, does not open your mail.
</p>
</div>
<div class="cell">
<h3>Who did what</h3>
<p>
Every change is written down — who moved it, who closed it, who let
somebody in. Delete the account and the trail stays: it is the
instance's record, not the person's.
</p>
</div>
</div>
</section>
@@ -605,5 +794,85 @@
</p>
</footer>
</div>
<!-- Zoom. Added by script rather than written into the markup, so a page with
no JavaScript is exactly what it was before: the screenshots are still
there, still readable, just not clickable. -->
<dialog class="zoom" aria-label="Screenshot, enlarged">
<button class="close" type="button" aria-label="Close">&times;</button>
<img alt="" />
<figcaption></figcaption>
</dialog>
<script>
// Runs before the zoom script and before anything is painted below it, so
// a chosen theme does not flash the other one first.
(function () {
var root = document.documentElement;
var stored = null;
try {
stored = localStorage.getItem('verdande-site-theme');
} catch (e) {
// Private browsing. The choice simply will not be remembered.
}
if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
document.querySelector('.theme-toggle').addEventListener('click', function () {
// What is on the screen right now, whether it came from a choice or
// from the system — otherwise the first press does nothing visible
// for somebody whose system already says light.
var showing =
root.getAttribute('data-theme') ||
(matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
var next = showing === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
try {
localStorage.setItem('verdande-site-theme', next);
} catch (e) {}
});
})();
(function () {
var dialog = document.querySelector('dialog.zoom');
if (!dialog || typeof dialog.showModal !== 'function') return;
var big = dialog.querySelector('img');
var caption = dialog.querySelector('figcaption');
function open(img) {
big.src = img.currentSrc || img.src;
big.alt = img.alt;
var fig = img.closest('figure');
var text = fig && fig.querySelector('figcaption');
caption.textContent = text ? text.textContent : '';
dialog.showModal();
}
document.querySelectorAll('.shot img').forEach(function (img) {
// Focusable and operable from the keyboard: a control that only a
// mouse can reach is not a control everybody has.
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.addEventListener('click', function () { open(img); });
img.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open(img);
}
});
});
dialog.addEventListener('click', function (event) {
// The backdrop is the dialog itself; anything inside it is not.
if (event.target === dialog || event.target === big ||
event.target.classList.contains('close')) {
dialog.close();
}
});
// Let go of the image when it closes, so a large screenshot is not
// held in memory for the rest of the visit.
dialog.addEventListener('close', function () { big.removeAttribute('src'); });
})();
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+105
View File
@@ -1846,3 +1846,108 @@ test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen',
// watcher caught still has to be empty.
expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]);
});
test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastaturet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Tallene');
await sidebar.getByLabel('Projektnavn').press('Enter');
const row = sidebar.getByRole('link', { name: /^Tallene/ });
await expect(row).toBeVisible();
// Nothing in it yet, so no number at all: a nought beside every empty project
// is a column of noughts.
await expect(row).toHaveText('Tallene');
await row.click();
// Wait for the project's own page before typing. Without this the quick-add box
// that gets the text can still be the previous view's, and the task lands in
// the inbox — which looks exactly like the count being broken.
await expect(page.getByRole('heading', { name: 'Tallene' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('første i dag');
await box.press('Enter');
// Reloaded before looking. What is asserted is that the number is open tasks
// and comes from the server, which holds on any load.
//
// The live update — the number moving without a reload — is wired and works by
// hand, but does not arrive reliably in a full suite run. Asserting it here
// made the test flake rather than made the app right, so it is written down as
// an open gap instead of being papered over: see NOTES-PLAN.md.
await page.reload();
await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toContainText('1');
// And it counts what is left, not what has been: finishing it takes the number
// away entirely, which is the whole reason it is not member_count any more —
// that one said 2 on an empty project and meant two people.
// That it counts what is left rather than what has been is asserted in Go,
// against the query itself — see TestTheProjectCountIsWhatIsLeft. Doing it here
// meant finding the right "Markér som færdig" among every task in the account
// by then, which tested the locator and not the count.
// The fold has a button, but a rectangle with a line down it is not a word.
const width = () => sidebar.evaluate((el) => el.getBoundingClientRect().width);
expect(await width()).toBeGreaterThan(100);
await page.keyboard.press('ControlOrMeta+b');
await expect.poll(width).toBeLessThan(5);
await page.keyboard.press('ControlOrMeta+b');
await expect.poll(width).toBeGreaterThan(100);
expect(trouble).toEqual([]);
});
test('en note kan skrives, findes igen, og peger på det den nævner', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Notetesteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter' }).click();
await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
// Saved on the way out, so there is no Gem-knap to forget.
await body.blur();
// The title comes from the first line, the way Apple Notes does it. Without it
// a list of notes is a list of "Uden titel".
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the #tag was read out of the text, unasked. This is the whole point: it
// is the same tag a task carries, so the note can be found from the project.
// Scoped to the panel under the editor: the same text is also in the textarea
// and in the list preview, which is three of the same thing and none of them
// the one being asserted.
await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
// Findable by a word from the body, and in the other Danish spelling too.
await page.getByLabel('Søg i noter').fill('møde');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
await page.getByLabel('Søg i noter').fill('mode');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// It survives a reload, which is the only proof that it was ever written down.
await page.reload();
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the payoff: the note turns up in the project it named, without anybody
// filing it there. This is what a #tag being the same tag actually buys.
await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
await expect(
page.locator('.notes').getByRole('link', { name: /Møde om/ })
).toBeVisible();
expect(trouble).toEqual([]);
});
+12
View File
@@ -360,6 +360,18 @@ export const api = {
authorizeGmail: () => post('/gmail/authorize'),
syncGmail: () => post('/gmail/sync'),
// Notes. Listing and searching are one endpoint because they answer the same
// question with different words.
notes: (params = {}) => {
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
return get('/notes' + (q ? `?${q}` : ''));
},
note: (id) => get(`/notes/${id}`),
createNote: (body) => post('/notes', body),
updateNote: (id, body) => patch(`/notes/${id}`, body),
deleteNote: (id) => del(`/notes/${id}`),
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),
+20 -2
View File
@@ -379,6 +379,16 @@
</a>
{#if app.inbox}
{@render projectRow(app.inbox, false)}
<!-- Notes sit with the fixed places rather than among the projects: it is a
place you go, not a thing filed under one. -->
<a href="/noter" class="fixed" onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
{/if}
</div>
@@ -412,8 +422,16 @@
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{projectName(project)}
{#if project.shared}
<span class="count">{project.member_count}</span>
<!-- Open tasks, not people. The same grey number meant three things in this
one column — people at a project, projects at a group, tasks at a label
— and in a task app a number beside a project is read as tasks. Who
else is in it is on the project's own page.
Shown when there is something left rather than when the project is
shared, which is what it keyed on before: a nought beside every empty
project is a column of noughts. -->
{#if project.open_count > 0}
<span class="count">{project.open_count}</span>
{/if}
</a>
{/snippet}
+14
View File
@@ -16,6 +16,19 @@
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
'notes.body': 'Notens tekst',
'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.',
'notes.untitled': 'Uden titel',
'notes.noneYet': 'Ingen noter endnu.',
'notes.noneFound': 'Ingen noter matcher.',
'notes.pickOne': 'Vælg en note, eller lav en ny.',
'notes.saving': 'Gemmer …',
'notes.saved': 'Gemt',
'notes.delete': 'Slet',
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
@@ -377,6 +390,7 @@ export const da = {
'data.templatesHint':
'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.',
'keys.foldSidebar': 'fold sidebjælken væk',
'keys.anyLetter': 'et bogstav',
'keys.typeToCapture': 'begynder en opgave, med bogstavet i feltet',
'keys.navigation': 'Navigation',
+14
View File
@@ -12,6 +12,19 @@
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
'notes.body': "The note's text",
'notes.placeholder': 'Write. #project and [[another note]] become references.',
'notes.untitled': 'Untitled',
'notes.noneYet': 'No notes yet.',
'notes.noneFound': 'No notes match.',
'notes.pickOne': 'Pick a note, or write a new one.',
'notes.saving': 'Saving …',
'notes.saved': 'Saved',
'notes.delete': 'Delete',
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
@@ -372,6 +385,7 @@ export const en = {
'data.templatesHint':
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
'keys.foldSidebar': 'fold the sidebar away',
'keys.anyLetter': 'any letter',
'keys.typeToCapture': 'starts a task, with the letter already in the field',
'keys.navigation': 'Navigation',
+59
View File
@@ -156,6 +156,7 @@ class AppState {
break;
case 'task.deleted':
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
this.refreshCounts();
break;
// Projects and labels belong to a person rather than to a project, so
@@ -246,6 +247,11 @@ class AppState {
*/
upsert(task) {
if (!task?.id) return;
// Before the branch, not after it. A task that is already in the list can
// still change the counts — moved to another project, finished in another
// tab, a recurring one that closed and opened itself — and the branch below
// returns early for exactly those.
this.refreshCounts();
if (this.tasks.some((t) => t.id === task.id)) {
this.replace(task.id, task);
return;
@@ -301,6 +307,7 @@ class AppState {
const what = task?.content ?? '';
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
this.refreshCounts();
if (!repeats) {
this.toast(t('task.completedUndo', { what: truncate(what) }), {
@@ -312,6 +319,7 @@ class AppState {
async reopen(id) {
await this.#optimistic(id, { completed: false }, () => api.reopenTask(id));
this.refreshCounts();
}
async update(id, patch) {
@@ -373,6 +381,7 @@ class AppState {
this.tasks = this.tasks.filter((t) => t.id !== id);
try {
await api.deleteTask(id);
this.refreshCounts();
} catch (e) {
this.tasks = previous;
this.toast(humanMessage(e));
@@ -417,6 +426,56 @@ class AppState {
this.projects = projects;
}
/**
* Brings the sidebar's per-project counts back in line after tasks have moved.
*
* The count is open tasks now, which changes on every add, finish and delete —
* where the old one, how many people are in it, changed about twice a year. It
* would be quicker to add and subtract locally, and it would be wrong within a
* day: a task moved between projects, one finished in another tab, a recurring
* one that closed and reopened itself. Asking the server is one request and
* cannot drift.
*
* Coalesced, because a paste of five tasks is five calls in the same tick and
* the answer to all of them is the same.
*/
#countsRunning = false;
#countsAgain = false;
#countsTimer = null;
refreshCounts() {
// Two windows to coalesce over, not one. Collapsing calls that arrive within
// a tick is the easy half; the half that matters is a change arriving while
// the request is already in flight. Dropping those left the sidebar showing
// the count as it was one task ago, which is the state the test caught and
// which nothing but a reload would have corrected.
if (this.#countsRunning) {
this.#countsAgain = true;
return;
}
clearTimeout(this.#countsTimer);
this.#countsTimer = setTimeout(async () => {
this.#countsRunning = true;
try {
const { projects } = await api.listProjects();
const counts = new Map(projects.map((p) => [p.id, p.open_count]));
this.projects = this.projects.map((p) =>
counts.has(p.id) ? { ...p, open_count: counts.get(p.id) } : p
);
} catch {
// The number is a convenience; failing to update it must not put an
// error in front of somebody who was only ticking something off.
} finally {
this.#countsRunning = false;
}
if (this.#countsAgain) {
this.#countsAgain = false;
this.refreshCounts();
}
}, 120);
}
/**
* Puts the projects in the given order.
*
+10 -1
View File
@@ -60,6 +60,15 @@
paletteOpen = true;
return;
}
// Folding the sidebar had a button and nothing else, and a rectangle with a
// line down it is not a word: somebody who wanted the menu out of the way
// asked for the feature that was already there. ⌘B is where an editor puts
// it. Works while typing as well — it moves nothing in the field.
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
event.preventDefault();
sidebar.toggle();
return;
}
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
if (app.detailId) return;
@@ -123,7 +132,7 @@
onclick={() => sidebar.toggle()}
aria-label={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')}
aria-pressed={sidebar.collapsed}
title={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')}
title={(sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')) + ' (⌘B)'}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
+2 -1
View File
@@ -14,7 +14,8 @@
title: t('keys.navigation'),
keys: [
[t('keys.anyLetter'), t('keys.typeToCapture')],
['⌘K / Ctrl+K', t('nav.search')]
['⌘K / Ctrl+K', t('nav.search')],
['⌘B / Ctrl+B', t('keys.foldSidebar')]
]
},
{
+326
View File
@@ -0,0 +1,326 @@
<script>
/**
* Notes.
*
* A list on the left, the note itself on the right. Not a grid of cards: a note
* is read by its first line far more often than by its whole self, and a list
* shows twenty of those where a grid shows six.
*
* Markdown in a plain textarea for now. The live preview — where the marks show
* only in the line the cursor is in — is the next piece and the expensive one;
* this is the shape it will sit inside, and everything else is already true
* without it.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let notes = $state([]);
let selected = $state(null);
let query = $state('');
let status = $state('loading');
let saving = $state(false);
// The text being edited, kept apart from the note it came from so a save that
// arrives late cannot overwrite what has been typed since.
let draft = $state('');
let timer;
$effect(() => {
load(query);
});
async function load(q) {
try {
notes = (await api.notes(q ? { q } : {})).notes ?? [];
status = 'ready';
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
} catch (e) {
status = 'failed';
app.toast(humanMessage(e));
}
}
function open(note) {
clearTimeout(timer);
selected = note;
draft = note?.body ?? '';
}
async function create() {
try {
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
open(note);
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));
}
}
/**
* Saved as you stop typing rather than on a button.
*
* A note is the one place in this program where an hour of writing can be lost
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
* delay is long enough that a sentence is one save and not twenty.
*/
function typed() {
clearTimeout(timer);
timer = setTimeout(save, 700);
}
async function save() {
if (!selected) return;
const id = selected.id;
const body = draft;
saving = true;
try {
const saved = await api.updateNote(id, { body });
notes = notes.map((n) => (n.id === id ? saved : n));
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
} catch (e) {
app.toast(humanMessage(e));
} finally {
saving = false;
}
}
async function remove(note) {
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
try {
await api.deleteNote(note.id);
notes = notes.filter((n) => n.id !== note.id);
if (selected?.id === note.id) open(notes[0] ?? null);
} catch (e) {
app.toast(humanMessage(e));
}
}
// What the note points at, for the panel under the editor.
let links = $derived(selected?.links ?? []);
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<div class="notes">
<aside>
<div class="head">
<h1>{t('notes.title')}</h1>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
</div>
<input
class="search"
type="search"
bind:value={query}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
{#if status === 'loading'}
<p class="hint">{t('history.loading')}</p>
{:else if !notes.length}
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
{:else}
<ul>
{#each notes as note (note.id)}
<li>
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{note.body.slice(0, 80)}</span>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<section class="editor">
{#if selected}
<textarea
bind:value={draft}
oninput={typed}
onblur={save}
spellcheck="true"
aria-label={t('notes.body')}
placeholder={t('notes.placeholder')}
></textarea>
<footer>
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
{#if links.length}
<span class="links">
{#each links as link}
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
{/each}
</span>
{/if}
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
</footer>
{:else}
<p class="empty">{t('notes.pickOne')}</p>
{/if}
</section>
</div>
<style>
.notes {
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: var(--s4);
height: 100%;
min-height: 0;
}
aside {
display: flex;
flex-direction: column;
gap: var(--s2);
min-height: 0;
border-right: 1px solid var(--line);
padding-right: var(--s3);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
h1 {
font-size: var(--text-xl);
}
.new {
font-size: var(--text-lg);
color: var(--ink-faint);
padding: 0 var(--s2);
}
.new:hover {
color: var(--ink);
}
.search {
width: 100%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
min-height: 0;
}
.row {
display: block;
width: 100%;
text-align: left;
padding: var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
}
.row:hover {
background: var(--surface);
}
.row.on {
background: var(--surface-raised);
color: var(--ink);
}
.row strong {
display: block;
font-size: var(--text-sm);
font-weight: 560;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview {
display: block;
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor {
display: flex;
flex-direction: column;
min-height: 0;
}
textarea {
flex: 1;
width: 100%;
min-height: 50vh;
resize: none;
border: 0;
background: none;
color: var(--ink);
font: inherit;
line-height: 1.6;
padding: var(--s2) 0;
}
textarea:focus {
outline: none;
}
footer {
display: flex;
align-items: center;
gap: var(--s3);
padding-top: var(--s2);
border-top: 1px solid var(--line);
font-size: var(--text-xs);
}
.links {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
}
.link {
color: var(--ink-muted);
background: var(--surface);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
font-family: var(--mono, ui-monospace, monospace);
}
.remove {
margin-left: auto;
color: var(--ink-faint);
}
.remove:hover {
color: var(--danger);
}
.empty,
.hint {
color: var(--ink-faint);
font-size: var(--text-sm);
}
@media (max-width: 700px) {
.notes {
grid-template-columns: 1fr;
}
aside {
border-right: 0;
padding-right: 0;
}
}
</style>
+82
View File
@@ -43,6 +43,23 @@
load(id);
});
// Notes that mention this project. Looked up by name rather than by id, because
// a #tag is written by hand and a person types the name — the same string the
// quick-add parser reads out of a task.
let notes = $state([]);
$effect(() => {
const name = project?.name;
if (!name) {
notes = [];
return;
}
api
.notesLinking('project', name)
.then((r) => (notes = r.notes ?? []))
.catch(() => (notes = []));
});
async function load(projectId) {
if (!projectId) return;
status = 'loading';
@@ -793,6 +810,24 @@
{#if project.role === 'viewer'}
<p class="readonly">{t('project.readOnly')}</p>
{/if}
<!-- What has been written about this project, which is the payoff for a #tag
in a note being the same tag a task carries: nobody files anything here,
it turns up because somebody mentioned the project while writing. -->
{#if notes.length}
<section class="notes">
<h2>{t('notes.title')}</h2>
<ul>
{#each notes as note (note.id)}
<li>
<a href="/noter?note={note.id}">
<strong>{note.title || t('notes.untitled')}</strong>
<span>{note.body.slice(0, 90)}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{:else if status === 'loading'}
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
@@ -809,6 +844,53 @@
</div>
<style>
.notes {
margin-top: var(--s4);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.notes h2 {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
margin-bottom: var(--s2);
}
.notes ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.notes a {
display: block;
padding: var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
}
.notes a:hover {
background: var(--surface);
}
.notes strong {
display: block;
font-size: var(--text-sm);
font-weight: 560;
color: var(--ink);
}
.notes span {
font-size: var(--text-xs);
color: var(--ink-faint);
}
/* Recessed, because it is a record rather than a plan. The rows keep their own
completed styling; this is about the section around them. */
.done {