Compare commits

..
35 Commits
Author SHA1 Message Date
Kristian Wind 445d9de57a Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 11m3s
CI / Docker image builds (push) Successful in 2m17s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 15m6s
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
2026-09-08 14:12:17 +02:00
Kristian Wind d5ad459eba Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste
2026-09-08 14:04:12 +02:00
Kristian Wind c297d256e4 Merge pull request #64 from kristianwind/tag-en-person-i-en-note
feat(noter): nævn en person i en note, og den er delt med dem
2026-09-08 14:04:08 +02:00
kristianwindandClaude Opus 5 d063425cae feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:03:32 +02:00
Kristian Wind 0764fbd566 Merge pull request #63 from kristianwind/del-note-med-en-uden-konto
feat(noter): del en note med en, der ikke har en konto endnu
2026-09-08 12:13:34 +02:00
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:12:54 +02:00
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:08:12 +02:00
Kristian Wind 61634a6bb5 Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
2026-09-08 11:59:04 +02:00
kristianwindandClaude Opus 5 8ee9fbedfb feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:58:47 +02:00
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:40:43 +02:00
Kristian Wind 48509e3073 Merge pull request #61 from kristianwind/delte-noter-tager-links-med
Release / Build and publish image (push) Failing after 8s
feat(noter): en delt note tager de noter med, den peger på
2026-09-07 22:59:47 +02:00
kristianwindandClaude Opus 5 916a0c0224 feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note
førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note
er at dele det, den *siger*, og et link er en del af det.

note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der
er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en
person har en note — "fulgte med" er en anden sætning end "du delte den" — og de
udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren
selv har sat.

SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne
handlinger: en deling, en fjernet deling, en gemning der har ændret links eller
titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på
hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede.
Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås
ad to veje.

Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger
på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som
helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med.
Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens
egne gemninger om. Begge dele har en Go-prøve.

Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren
bagefter sin egen note, følger den note med dér. Det, der står imod, er
synlighed — linket står som tekst i ejerens egen note, og panelet viser hver
note, der er fulgt med, og hvem der har den.

Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to
værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en
gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det
første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven
for monospace at taste sin titel ind i den forrige note, som editoren så tegnede
væk.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 22:28:58 +02:00
Kristian Wind ff75302cf8 Merge pull request #60 from kristianwind/note-links-og-kodeknap
fix(noter): følg note-links i en delt note, og lad </> være kode
2026-09-07 21:58:29 +02:00
kristianwindandClaude Opus 5 08d703e3af fix(noter): følg note-links i en delt note, og lad </> være kode
To ting meldt af en, der havde en note delt med sig.

Note-linkene i den delte note gjorde ingenting. Opslaget faldt tilbage på det
første søgeresultat, når ingen titel passede — og i en delt note er det første
resultat næsten altid noten selv, fordi den nævner den titel, den peger på. Så
åbnede linket den note, man allerede stod i. Kun en titel, der passer helt,
tæller nu; og rammer den forbi, siger beskeden den rigtige grund: en note, der
deles, deler ikke de noter, den peger på, så modtageren kan se linket uden at
kunne åbne det.

Og `</>` sad på kildevisningen med teksten "Vis som kode" på. Tegnet betyder
inline kode i ethvert andet program, så et markeret ord plus et tryk gav hele
noten som Markdown. Nu gør `</>` det, det siger — ⌘E, en kontakt, og ordet under
markøren når der ikke er markeret noget — og kilden har fået Markdown-mærket og
sin egen tekst.

Kode indsættes som en node frem for med execCommand: Chromium renser den HTML
gennem indsætningsvejen og gav et <span> med skrifttypen skrevet ind i sig, som
htmlToMarkdown ikke kan skrive ned som kode.

To e2e-prøver, som begge fejler på koden før den her.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 21:50:12 +02:00
Kristian Wind d4784fd174 Merge pull request #59 from kristianwind/upcoming-horizon
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 9m37s
CI / Docker image builds (push) Successful in 2m9s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 14m45s
Kommende: en horisont på 7, 14 eller 30 dage
2026-09-07 15:08:06 +02:00
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00
Kristian Wind f60758b770 Merge pull request #58 from kristianwind/site-all-themes
docs(site): show all six themes, and put the demo line in English
2026-09-04 09:56:28 +02:00
kristianwindandClaude Opus 4.8 9d390d021f docs(site): show all six themes, and put the demo line in English
Adds a gallery of the same Today view in each of the six themes — Northern lights,
Charcoal, Dusk, Daylight, Paper, Contrast — so the range is shown rather than
described, on the landing page and (as the swatch panel) the docs home. The lone
Charcoal Today capture is folded into it.

And the quick-add demo that leads the docs — "betal moms i morgen kl 10 p1 #Firma
@regnskab" — is now English, "file the VAT return tomorrow at 10am p1 #Accounts
@tax", on every page it appears as the line to type: the home, the quick-add page,
the API and MCP examples, and the OpenAPI blurb. The Danish date words on the
quick-add page stay, because documenting the Danish parser is their job.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:46:50 +02:00
Kristian Wind 18db79c063 Merge pull request #57 from kristianwind/site-charcoal-features
docs(site): Charcoal theme, offline, and the star that comes off
2026-09-04 09:27:50 +02:00
kristianwindandClaude Opus 4.8 1612f53966 docs(site): the Charcoal theme, offline, and the star that comes off
Refreshes the whole screenshot set to the app as it ships now — the three-line
note rows, the lightened calendar — and adds two Charcoal captures, the theme
modelled on Apple Notes, since it is the one worth showing. The landing page and
the docs home both gain it, a "holds through a short network drop" line, and the
correction that a starred or flagged mail now loses its mark once it is a task
rather than keeping it. Six themes now, not five.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:20:11 +02:00
Kristian Wind 4d5f1a56b1 Merge pull request #56 from kristianwind/gmail-unstar
CI / OpenAPI is valid (push) Successful in 2m55s
CI / End-to-end (push) Successful in 8m3s
CI / Docker image builds (push) Successful in 1m46s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 13m23s
feat(gmail): take the star off a mail once it has become a task
2026-09-04 09:06:31 +02:00
Kristian Wind 274e392708 Merge pull request #55 from kristianwind/offline-cache
feat(offline): hold through a short network drop with a caching worker
2026-09-04 08:57:38 +02:00
kristianwindandClaude Opus 4.8 84ca506961 feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only
grew — the IMAP side has always unflagged what it imports, and Gmail could not,
because the scope was gmail.readonly. This gives Gmail the same one write: after
the task is made, the STARRED label comes off the message, in one batchModify for
the whole sweep. Nothing else is touched — not the body, not other labels, and a
label trigger's label stays where it is, since that label is the person's own.

The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope
Google offers that can remove a label. An existing connection keeps working on its
old read-only grant — the sync still reads, the unstar is a best-effort 403 that is
logged, not fatal — and starts clearing stars once the account is reconnected and
the new grant is in place. It self-heals: a mail that could not be unstarred stays
starred, stays in the next query, and is tried again.

The consent screen now asks for more than it used to; the settings copy, the docs
and the scope table all say modify rather than read-only in the same change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:57:07 +02:00
kristianwindandClaude Opus 4.8 6db61b0cc7 test(e2e): block the service worker except where the test is about it
The caching worker controls every page once it registers, and Playwright's
page.route() does not see the fetches a worker makes — so the failed-load test,
which forces a 500 to check the error path, got the real 200 through the worker
and read as a broken feature. Block the worker by default and turn it back on only
for the offline test, which is the one that needs a real one.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:12:15 +02:00
kristianwindandClaude Opus 4.8 57689ddfbb feat(offline): hold through a short network drop with a caching worker
The service worker did Web Push and nothing else, with a note that a cache
serving yesterday's data was worse than an honest "cannot connect". This adds a
cache that does not do that: network-first for everything that can go stale, so a
reader online always gets the live answer and the cache is reached only when the
network cannot answer. The task and note lists stay live; they just no longer go
blank the moment the signal does — a reload through a tunnel, a lift or a
base-station handover comes back to the app instead of the browser's offline page.

Writes are untouched and still go straight to the network; api.js already retries
them across the same short drop. The shell cache is versioned so a deploy lands in
a fresh cache and the old one is dropped on activate; the data cache is cleared on
sign-out so the next person on the browser does not inherit it.

The old hand-registered /sw.js becomes SvelteKit's src/service-worker.js, which it
registers itself, and the push handlers move across unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 22:46:35 +02:00
Kristian Wind 7770ba9844 Merge pull request #54 from kristianwind/tasks-first-tap-rounded
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Successful in 11m2s
CI / Docker image builds (push) Successful in 2m40s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 15m27s
Rows: open on the first tap on iPhone, round the hover highlight
2026-09-03 22:23:08 +02:00
kristianwindandClaude Opus 4.8 55b8e438ad fix(rows): open on the first tap on iPhone, and round the hover highlight
On a touch screen a :hover on the tap target makes iOS spend the first tap
applying the hover and only open on the second — the double-tap Kristian hit
on tasks (and notes). The row hover is gated behind @media (hover: hover)
now, so a touch has no hover to apply and the first tap opens. The note
row's action buttons, which the hover used to reveal, are shown outright on
touch instead. And the task-row hover highlight is a rounded box (border
radius on the row), the same corner as a note in the list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 22:15:23 +02:00
Kristian Wind 1607a22f0c Merge pull request #53 from kristianwind/charcoal-separators
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m56s
CI / Docker image builds (push) Successful in 2m2s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 15m10s
Charcoal: lighter day separators in the note list
2026-09-03 20:54:56 +02:00
kristianwindandClaude Opus 4.8 8a5dfef41a fix(theme): lighter day separators in Charcoal
In Charcoal the note-list day separators used --ground, the darkest colour
in the theme, so they read as dark bars on the lighter list. They take the
menu's own resting grey (#2d3133) now — the colour "Projekter" sits on —
via a --note-group-bg fallback token, so every other theme keeps --ground.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 20:46:51 +02:00
Kristian Wind 1a610eaac4 Merge pull request #52 from kristianwind/notes-single-tap
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m25s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 15m11s
Notes: open on the first tap, not the second
2026-09-03 20:35:30 +02:00
kristianwindandClaude Opus 4.8 c2d4ce0179 fix(notes): open a note on the first tap, not the second
On a phone the list/editor switch keyed on the loaded note, which arrives a
moment after the tap — so the first tap looked like nothing happened and
people tapped again. It keys on the selected id now, set the instant a row
is tapped, so the pane switches at once; the back button is there
immediately and the body fills in when it arrives, with a quiet pane rather
than the "pick a note" prompt in the half-second between.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 20:27:28 +02:00
Kristian Wind a1b8cc6ddc Merge pull request #51 from kristianwind/notes-mobile-detail
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m51s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m24s
Notes on a phone: show the note (master-detail layout)
2026-09-03 19:15:05 +02:00
kristianwindandClaude Opus 4.8 8a2c46f5ea fix(notes): show the note on a phone — one pane at a time
On a phone the note list and the editor stacked in one column, so a tapped
note opened below a full-height list, out of sight — "kan vælge en note men
kan ikke se indholdet". Now it is master-detail: the list fills the screen
until a note is opened, the editor takes over once one is, and an iOS-style
"‹ Noter" at the top of the editor goes back. The cards drop their border
and corners on one column, where there is nothing beside them.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 19:07:02 +02:00
Kristian Wind 8d42f34065 Merge pull request #50 from kristianwind/charcoal-apple-colors
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m48s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m30s
Match Charcoal theme to Apple Notes' exact colours
2026-09-03 17:51:16 +02:00
kristianwindandClaude Opus 4.8 905ceebc06 fix(theme): match Charcoal to Apple Notes' sampled colours
Kristian sent the exact values; the first Charcoal was a neutral grey guess
and missed them. Now: three panel greys, lightest sidebar (#2d3133) to
darkest editor (#1e1e1e) with the list (#242b2e) between — the reverse of
the usual sunken-is-darkest order, so the editor takes its own --editor-bg
rather than sharing the list's surface. The accent is amber: the selected
menu item is solid #ffce35 with dark text, a selected note is gold #a78c26
with dark text, and the section headings drop to a quiet grey (#8a8e90).

Each per-element colour is a fallback token only Charcoal sets, so every
other theme reads through to the shared token unchanged. Verified the
computed values land on Kristian's samples exactly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 17:42:55 +02:00
107 changed files with 4948 additions and 273 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
| Times on tasks, set by dragging in the week grid | Done, tested |
+2 -2
View File
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
```
### Projects and sections
@@ -108,7 +108,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| Method | Path | |
|---|---|---|
| `GET` | `/today` | Overdue and due today, in your timezone. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
| `GET` | `/search?q=` | Across everything you can see. |
| `GET` `POST` | `/filters` | |
| `GET` | `/filters/{id}/tasks` | Run one. |
+1 -1
View File
@@ -74,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
| For | Enable | Scope |
|---|---|---|
| Mailboxes | Gmail API | `gmail.readonly` |
| Mailboxes | Gmail API | `gmail.modify` |
| Calendar | Google Calendar API | `calendar.readonly` |
| Variable | What it does |
+25 -5
View File
@@ -18,9 +18,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
## What it does
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, and foldable groups over the projects in the sidebar.
The calendar draws each task to its length — drag the foot to resize, the body
@@ -39,8 +40,17 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
- **Live sync** — a change made by somebody else appears without a refresh.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
comes off once it has, so a starred inbox empties as the tasks are made instead
of only filling up.
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
rather than private formats.
- **Six themes**, three dark and three light — including *Charcoal*, a warm
near-black after Apple Notes — and a size and typeface chosen apart from them.
- **Holds through a short network drop.** A save rides out a few seconds of no
signal and goes through when it comes back; a reload opens from what it last saw
rather than the browser's offline page. It installs as a PWA.
## What it looks like
@@ -60,6 +70,16 @@ columns.*
marked with an arrow to them, so a delegated task reads as delegated wherever you
meet it.*
![Notes in the Charcoal theme](screenshots/notes-charcoal.png)
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
amber where the accent green usually is.*
![The six themes as swatches](screenshots/themes.png)
*Six themes — three dark, three light — chosen on the settings page, with the
typeface and how tightly it sits set apart from them.*
![Settings, integrations](screenshots/settings.png)
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
+3 -3
View File
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
labels and recurrence in either language.
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
projects, labels and recurrence in either language.
## What it can reach
+230 -8
View File
@@ -832,9 +832,9 @@ paths:
tags: [tasks]
summary: Create a task from one line of natural language.
description: |
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
vigtigt. An unknown `#project` is not an error — the task goes to the
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
labelled tax. An unknown `#project` is not an error — the task goes to the
Inbox rather than a thought being thrown away over a typo.
requestBody:
required: true
@@ -1250,7 +1250,7 @@ paths:
The one click that opens Google's consent screen already exists. What it
needs behind it is a registered client, and no app can conjure one: Google
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
issues no Gmail access to an unregistered client, and `gmail.modify` is a
restricted scope, so an id shipped inside a public image would be both
extractable from it and unusable without Google's security review of every
deployment that used it.
@@ -2365,7 +2365,12 @@ paths:
tags: [views]
summary: The next few days, empty ones included.
parameters:
- { name: days, in: query, schema: { type: integer, default: 7 } }
- {
name: days,
in: query,
description: "How many days to return, counting today. Above the maximum is capped, not refused.",
schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
}
responses:
"200":
description: OK
@@ -2713,6 +2718,25 @@ paths:
responses:
"200": { $ref: "#/components/responses/MailAddress" }
/api/v1/hook-url:
get:
tags: [integrations]
summary: Your personal address for pushing a task in from somewhere else.
description: >
Minted on first ask. Its own token rather than the mail one: the two are
the same kind of secret, but they leak separately — a URL that has been
sitting in a shortcut on a lost phone has to be replaceable without
changing the address other people have in their address books.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/hook-url/rotate:
post:
tags: [integrations]
summary: Issue a new push address, retiring the old one at once.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/notifications:
get:
tags: [integrations]
@@ -3012,6 +3036,12 @@ paths:
post:
tags: [notes]
summary: Write a note.
description: >
`#Projekt` files the note against a project and `[[Another note]]` links to
one. A third, `@Name`, shares the note with that person as a viewer and
tells them — only the owner's mentions share, and only a name that has just
appeared in the text counts, so a note saved on every typing pause does not
share itself twice.
requestBody:
required: true
content:
@@ -3075,6 +3105,11 @@ paths:
patch:
tags: [notes]
summary: Change a note. Every field is optional; what is sent is what changes.
description: >
A name written with an `@` that was not in the text before shares the note
with that person as a viewer and tells them. Only the owner's mentions
share — an editor may write the name, but giving somebody else's note away
is the owner's alone — and a role already granted is never lowered by one.
requestBody:
required: true
content:
@@ -3121,25 +3156,103 @@ paths:
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
invites:
description: >
Addresses invited to the note that have not been taken up yet.
There is no account behind them, so there is no name or colour
to show — the address is all there is until they arrive.
type: array
items: { $ref: "#/components/schemas/NoteInvite" }
follows:
description: >
The notes this one points at with [[links]], which are shared
alongside it. Owner's own notes only.
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [notes]
summary: Share a note with a person, or change the role they hold.
description: >
The person is named either by `user_id` — somebody picked from the
candidates — or by `email`. An address that already has an account is
shared with straight away; one that does not gets an invitation, which
becomes the share the moment the account is created (201).
The notes this one points at with [[links]] are shared with the same person
and the same role, so a link in a shared note leads somewhere. Only notes
the sharer owns follow, and the response lists them.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [user_id]
description: One of user_id or email.
properties:
user_id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
responses:
"204": { description: Shared }
"200":
description: Shared
content:
application/json:
schema:
type: object
properties:
user:
description: >
Who it was shared with. Sent back because a caller that shared
by address does not otherwise know who it reached.
$ref: "#/components/schemas/Person"
role: { type: string, enum: [viewer, editor] }
follows:
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"201":
description: >
Invited. The address has no account yet, so a link that creates one and
the share together has been issued.
content:
application/json:
schema:
type: object
properties:
invited: { $ref: "#/components/schemas/NoteInvite" }
link:
description: The signup link, shown so it can be passed on.
type: string
emailed:
description: >
False when the instance has no mail configured, which is when
the link is the only way the invitation reaches anybody.
type: boolean
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/notes/{noteID}/invites/{inviteID}:
delete:
tags: [notes]
summary: Withdraw an invitation to a note before it is used.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/{noteID}/shares/{userID}:
delete:
tags: [notes]
@@ -3658,6 +3771,69 @@ paths:
task_id: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
/inbound/hook/{token}:
post:
tags: [integrations]
summary: Push a task into somebody's inbox from another program.
description: |
The token in the path is the whole credential — the caller is a shortcut on
a phone or a script, and neither can sign in. A token here can do one
thing: create a task in that person's inbox. An API token can do everything,
which is why this is not one.
The body is read three ways, because those are the three you meet: JSON from
a service, a form field from a webhook form, and plain text from a shortcut
that just sends what was selected. The first line becomes the task and the
rest sits underneath it, and the line goes through the quick-add parser — so
"Ring til Anders i morgen p1 #Firma" means here what it means in the box at
the top of the app.
POST only. A GET would be easier to call, and that is exactly the problem:
a browser prefetch, a link check in a chat, and a revisited history entry
would all create tasks nobody asked for.
security: []
parameters:
- name: token
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
text/plain:
schema:
type: string
description: First line the task, the rest its description.
application/json:
schema:
type: object
properties:
text: { type: string }
title: { description: Same as text. }
content: { description: Same as text. }
note: { type: string }
body: { description: Same as note. }
description: { description: Same as note. }
application/x-www-form-urlencoded:
schema:
type: object
properties:
text: { type: string }
note: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
task_id: { type: string }
content: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
"422": { $ref: "#/components/responses/Validation" }
"429": { description: Too many pushes; the address is rate limited. }
/oauth/gmail/callback:
get:
tags: [integrations]
@@ -3924,6 +4100,15 @@ components:
type: array
items: { type: string }
HookURL:
description: The address another program pushes to.
content:
application/json:
schema:
type: object
properties:
url: { type: string }
schemas:
Calendar:
type: object
@@ -4030,6 +4215,33 @@ components:
properties:
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [viewer, editor] }
linked:
description: >
True when the person has this note because another shared note points
at it, rather than because it was shared with them on its own. Such a
grant is taken back by removing the link or the share it came from, not
by removing it here.
type: boolean
via:
description: The title of the shared note that points at this one.
type: string
NoteFollow:
type: object
description: A note that is shared alongside another because that one links to it.
properties:
note_id: { type: string }
title: { type: string }
NoteInvite:
type: object
description: >
A share on a note that is waiting for an account. The address is the whole
of who the person is until they sign up.
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
Mailbox:
type: object
@@ -4519,12 +4731,22 @@ components:
Notification:
type: object
description: >
Something somebody else did that you need to know about. `kind` says what
happened and the client composes the sentence from it, so the bell follows
the reader's language; `title` is the server's own Danish wording, used for
Web Push, which has no dictionary to look words up in.
properties:
id: { type: string }
actor_name: { type: string }
project_id: { type: string }
task_id: { type: string }
kind: { type: string }
note_id:
description: Set on a notification about a note — a shared note somebody else edited.
type: string
kind:
type: string
enum: [assigned, note.changed, comment]
title: { type: string }
body: { type: string }
read: { type: boolean }
+5 -5
View File
@@ -3,11 +3,11 @@
One line in, a fully-formed task out.
```
betal moms i morgen kl 10 p1 #Firma @regnskab
file the VAT return tomorrow at 10am p1 #Accounts @tax
```
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
Firma project, labelled regnskab.
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
in the Accounts project, labelled tax.
The parts it understands are tinted underneath the text as you type, so you can see
it being read before you commit to it.
@@ -16,8 +16,8 @@ it being read before you commit to it.
| You type | It means |
|---|---|
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
| `@regnskab` | Add that label. As many as you like. |
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
| `@tax` | Add that label. As many as you like. |
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
| `mandag`, `på fredag` | The next such weekday. |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 220 KiB

+36 -9
View File
@@ -1,14 +1,20 @@
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The connection is one-way on purpose: a starred or labelled message becomes a
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
// message does not delete the task, completing the task does not unstar the
// message. Two-way would mean deciding what to do when both sides changed, and the
// only honest answer to that is a synchronisation model far bigger than the feature.
// The one thing verdande writes back is the star. A starred message becomes a
// task, and once it has, the star comes off — exactly as the IMAP side takes the
// flag off a mail it has imported, and for the same reason: the star meant "turn
// this into a task", the task now exists, so the star is no longer true. Nothing
// else is touched. Completing the task does not reach back to the mail; the mail
// is a pointer the task was made from, not a copy the two have to be kept in step.
//
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
// asking for less is what makes the consent screen something a person can agree to.
// That one write is why the scope is gmail.modify rather than gmail.readonly.
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
// can do it — so the consent screen asks for more than it used to, and the trade
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
// than one that fills with stars nothing ever clears. Still not send, still not
// delete — modify is labels, and the only label verdande removes is the one that
// triggered the task.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
@@ -27,8 +33,10 @@ import (
"github.com/kristianwind/verdande/internal/google"
)
// Read-only, and only mail. Not profile, not contacts, not send.
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
// Read mail and change its labels, and only mail. Not profile, not contacts, not
// send, not delete. The single write is removing the STARRED label from a message
// that has become a task; see the package comment for why that costs modify.
const Scope = "https://www.googleapis.com/auth/gmail.modify"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// Unstar takes the star off the given messages — one call for all of them, the way
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
// not ten.
//
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
// label would need one, and verdande removes none. batchModify answers 204 with no
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
// an error on Google's side and nothing worth making a round trip to hear.
func (c *Client) Unstar(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
body := struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
}
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
// falling back to the address when there is no display name.
func SenderName(from string) string {
+69 -4
View File
@@ -1,16 +1,81 @@
package gmail
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The scope is the one thing verdande chooses about the connection, and it is the
// one the person reads on the consent screen. The flow itself is exercised in
// internal/google; what belongs here is that Gmail still asks for read-only mail.
func TestTheScopeIsReadOnlyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.readonly") {
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
// internal/google; what belongs here is that Gmail asks for modify — read plus the
// one write it makes, taking the star off an imported message — and nothing wider.
func TestTheScopeIsModifyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.modify") {
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
}
// Modify is the ceiling. Send, or the full account, would be a different promise
// to the person on the consent screen than the one this feature needs.
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
if strings.Contains(Scope, wider) {
t.Errorf("scope = %q; must not include %q", Scope, wider)
}
}
}
// Unstar removes the star from a batch in one call, and it addresses the messages
// it was given and only the STARRED label.
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
var gotPath string
var gotBody struct {
IDs []string `json:"ids"`
AddLabelIDs []string `json:"addLabelIds"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
}))
defer srv.Close()
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
if err != nil {
t.Fatalf("Unstar: %v", err)
}
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
}
if strings.Join(gotBody.IDs, ",") != "a,b" {
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
}
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
}
if len(gotBody.AddLabelIDs) != 0 {
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
}
}
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
// there is nothing to say to it anyway.
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
defer srv.Close()
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
t.Fatalf("Unstar(nil): %v", err)
}
if called {
t.Error("Unstar(nil) made a request; it should make none")
}
}
+34
View File
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
return json.NewDecoder(resp.Body).Decode(out)
}
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
// body coming back. A write call — modifying labels on a message — answers with
// 204 No Content when it worked, so anything 2xx is success and only the body is
// decoded, and only when the caller asked for one.
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("google: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
+31 -6
View File
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// takes a context and a user rather than a request.
// maxGmailSeen bounds the list of message ids already turned into tasks.
//
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
// asking for less is what makes the consent screen something a person can agree
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
// record has to live on this side, and a record on this side has to be bounded.
// The star is taken off an imported message now, so on a starred trigger the mail
// leaves the query on its own and this list is a safety net rather than the only
// record. It still earns its place: it covers the window between making the task
// and the star coming off, and it is the whole of the dedup for a label trigger,
// where the label is the person's and not ours to remove. A record on this side
// has to be bounded.
//
// A count rather than the previous rule of "whatever Gmail returned this run",
// which was not a bound but a way of forgetting.
@@ -381,6 +383,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// The star comes off every message this query returned that now has a task —
// the ones made this run and the ones a run before it already made. Gated to the
// triggers that are about the star: for a label trigger the star is the person's
// own and not ours to touch, and dedup there rests on the seen list alone.
//
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
// that would not come off. The likeliest reason it will not, right after this
// ships, is a connection still holding the old read-only grant — the write is a
// 403 until the account is reconnected, and this logs it rather than failing the
// sync. It self-heals: the mail stays starred, stays in the next query, and is
// tried again once the grant is there.
if box.Trigger == "starred" || box.Trigger == "both" {
unstar := make([]string, 0, len(ids))
for _, id := range ids {
if seen[id] {
unstar = append(unstar, id)
}
}
if err := client.Unstar(ctx, unstar); err != nil {
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
}
}
if created > 0 {
// The ids this run saw, on top of the ones already known.
//
@@ -442,8 +467,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
//
// The one click that opens Google's consent screen already exists; what it needs
// behind it is a registered client, and that is not something any app can conjure:
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
// a restricted scope, so an id shipped inside a public image would be both
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
// restricted scope, so an id shipped inside a public image would be both
// extractable from it and unusable without Google's security review of every
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
// recreate the container to paste the result was not.
+82 -1
View File
@@ -1,14 +1,17 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// A Gmail failure has to survive being reported.
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
// A message that becomes a task has its star taken off — the write that the whole
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
// the way the IMAP side unflags what it imports.
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
var unstarred struct {
hit bool
body struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
}
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
unstarred.hit = true
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &unstarred.body)
w.WriteHeader(http.StatusNoContent)
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
writeTestJSON(w, map[string]any{
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
"payload": map[string]any{"headers": []map[string]string{
{"name": "From", "value": "Anders <[email protected]>"},
{"name": "Subject", "value": "Møde på fredag"},
}},
})
case strings.HasSuffix(r.URL.Path, "/messages"):
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
default:
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A live access token, so the run goes straight to the list without a refresh.
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)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
}
if created, _ := body["created"].(float64); created != 1 {
t.Fatalf("created = %v, want 1", body["created"])
}
if !unstarred.hit {
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
}
if strings.Join(unstarred.body.IDs, ",") != "m1" {
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
}
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
+194
View File
@@ -0,0 +1,194 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
// prøves her.
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
// hookURL henter personens egen krog-adresse, som fladen viser den.
func (ts *testServer) hookURL(t *testing.T) string {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
}
url, _ := out["url"].(string)
if !strings.Contains(url, "/inbound/hook/") {
t.Fatalf("url = %q", url)
}
return url
}
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
// skal kunne kopieres ind i en genvej på en telefon.
func hookPath(url string) string {
return url[strings.Index(url, "/inbound/hook/"):]
}
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
// betyder det samme, som den ville betyde i feltet øverst i fladen.
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, out := anon.doRaw(t, "POST", path, "text/plain",
"Ring til Anders p1\nhan ringede i går")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
}
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
tasks, _ := list["tasks"].([]any)
var found map[string]any
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] == "Ring til Anders" {
found = task
}
}
if found == nil {
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
}
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
// teksten, og resten af kroppen står under opgaven.
if found["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", found["priority"])
}
if found["description"] != "han ringede i går" {
t.Errorf("beskrivelse = %v", found["description"])
}
}
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
func TestTheHookReadsJSON(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
for _, body := range []string{
`{"text": "Køb mælk"}`,
`{"title": "Køb mælk"}`,
`{"content": "Køb mælk"}`,
} {
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
}
if out["content"] != "Køb mælk" {
t.Errorf("%s gav %v", body, out["content"])
}
}
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
}
}
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
// Alt andet er en maskine til at gætte tokens med.
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
}
}
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
// tilbage: adressen står i en genvej på en telefon eller i et script.
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
old := hookPath(ts.hookURL(t))
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("skift: %d", resp.StatusCode)
}
fresh := hookPath(out["url"].(string))
if fresh == old {
t.Fatal("den nye URL er den gamle")
}
anon := newSignedOut(t, ts)
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
}
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
}
}
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
// fordi den ser ud som om integrationen virker.
func TestAnEmptyPushIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
}
}
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
url := ts.hookURL(t)
token := url[strings.LastIndex(url, "/")+1:]
anon := newSignedOut(t, ts)
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := anon.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatal("krog-tokenet gav adgang til API'et")
}
}
+187
View File
@@ -2,7 +2,9 @@ package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
@@ -155,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
}
// --- en krog ind i indbakken --------------------------------------------------------
type hookURLResponse struct {
URL string `json:"url"`
}
// handleGetHookURL returns the personal address another program can push a task
// to, minting the token on first ask.
//
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
// — fra en genvej på telefonen, et script, en anden tjeneste.
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
// handleRotateHookURL mints a new one and retires the old.
//
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
// tilbage end tokenet selv.
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, r, "generate hook token", err)
return
}
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, r, "set hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
func (s *Server) hookURL(token string) string {
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
}
type hookRequest struct {
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
// op.
Text string `json:"text"`
Title string `json:"title"`
Content string `json:"content"`
// Note er det, der står under opgaven. `body` og `description` ligeså.
Note string `json:"note"`
Body string `json:"body"`
Description string `json:"description"`
}
func (h hookRequest) line() string {
for _, v := range []string{h.Text, h.Title, h.Content} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func (h hookRequest) note() string {
for _, v := range []string{h.Note, h.Body, h.Description} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
//
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
// den ene persons indbakke — hvor et API-token kan alt.
//
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
//
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
if err != nil {
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
// kender forskel, er en maskine til at gætte tokens med.
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
return
}
line, note := s.readHookBody(r)
if strings.TrimSpace(line) == "" {
writeFieldErrors(w, map[string]string{"text": "required"})
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
content := parsed.Content
if strings.TrimSpace(content) == "" {
content = line
}
projectID := ""
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
task := &store.Task{
ProjectID: projectID, Content: content, Priority: parsed.Priority,
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from hook", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
s.log.Info("task created from hook", "user", user.ID)
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
}
// readHookBody er de tre måder, en linje tekst kommer ind ad.
//
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
//
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
func (s *Server) readHookBody(r *http.Request) (line, note string) {
const maxBody = 64 << 10
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil {
return "", ""
}
kind := r.Header.Get("Content-Type")
switch {
case strings.Contains(kind, "application/json"):
var req hookRequest
if err := json.Unmarshal(body, &req); err != nil {
return "", ""
}
return req.line(), req.note()
case strings.Contains(kind, "application/x-www-form-urlencoded"):
values, err := url.ParseQuery(string(body))
if err != nil {
return "", ""
}
req := hookRequest{
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
}
return req.line(), req.note()
}
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
// man har delt fra en anden app, ud — en overskrift og noget under.
text := strings.TrimSpace(string(body))
first, rest, _ := strings.Cut(text, "\n")
return first, strings.TrimSpace(rest)
}
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
func mailToken(address string) string {
if i := strings.LastIndex(address, "<"); i >= 0 {
+3 -1
View File
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"links to a note by its title. A third, `@Navn`, shares the note with that " +
"person and tells them — so only write one when the note is meant for them. " +
"Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
+185
View File
@@ -204,9 +204,152 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "create note", err)
return
}
// En ny note kan være den, en delt note hele tiden har peget på: linket er
// skrevet som en titel, og titlen fandtes ikke før nu.
//
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
// nok til at nå at taste den første linje ind i den forrige note, som så blev
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
if strings.TrimSpace(n.Body) != "" {
s.followLinks(r, n)
s.shareWithMentioned(r, n, "")
}
writeJSON(w, http.StatusCreated, n)
}
// followLinks holder de udledte delinger i takt med teksten.
//
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
// samme sted hen for begge.
//
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
// gemning talte med, kunne den, man har delt en note med, skrive
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
//
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
//
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
func (s *Server) followLinks(r *http.Request, n *store.Note) {
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
return
}
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
s.log.Error("sync linked shares", "err", err, "note", n.ID)
}
}
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
//
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
//
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
//
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
// kun besked til en, der kan se noten i forvejen.
//
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
// fortæller dem, at noten er blevet rettet.
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
told := map[string]bool{}
if n == nil || n.CreatedBy == "" {
return told
}
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
// deles med den, der har den.
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
if err != nil {
s.log.Warn("people for mentions", "err", err, "note", n.ID)
return told
}
was := map[string]bool{}
for _, id := range store.MentionsIn(before, people) {
was[id] = true
}
fresh := []string{}
for _, id := range store.MentionsIn(n.Body, people) {
if !was[id] {
fresh = append(fresh, id)
}
}
if len(fresh) == 0 {
return told
}
actor := userFrom(r.Context())
owner := actor.ID == n.CreatedBy
shared := false
for _, id := range fresh {
if !owner {
continue
}
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
// skrev deres navn i teksten.
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
s.log.Warn("mention share role", "err", err, "note", n.ID)
continue
} else if has {
continue
}
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
continue
}
shared = true
}
if shared {
// Og det, noten peger på, følger med delingen — samme vej som en deling
// sat i panelet.
s.followLinks(r, n)
}
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
return told
}
canSee := map[string]bool{}
for _, id := range audience {
canSee[id] = true
}
for _, id := range fresh {
if !canSee[id] {
continue
}
s.notify(r, &store.Notification{
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
})
told[id] = true
}
return told
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
@@ -242,6 +385,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
before := n.Body
if body.Body != nil {
n.Body = *body.Body
}
@@ -249,13 +393,51 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
n.Pinned = *body.Pinned
}
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
// køre, når svaret kan være blevet et andet, og ellers ikke.
wasLinking := store.LinksIn(before)
wasTitled := n.Title
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "update note", err)
return
}
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
// det, andres links peger på, uden at et eneste link er rørt.
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
s.followLinks(r, n)
}
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
// det ville lære folk at overse dem alle sammen.
if before != n.Body {
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
// ikke også "rettede en note" om den samme gemning — det er én handling, og
// den ene sætning siger mere end den anden.
mentioned := s.shareWithMentioned(r, n, before)
s.notifyNoteChanged(r, n, mentioned)
}
writeJSON(w, http.StatusOK, n)
}
func sameLinks(a, b []store.NoteLink) bool {
if len(a) != len(b) {
return false
}
in := make(map[store.NoteLink]bool, len(a))
for _, l := range a {
in[l] = true
}
for _, l := range b {
if !in[l] {
return false
}
}
return true
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
@@ -279,6 +461,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "delete note", err)
return
}
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
s.followLinks(r, n)
w.WriteHeader(http.StatusNoContent)
}
+186
View File
@@ -0,0 +1,186 @@
package httpapi
import (
"net/http"
"strings"
"testing"
)
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]", "role": "editor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
}
invited, _ := body["invited"].(map[string]any)
if invited == nil || invited["email"] != "[email protected]" {
t.Fatalf("the response does not say who was invited: %v", body)
}
if invited["role"] != "editor" {
t.Errorf("role = %v, want the one the owner chose", invited["role"])
}
// Uden en postserver er linket det eneste, der findes, og det skal stå i
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
link, _ := body["link"].(string)
if !strings.Contains(link, "token=") {
t.Fatalf("link = %q, want one carrying a token", link)
}
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
if len(pending) != 1 {
t.Fatalf("want one pending invite under the note, got %v", pending)
}
// Og linket laver kontoen og delingen i ét.
token := link[strings.Index(link, "token=")+len("token="):]
fresh := newSignedOut(t, owner)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Sofie", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
}
// Noten er hendes at læse — og som medredaktør også at rette.
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
}
if note["title"] != "Aftale om levering" {
t.Errorf("the invited person got something else: %v", note)
}
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
}
// Og invitationen er brugt op: den står ikke længere og venter.
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
if left, _ := after["invites"].([]any); len(left) != 0 {
t.Errorf("the invite is still pending after it was accepted: %v", left)
}
shares, _ := after["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("want the accepted invite to be a share, got %v", shares)
}
}
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
// invitation til at oprette en, som personen aldrig kan komme igennem.
func TestSharingByEmailFindsAnExistingAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Prisliste\n2026")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
}
person, _ := body["user"].(map[string]any)
if person == nil || person["id"] != sofie {
t.Fatalf("the response does not say who it reached: %v", body)
}
if _, invited := body["invited"]; invited {
t.Error("an address with an account was invited rather than shared with")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "viewer" {
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
}
}
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
// det samme, og kun det ene kan trækkes tilbage ad gangen.
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("first invite: status %d", resp.StatusCode)
}
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
}
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
id := pending[0].(map[string]any)["id"].(string)
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
}
}
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
func TestOnlyTheOwnerCanInviteToANote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "[email protected]"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
}
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
t.Fatalf("invites = %v (%v), want none", n, err)
}
}
// En invitation til én note kan ikke bruges til at rydde en anden notes
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
mine := owner.createNote(t, "# Min note\n1")
other := owner.createNote(t, "# Min anden note\n2")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
t.Fatal("invite failed")
}
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
if err != nil || len(invites) != 1 {
t.Fatalf("invites = %v (%v)", invites, err)
}
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
}
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
t.Error("the invite was withdrawn through a note it does not belong to")
}
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
// noten, og den, der bliver nævnt, får besked om det.
func TestMentioningSomebodySharesTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret noten: %d", resp.StatusCode)
}
// Delt, som læser.
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || !has || role != "viewer" {
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
}
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
// handling, og den ene sætning siger mere end den anden.
list, unread := notifications(t, other)
if len(list) != 1 || unread != 1 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
}
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
if list, _ := notifications(t, other); len(list) != 1 {
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
}
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
}
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
// omtaler mere.
list, _ := notifications(t, other)
if len(list) != 2 {
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
}
if firstOf(t, list)["kind"] != "note.changed" {
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
}
}
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
editor := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
peterClient := owner.newUser(t, "[email protected]", "Peter")
peter := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren retter")
}
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
}
// Og Peter får ikke besked om en note, han ikke kan åbne.
if list, _ := notifications(t, peterClient); len(list) != 0 {
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
}
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
if list, _ := notifications(t, owner); len(list) == 0 {
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
}
}
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "editor" {
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
}
}
+215 -8
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
@@ -77,12 +78,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list note shares", err)
return
}
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
// med den her. To udregninger af det samme er to, der kan blive uenige.
//
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
// siden.
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
via := map[string]string{}
for _, l := range following {
if l.NoteID == n.ID {
via[l.UserID] = l.Via
}
}
out := make([]noteShareJSON, 0, len(shares))
already := map[string]bool{}
for _, sh := range shares {
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
// deling, den kom fra. En knap, der lover det første og gør det andet, er
// værre end ingen knap.
out = append(out, noteShareJSON{
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
Linked: via[sh.User.ID] != "",
Via: via[sh.User.ID],
})
already[sh.User.ID] = true
}
@@ -103,20 +129,86 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
// den samme indbakke, og ingen af dem til at trække tilbage.
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note invites", err)
return
}
pending := make([]noteInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"shares": out,
"candidates": cand,
"invites": pending,
"follows": followsOf(following, n.ID),
})
}
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
// følger med" er det samme, hvem det så end deles med.
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
seen := map[string]bool{}
out := []followJSON{}
for _, l := range all {
if l.ViaID != noteID || seen[l.NoteID] {
continue
}
seen[l.NoteID] = true
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
}
return out
}
type followJSON struct {
NoteID string `json:"note_id"`
Title string `json:"title"`
}
type noteShareJSON struct {
User personJSON `json:"user"`
Role string `json:"role"`
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
// fordi nogen har delt netop den. `via` er den note, der peger.
Linked bool `json:"linked"`
Via string `json:"via,omitempty"`
}
type shareNoteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
// Email deles der med, når personen ikke er at finde i listen — enten fordi
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
// finde endnu.
Email string `json:"email"`
Role string `json:"role"`
}
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
// dukker op.
type noteInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
//
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
//
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
// vent på at de opretter sig, find noten frem, del den.
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
var req shareNoteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -140,25 +232,133 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
return
}
if req.UserID == "" {
me := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
// — samme regel som en projektinvitation følger.
if req.UserID == "" {
if !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if email == store.NormalizeEmail(me.Email) {
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
return
}
existing, err := s.db.UserByEmail(r.Context(), email)
if err != nil {
s.inviteToNote(w, r, n, email, role)
return
}
req.UserID = existing.ID
}
// The recipient must be a real account and not the sharer themselves; ShareNote
// refuses the owner, and a made-up id should not reach it.
if req.UserID == userFrom(r.Context()).ID {
if req.UserID == me.ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
s.storeError(w, r, "share note", err)
return
}
s.notifyNoteShared(r, n, req.UserID)
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"follows": followsOf(followed, n.ID),
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
// derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
}
// inviteToNote sends a link that creates an account and the share at once.
//
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
// på den.
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
me := userFrom(r.Context())
// En anden invitation til den samme note i den samme indbakke er ikke en
// invitation mere: det er to links, der gør det samme, og kun det ene kan
// trækkes tilbage ad gangen.
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
if err != nil {
s.internal(w, r, "pending note invite", err)
return
}
if pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
return
}
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create note invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
s.log.Error("send note invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
"link": link,
"emailed": emailed,
})
}
// handleDeleteNoteInvite withdraws an invitation before it is used.
//
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
// kan kun rækken, det hører til.
func (s *Server) handleDeleteNoteInvite(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
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete note invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -177,5 +377,12 @@ func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "unshare note", err)
return
}
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
// bliver stående, og en, der ikke kan, forsvinder.
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+100 -3
View File
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
// Share it as a viewer.
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusNoContent {
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
noteID := owner.createNote(t, "# Delt\noprindelig")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share as editor failed")
}
@@ -193,7 +193,104 @@ func TestShareRejectsUnknownAndSelf(t *testing.T) {
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
t.Error("a note was shared at the owner role")
}
}
// De noter, en delt note peger på, følger med — og holder op igen.
//
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
// må følge med, og vejen tilbage.
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
deep := owner.createNote(t, "# Leveringstider\nfire dage")
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
reachable := func(id string) bool {
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
return resp.StatusCode == http.StatusOK
}
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
// til at overskue.
follows, _ := out["follows"].([]any)
if len(follows) != 2 {
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
}
if !reachable(linked) {
t.Error("noten, den delte note peger på, fulgte ikke med")
}
if !reachable(deep) {
t.Error("kæden stoppede efter ét led")
}
if reachable(apart) {
t.Error("en note, ingen peger på, blev delt")
}
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
// samme sted hen for begge.
senere := owner.createNote(t, "# Rabatter\nti procent")
if reachable(senere) {
t.Fatal("en helt ny note var delt, før noget pegede på den")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("kunne ikke skrive videre i den delte note")
}
if !reachable(senere) {
t.Error("et link skrevet efter delingen fulgte ikke med")
}
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("unshare: status %d", resp.StatusCode)
}
if reachable(linked) || reachable(deep) || reachable(senere) {
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
}
}
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
//
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
noteID := owner.createNote(t, "# Fælles\nintet link her")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("share as editor failed")
}
// Hun skriver linket selv, i den note hun har fået lov at rette i.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren kunne ikke skrive")
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
}
}
+99 -5
View File
@@ -19,6 +19,7 @@ type notificationJSON struct {
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
NoteID string `json:"note_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
@@ -102,6 +104,89 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// notifyAssigned tells somebody a task is now theirs.
//
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
// hvad de lige har gjort.
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
if t == nil || t.AssigneeID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
})
}
// notifyNoteShared tells somebody that a note is now theirs to read.
//
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
//
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
// ikke en ny note at læse.
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
if n == nil || userID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
//
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
if n == nil {
return
}
actor := userFrom(r.Context())
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
return
}
title := actor.Name + " rettede en note"
body := n.Title
for _, userID := range audience {
if userID == actor.ID || except[userID] {
continue
}
// Fold first, and only write a row when there was nothing to fold into.
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
// hvor klokken siger to.
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
if err != nil {
s.log.Warn("fold note notification", "err", err, "note", n.ID)
continue
}
if folded {
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
// op og siger nu et andet navn. Uden det her opdager en åben fane først
// den sammenlagte rettelse ved næste genindlæsning.
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.changed", Title: title, Body: body,
})
}
}
// 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) {
@@ -152,7 +237,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
// stadig en besked.
unread, err := s.db.UnreadNotificationCount(ctx, userID)
if err != nil {
s.log.Warn("unread count for push", "err", err)
}
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
+205
View File
@@ -0,0 +1,205 @@
package httpapi
import (
"net/http"
"testing"
)
// notifications reads somebody's bell.
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list notifications: status %d", resp.StatusCode)
}
list, _ := out["notifications"].([]any)
unread, _ := out["unread"].(float64)
return list, unread
}
func firstOf(t *testing.T, list []any) map[string]any {
t.Helper()
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
n, _ := list[0].(map[string]any)
return n
}
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
projectID, _ := project["id"].(string)
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
// medlem med det samme.
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "[email protected]", "role": "editor"}); resp.StatusCode >= 300 {
t.Fatalf("invitér: %d", resp.StatusCode)
}
resp, task := owner.do(t, "POST", "/api/v1/tasks",
map[string]any{"project_id": projectID, "content": "ring til revisor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("opret opgave: %d", resp.StatusCode)
}
taskID, _ := task["id"].(string)
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
before, _ := notifications(t, other)
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel: %d", resp.StatusCode)
}
list, _ := notifications(t, other)
if len(list) != len(before)+1 {
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
}
n := firstOf(t, list)
if n["kind"] != "assigned" {
t.Errorf("kind = %v, want assigned", n["kind"])
}
if n["task_id"] != taskID {
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
}
if n["actor_name"] != "Kristian" {
t.Errorf("actor_name = %v", n["actor_name"])
}
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
// ændring, gør — og så bliver klokken ubrugelig på en dag.
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret: %d", resp.StatusCode)
}
if now, _ := notifications(t, other); len(now) != len(before)+1 {
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
}
// Og den, der tildeler sig selv, får ingenting.
me := userID(t, owner, "[email protected]")
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
}
}
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
// så længe den ikke er læst.
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
sofie := userID(t, owner, "[email protected]")
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
// nogen retter i den. Den tælles med i alt, hvad der følger.
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
firstOf(t, list)["kind"] != "note.shared" {
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
}
// Ejeren skriver. Sofie skal høre det.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
list, unread := notifications(t, other)
if unread != 2 || len(list) != 2 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "note.changed" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
// ens linjer i klokken.
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten igen")
}
}
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
len(list))
}
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
t.Fatal("markér som læst")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten efter læsningen")
}
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
}
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
}
// Og den anden vej: Sofie skriver, ejeren hører det.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
t.Fatal("Sofie retter")
}
list, _ = notifications(t, owner)
if len(list) != 1 {
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
}
if firstOf(t, list)["actor_name"] != "Sofie" {
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
}
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
t.Fatal("stjernemarkér")
}
if list, _ := notifications(t, other); len(list) != 3 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
}
}
// En note, ingen deler, er ingen at give besked.
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "[email protected]", "Sofie")
noteID := owner.createNote(t, "# Min egen\nintet")
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
}
}
+22
View File
@@ -66,6 +66,7 @@ type Server struct {
// Sat kun af prøver; se icsClient().
icsFetch *http.Client
mailLimiter *limiter
hookLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
@@ -93,6 +94,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// day. Sixty a minute never touches real delivery and still puts a ceiling
// on the one public write path that has none.
mailLimiter: newLimiter(60, time.Minute),
hookLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
@@ -143,6 +145,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/inbound/mail",
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
//
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
@@ -319,6 +335,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
r.Get("/hook-url", s.handleGetHookURL)
r.Post("/hook-url/rotate", s.handleRotateHookURL)
// Personal API tokens. Behind requireSession rather than the ambient
// auth: a token must not be able to mint or revoke another.
r.Route("/tokens", func(r chi.Router) {
@@ -353,6 +372,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
// En invitation til en note er en deling, der venter på en konto.
// Den kan tages tilbage indtil den bruges; se handleShareNote.
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
+18
View File
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
t.Labels = req.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
// ind i "det her er dit nu" giver besked og den anden ikke.
s.notifyAssigned(r, t)
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
}
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
// det hver gang nogen retter en overskrift.
wasAssigned := ""
if req.AssigneeID != nil {
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
wasAssigned = before.AssigneeID
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
s.notifyAssigned(r, t)
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
+43
View File
@@ -285,6 +285,49 @@ func TestTodayAndUpcoming(t *testing.T) {
}
}
// The horizon is the list's, and it is asked for per request: seven days is the
// default, thirty is what Kommende offers for "when does this month get busy", and
// anything past the cap is trimmed rather than refused — a client that asks for a
// year gets a month, not a 400.
func TestUpcomingHorizon(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "om tyve dage",
"due_date": userDate(t, 20),
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, out)
}
count := func(query string) (int, int) {
t.Helper()
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
days, _ := view["days"].([]any)
var tasks int
for _, d := range days {
day, _ := d.(map[string]any)
list, _ := day["tasks"].([]any)
tasks += len(list)
}
return len(days), tasks
}
if n, found := count(""); n != 7 || found != 0 {
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
}
if n, found := count("?days=30"); n != 30 || found != 1 {
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
}
if n, _ := count("?days=365"); n != 31 {
t.Errorf("days=365 returned %d days, want it capped at 31", n)
}
if n, _ := count("?days=nonsense"); n != 7 {
t.Errorf("an unreadable days returned %d days, want the default 7", n)
}
}
func TestSearchFindsAcrossProjects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
+7 -2
View File
@@ -51,7 +51,8 @@ type upcomingDay struct {
Tasks []taskJSON `json:"tasks"`
}
// handleUpcoming returns the next seven days, one entry per day.
// handleUpcoming returns the next `days` days, one entry per day, seven by default
// and at most a month.
//
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
// missing from the response because nothing is due would collapse the layout and
@@ -65,10 +66,14 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
from := start.Format("2006-01-02")
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
// The row limit follows the window rather than sitting at a constant, or a
// month asked for with the same ceiling as a week would truncate the last days
// silently — and a day cut off the end of the answer looks exactly like a day
// with nothing due.
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DueFrom: from,
DueBefore: to,
Limit: 1000,
Limit: 200 * days,
})
if err != nil {
s.internal(w, r, "upcoming", err)
+20 -1
View File
@@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
what := "verdande"
if projectName != "" {
what = "projektet “" + projectName + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
// SendNoteInvite is the same letter about a single note.
//
// The title is in the mail because it is what makes the invitation mean anything —
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
// somebody can decide about, where "til en note" is one they have to click a link
// to understand. It travels no further than the address the owner typed, which is
// the address they chose to share the note with.
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
what := "en note"
if noteTitle != "" {
what = "noten “" + noteTitle + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
+7
View File
@@ -56,6 +56,13 @@ type Payload struct {
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
//
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
// er serverens, og serveren ved det i forvejen.
Unread int `json:"unread,omitempty"`
}
var b64 = base64.RawURLEncoding
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
}
return VAPID{Public: pub, Private: priv, Subject: "mailto:[email protected]"}
}
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
// ikke lykkedes.
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(with), `"unread":3`) {
t.Errorf("payload = %s, want the unread count in it", with)
}
without, err := json.Marshal(Payload{Title: "hej"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(without), "unread") {
t.Errorf("payload = %s, want no count when there is none to send", without)
}
}
+161 -36
View File
@@ -16,6 +16,7 @@ type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
NoteID string // set when the invite is to a single note
Role Role
CreatedBy string
CreatedAt time.Time
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
// CreateInvite issues an invite to a project and returns the token for the emailed
// link. projectID may be empty, which invites somebody to the instance rather than
// to a particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
}
// CreateNoteInvite issues an invite to a single note.
//
// Same row, same token, same link as a project invite — the difference is only
// what the person gets when they arrive, and that is settled in AcceptInvite. A
// second table for "invited to a note" would have meant a second signup path to
// keep in step with this one, and the two would drift the first time either was
// touched.
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if noteID == "" {
return "", nil, errors.New("store: a note invite needs a note")
}
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
}
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
if !inv.Role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
inv.ID = NewID()
inv.Email = NormalizeEmail(inv.Email)
inv.CreatedAt = now
inv.ExpiresAt = now.Add(ttl)
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var projectID, noteID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
Scan(&inv.ID, &inv.Email, &projectID, &noteID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
}
inv.ProjectID = projectID.String
inv.NoteID = noteID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
@@ -144,10 +156,16 @@ type PendingInvite struct {
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
// NoteID is set for an invitation to a single note. The note is named by its
// id and never by its title: titles are sealed in the database, and a private
// note's first line is not something an administrator's user list should
// spell out. That there is an account on its way is the administrator's
// business; what it was invited to read is not.
NoteID string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
@@ -159,7 +177,8 @@ type PendingInvite struct {
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
COALESCE(i.note_id, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// AcceptInvite marks the invite used and grants what it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
// same link produce one grant and one failure rather than two.
//
// What is granted depends on what the invite was to: a project membership, a share
// on a single note, or — when it carries neither — nothing beyond the account the
// caller has just created.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return ErrInviteInvalid
}
var projectID sql.NullString
var projectID, noteID sql.NullString
var role Role
var invitedBy string
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &noteID, &role, &invitedBy); err != nil {
return err
}
if noteID.Valid && noteID.String != "" {
// created_by is the person who invited, not the person arriving: the row
// records who did the sharing, the same as a share made from the panel.
//
// The note may have been deleted between the invite and the signup. The
// foreign key would refuse the row and take the whole signup with it, so
// the note is looked up first and a share on a note that is gone is
// simply not made — the account is still worth creating.
var alive int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
noteID.String, userID).Scan(&alive); err != nil {
return err
}
if alive == 0 {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
@@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
})
}
// --- invitationer til én note ----------------------------------------------------
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
// The email address is the whole of who they are — there is no account to name
// them by, which is the entire reason the row exists.
type NoteInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListNoteInvites is who has been invited to a note and not yet taken it up.
//
// The panel needs them beside the people who *are* on the note: without this, an
// invitation sent yesterday is invisible, and the owner sends it again — a second
// link to the same inbox, and no way to take either back.
//
// Expired ones are left out, the same rule the administrator's list follows: an
// invite past its date is not something anybody can act on.
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, noteID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []NoteInvite{}
for rows.Next() {
var i NoteInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
// anything else.
//
// The note id is part of the WHERE rather than checked beforehand: the owner has
// been established for *this* note, and one statement that can only touch this
// note's rows cannot be talked into deleting a project invite by id.
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
return n > 0, err
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"sort"
"strings"
"unicode"
)
// MentionsIn reads the people named with an @ out of a body, and returns their
// ids in the order they are written.
//
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville
// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke.
//
// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang,
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
//
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
// efternavn hængende bagefter — også når begge findes som konti.
func MentionsIn(body string, people []Person) []string {
if body == "" || len(people) == 0 {
return nil
}
lower := strings.ToLower(body)
byLength := make([]Person, len(people))
copy(byLength, people)
sort.SliceStable(byLength, func(i, j int) bool {
return len(byLength[i].Name) > len(byLength[j].Name)
})
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
// noten ville blive delt med en, der ikke står i den.
claimed := make([]bool, len(body))
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
for _, p := range byLength {
name := strings.ToLower(strings.TrimSpace(p.Name))
if name == "" || p.ID == "" {
continue
}
needle := "@" + name
for at := 0; ; {
i := strings.Index(lower[at:], needle)
if i < 0 {
break
}
i += at
at = i + len(needle)
end := i + len(needle)
if overlaps(claimed, i, end) || !isMention(body, i, end) {
continue
}
for j := i; j < end; j++ {
claimed[j] = true
}
if _, seen := found[p.ID]; !seen {
found[p.ID] = i
}
}
}
out := make([]string, 0, len(found))
for id := range found {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
return out
}
func overlaps(claimed []bool, from, to int) bool {
for i := from; i < to && i < len(claimed); i++ {
if claimed[i] {
return true
}
}
return false
}
// isMention er de to kanter om navnet.
//
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
// efter snabel-a'et.
//
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
func isMention(body string, from, to int) bool {
runes := []rune(body[:from])
if len(runes) > 0 {
prev := runes[len(runes)-1]
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
return false
}
}
rest := body[to:]
if rest == "" {
return true
}
next := []rune(rest)
if next[0] == 's' || next[0] == 'S' {
next = next[1:]
if len(next) == 0 {
return true
}
}
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
}
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"reflect"
"testing"
)
func TestMentionsIn(t *testing.T) {
people := []Person{
{ID: "sofie", Name: "Sofie"},
{ID: "sofie-jensen", Name: "Sofie Jensen"},
{ID: "kristian", Name: "Kristian"},
}
for _, tc := range []struct {
name string
body string
want []string
}{
{"ingen omtale", "En helt almindelig note om noget", nil},
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
// med den, der står i den, og ikke også med en, der ikke gør.
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
{"e-mailadresse", "skriv til [email protected]", nil},
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
{"ukendt navn", "@Ingen her", nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := MentionsIn(tc.body, people)
if len(got) == 0 && len(tc.want) == 0 {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
}
})
}
}
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
// tomt svar frem for et gæt på, hvad et @ mon betød.
func TestMentionsInWithoutPeople(t *testing.T) {
if got := MentionsIn("@Sofie hej", nil); got != nil {
t.Errorf("got %v, want nothing to resolve against", got)
}
}
@@ -0,0 +1,22 @@
-- De noter, en delt note peger på, følger med delingen.
--
-- Et `[[link]]` i en note, der er delt, pegede indtil nu på noget, modtageren
-- ikke havde fået. Linket stod der, kunne ses, og førte ingen steder hen — meldt
-- af den første, der prøvede at læse en delt note, og med rette: den, der deler
-- en note, deler det, noten *siger*, og et link er en del af det, den siger.
--
-- Rækken er den samme som en almindelig deling. `linked` er den ene forskel: den
-- siger, at rækken ikke er valgt af nogen, men udledt af, hvad noten peger på.
-- Det gør to ting muligt, som en boolsk værdi ellers sjældent gør sig fortjent
-- til. Fladen kan sige *hvorfor* en person har den note — "den fulgte med" er en
-- anden sætning end "du delte den". Og udledningen kan regnes forfra hver gang:
-- de udledte rækker må slettes og skrives igen efter hver ændring, mens de
-- valgte aldrig røres. Uden skellet ville en oprydning enten lade forældede
-- delinger blive stående eller fjerne dem, ejeren selv havde sat.
--
-- Kun ejerens egne noter følger med. Et link peger på en *titel*, og titler kan
-- skrives af enhver, der må rette i noten — så hvis et link kunne hente hvad som
-- helst, ville en medredaktør kunne skrive `[[Bankoplysninger]]` og læse med.
-- Opslaget sker derfor kun blandt de noter, ejeren selv ejer, og deling af dem
-- har ejeren allerede ret til.
ALTER TABLE note_shares ADD COLUMN linked INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,18 @@
-- En besked kan handle om en note.
--
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
-- for at opdage det.
--
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
-- — kan et generisk par slet ikke have.
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
-- er nøjagtig det: min, ulæst, om denne note.
CREATE INDEX notifications_unread_by_note
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
@@ -0,0 +1,30 @@
-- En invitation kan handle om en note.
--
-- En note kunne deles med nogen, der allerede havde en konto — og kun med dem.
-- Skulle en, der ikke havde en, læse med, skulle man først invitere dem til
-- instansen, vente på at de oprettede sig, og så finde noten frem og dele den. Tre
-- skridt og en ventetid for det, der er én sætning: "lad Sofie se den her".
--
-- Rækken er den, der allerede findes. En invitation bærer i forvejen en e-mail, en
-- rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad
-- den er en invitation *til*. project_id sagde det for projekter, og note_id siger
-- det nu for noter.
--
-- To søjler frem for én kolonne med et id og en art ved siden af — samme valg som
-- notifications traf i 0033, og af samme grund: fremmednøglen. ON DELETE CASCADE
-- er dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage
-- imod en invitation til noget, der ikke er der længere. Et generisk par kan ikke
-- have den nøgle, og oprydningen skulle så skrives i kode, der huskede at køre.
--
-- De to udelukker hinanden i praksis, men det står ikke som en CHECK: en
-- invitation uden nogen af delene er den gyldige "kom med på instansen", og en
-- regel, der skulle tillade nul eller én af to, ville skulle skrives om hver gang
-- der kom en tredje slags ting at invitere til.
ALTER TABLE invites ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Panelet under en note spørger "hvem er inviteret til den her, og har ikke taget
-- imod endnu" hver gang, det åbnes. Det er opslaget, og det er derfor, det er
-- indekset. Kun de uafklarede: en invitation, der er taget imod, er en deling nu
-- og står i note_shares.
CREATE INDEX invites_pending_by_note
ON invites (note_id) WHERE accepted_at IS NULL AND note_id IS NOT NULL;
@@ -0,0 +1,21 @@
-- En personlig adresse, andre programmer kan skubbe en opgave ind ad.
--
-- Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
-- jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
-- en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
-- mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der
-- manglede, var en URL, man kan smide en linje tekst efter.
--
-- Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed,
-- men de bliver kompromitteret hver for sig: en URL, der har ligget i en genvej på
-- en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i
-- andres adressebøger — skifter med. Og omvendt.
--
-- Ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons
-- indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på
-- en anden maskine, og en, man ikke kan.
ALTER TABLE users ADD COLUMN hook_token TEXT;
-- Opslaget er "hvem er det her token", og det er også dét, der skal være entydigt:
-- to konti med det samme token er to konti, en opgave kan lande i.
CREATE UNIQUE INDEX idx_users_hook_token ON users (hook_token) WHERE hook_token IS NOT NULL;
+279
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"time"
)
@@ -131,3 +133,280 @@ func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []stri
}
return out, rows.Err()
}
// --- de noter, en delt note peger på ---------------------------------------------
// LinkedShare is one note that followed a share: which note, what it is called,
// and which of the owner's shared notes brought it. The panel shows all three —
// "Prisliste came along with Aftale om levering" is a sentence somebody can act
// on, where a bare id is not.
type LinkedShare struct {
NoteID string `json:"note_id"`
Title string `json:"title"`
ViaID string `json:"via_id"`
Via string `json:"via"`
Role Role `json:"role"`
UserID string `json:"user_id"`
}
// Hvor langt en deling må brede sig.
//
// En note, der peger på en note, der peger på en note, er stadig én ting at læse,
// og den skal følge med. Men noter kan pege i ring og i vifte, og "del denne ene
// note" må ikke kunne blive til fire hundrede. Loftet er ikke en optimering: det
// er den grænse, hvor en deling holder op med at være noget, ejeren kan overskue.
const linkedShareLimit = 100
// SyncLinkedShares regner de udledte delinger forfra for alt, hvad én person ejer.
//
// Kaldt efter enhver af ejerens egne handlinger, der kan flytte svaret: en deling,
// en fjernet deling, og en gemning, der har ændret på det, noten peger på. Regnet
// forfra frem for rettet til, fordi det er den samme kode hver gang og derfor det
// samme resultat hver gang — en tilføjelse og en fjernelse er ikke to veje gennem
// koden, men det samme kald med et andet udgangspunkt.
//
// Kun *direkte* delinger er udgangspunkt. En note i et delt projekt er delt af
// projektet, og projektets folk kan se projektets noter; det er en anden vej ind,
// med sine egne regler, og den blander sig ikke her.
//
// Returnerer, hvad der nu følger med, så kalderen kan sige det. Det er meningen at
// det bliver sagt: en deling, der stille tager tre noter mere med, er ikke til at
// overskue, uanset hvor rigtigt den gør det.
func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedShare, error) {
if ownerID == "" {
return nil, nil
}
// Har personen ikke delt noget, er der intet at regne — og det er de fleste,
// det meste af tiden. Uden det her ville hvert eneste klik i notelisten åbne
// hver eneste note for at finde ud af, at svaret er tomt: fladen spørger til
// delingerne, hver gang man vælger en note, man selv ejer.
//
// Ét spørgsmål dækker begge halvdele. Er der ingen rækker overhovedet, er der
// hverken en deling at brede ud eller en udledt række at rydde op efter.
var anyShare int
if err := db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM note_shares s
JOIN notes n ON n.id = s.note_id
WHERE n.created_by = ?)`, ownerID).Scan(&anyShare); err != nil {
return nil, err
}
if anyShare == 0 {
return nil, nil
}
// Ejerens egne noter, åbnet. Titlerne er forseglet i basen, og et link peger på
// en titel — så der er ikke noget opslag at lave i SQL. Målt andetsteds i denne
// fil: tolv hundrede noter koster nogle og tredive millisekunder at åbne, og det
// her kører kun, når ejeren selv har gjort noget.
mine, err := db.notesWhere(ctx, `WHERE created_by = ? AND deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
byTitle := map[string]string{}
title := map[string]string{}
for _, n := range mine {
title[n.ID] = n.Title
key := strings.ToLower(strings.TrimSpace(n.Title))
// Den første vinder, og listen kommer sorteret med den senest rørte først:
// to noter med samme titel er sjældent, og når det sker, er den, man skrev
// sidst, den, man mente.
if key != "" {
if _, taken := byTitle[key]; !taken {
byTitle[key] = n.ID
}
}
}
// Hvad hver note peger på, i ét spørgsmål frem for ét pr. note.
links := map[string][]string{}
rows, err := db.QueryContext(ctx, `
SELECT l.note_id, l.target_id
FROM note_links l
JOIN notes n ON n.id = l.note_id
WHERE l.kind = 'note' AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
for rows.Next() {
var from, target string
if err := rows.Scan(&from, &target); err != nil {
rows.Close()
return nil, err
}
links[from] = append(links[from], target)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// De delinger, ejeren selv har sat.
type grant struct{ noteID, userID string }
direct := map[grant]Role{}
rows, err = db.QueryContext(ctx, `
SELECT s.note_id, s.user_id, s.role
FROM note_shares s
JOIN notes n ON n.id = s.note_id
WHERE s.linked = 0 AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
for rows.Next() {
var g grant
var role Role
if err := rows.Scan(&g.noteID, &g.userID, &role); err != nil {
rows.Close()
return nil, err
}
if role.Valid() {
direct[g] = role
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Vandringen. Pr. person, fordi det er personen, delingen handler om: to
// forskellige mennesker kan have to forskellige noter og derfor to forskellige
// vifter ud fra dem.
wanted := map[grant]LinkedShare{}
perUser := map[string][]grant{}
for g := range direct {
perUser[g.userID] = append(perUser[g.userID], g)
}
for userID, roots := range perUser {
seen := map[string]bool{}
type step struct {
noteID string
via string
}
// Medredaktør først, læser bagefter, og hver runde tømt for sig.
//
// En note kan nås fra to delte noter med hver sin rolle, og så skal den
// stærkeste gælde. Én kø med det hele i ville afgøre det på afstand frem for
// på rolle: et barnebarn af en redigerbar note ville tabe til et barn af en
// læsbar, fordi det stod længere bagude i køen. To runder gør reglen til
// rækkefølgen — er noten allerede nået som redigerbar, ser den anden runde
// den ikke.
for _, role := range []Role{RoleEditor, RoleViewer} {
var queue []step
for _, g := range roots {
if direct[g] != role || seen[g.noteID] {
continue
}
seen[g.noteID] = true
queue = append(queue, step{noteID: g.noteID, via: g.noteID})
}
for len(queue) > 0 && len(seen) <= linkedShareLimit {
cur := queue[0]
queue = queue[1:]
for _, target := range links[cur.noteID] {
id, ok := byTitle[strings.ToLower(strings.TrimSpace(target))]
// Et link, der ikke rammer en af ejerens egne noter, er enten
// dødt eller peger på en andens note. Ingen af delene er ejerens
// at dele.
if !ok || seen[id] {
continue
}
seen[id] = true
// Rollen arves fra den note, man kom fra. En medredaktør på en
// note bliver medredaktør på det, den peger på: teksten er én
// ting at arbejde i, og et link midt i den er ikke en grænse.
if _, isDirect := direct[grant{noteID: id, userID: userID}]; !isDirect {
wanted[grant{noteID: id, userID: userID}] = LinkedShare{
NoteID: id,
Title: title[id],
ViaID: cur.via,
Via: title[cur.via],
Role: role,
UserID: userID,
}
}
queue = append(queue, step{noteID: id, via: cur.via})
}
}
}
}
// Skrevet i én transaktion: mellem sletningen og skrivningen står basen med
// færre delinger, end den skal, og det er ikke et vindue nogen skal kunne læse i.
err = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM note_shares
WHERE linked = 1
AND note_id IN (SELECT id FROM notes WHERE created_by = ?)`, ownerID); err != nil {
return err
}
for g, l := range wanted {
// DO NOTHING ved sammenstød: en direkte deling på den samme note er
// ejerens eget valg og har forrang. Den kan ikke nå herned — den er
// sorteret fra ovenfor — men reglen skal stå dér, hvor rækken skrives,
// ikke kun dér, hvor den udregnes.
if _, err := tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at, linked)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT (note_id, user_id) DO NOTHING`,
g.noteID, g.userID, l.Role, ownerID, time.Now().Unix()); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
out := make([]LinkedShare, 0, len(wanted))
for _, l := range wanted {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool {
if out[i].UserID != out[j].UserID {
return out[i].UserID < out[j].UserID
}
return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title)
})
return out, nil
}
// NoteAudience is everybody who can see a note: its owner, the people it is shared
// with — chosen or followed along a link — and the members of the project it is
// filed in.
//
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
// delt direkte, og to beskeder om den samme rettelse er én for mange.
//
// The note's own row is joined rather than passed in, so a caller cannot ask about
// a note that has been deleted and get an audience for it anyway.
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
UNION
SELECT user_id FROM note_shares WHERE note_id = ?
UNION
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
UNION
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL`,
noteID, noteID, noteID, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
+85 -8
View File
@@ -18,6 +18,7 @@ type Notification struct {
ActorName string
ProjectID string
TaskID string
NoteID string
Kind string
Title string
Body string
@@ -32,25 +33,54 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
n.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
title, body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
return err
}
// BumpUnreadNoteNotification folds a second change to the same note into the first,
// and says whether it found one to fold into.
//
// A note is saved on a typing pause, so writing a row per save would turn one
// person editing one note for five minutes into a column of twenty identical
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
// der rørte den sidst.
//
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
// gentagelse af noget, man allerede har set.
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
res, err := db.ExecContext(ctx, `
UPDATE notifications
SET created_at = ?, actor_id = ?, title = ?, body = ?
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
rows, err := db.QueryContext(ctx, `
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
n.kind, n.title, n.body, n.read_at, n.created_at
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
ORDER BY n.created_at DESC
-- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver
-- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på
-- afgør basen selv, hvilken der står øverst, og klokken viser dem i den
-- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er
-- den rigtige afgørelse at give den — og den er den samme hver gang.
ORDER BY n.created_at DESC, n.id DESC
LIMIT ?`, userID, limit)
if err != nil {
return nil, err
@@ -60,14 +90,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
out := []Notification{}
for rows.Next() {
var n Notification
var actorID, projectID, taskID sql.NullString
var actorID, projectID, taskID, noteID sql.NullString
var readAt sql.NullInt64
var created int64
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
&taskID, &noteID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
return nil, err
}
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
n.ActorID, n.ProjectID = actorID.String, projectID.String
n.TaskID, n.NoteID = taskID.String, noteID.String
if readAt.Valid {
v := time.Unix(readAt.Int64, 0).UTC()
n.ReadAt = &v
@@ -299,6 +330,52 @@ func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
return err
}
// --- krogen ind i indbakken ------------------------------------------------------
// EnsureHookToken returns the token in the person's webhook URL, minting one the
// first time it is asked for.
//
// Sit eget token frem for mailens. De to er den samme slags hemmelighed, men de
// slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der
// blev væk, skal kunne skiftes uden at mailadressen — som står i andres
// adressebøger — skifter med.
func (db *DB) EnsureHookToken(ctx context.Context, userID string) (string, error) {
var token sql.NullString
err := db.QueryRowContext(ctx, `SELECT hook_token FROM users WHERE id = ?`, userID).Scan(&token)
if err != nil {
return "", err
}
if token.Valid && token.String != "" {
return token.String, nil
}
fresh, err := auth.NewToken()
if err != nil {
return "", err
}
if err := db.SetHookToken(ctx, userID, fresh); err != nil {
return "", err
}
return fresh, nil
}
func (db *DB) SetHookToken(ctx context.Context, userID, token string) error {
_, err := db.ExecContext(ctx,
`UPDATE users SET hook_token = ?, updated_at = ? WHERE id = ?`,
token, time.Now().Unix(), userID)
return err
}
// UserByHookToken is who a webhook call is on behalf of. Tokenet er hele
// legitimationen, så et tomt et er et nej med det samme frem for et opslag, der
// kunne ramme en række, hvor søjlen aldrig er sat.
func (db *DB) UserByHookToken(ctx context.Context, token string) (*User, error) {
if token == "" {
return nil, ErrNotFound
}
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE hook_token = ?`, token)
}
func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
if token == "" {
return nil, ErrNotFound
+113 -5
View File
@@ -992,12 +992,22 @@
<figure class="shot">
<img
src="screenshots/themes.webp"
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
alt="The settings page showing six themes as swatches and, below them, four looks written in the typeface each one selects."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
<figcaption>Six themes and four looks: how bright, and how it reads.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-charcoal.webp"
alt="Notes in the Charcoal theme: a warm near-black modelled on Apple Notes, the note list on the left and a note open on the right, with amber where the accent green usually is."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Charcoal — the newest theme, a warm near-black after Apple Notes, amber for the accent.</figcaption>
</figure>
<figure class="shot">
<img
@@ -1040,6 +1050,75 @@
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
</figure>
</div>
<h3 class="sub">Six themes, the same day</h3>
<p class="muted">
Three dark and three light — a property of the screen you are sitting at, a
laptop in the sun and a monitor in a dark room wanting different answers. The
typeface and how tight it sits are chosen apart from them.
</p>
<div class="shots">
<figure class="shot">
<img
src="screenshots/theme-northern-lights.webp"
alt="The Today view in the Northern lights theme: dark, with a green accent."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Northern lights — dark, a green accent. The default.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/theme-charcoal.webp"
alt="The Today view in the Charcoal theme: a warm near-black modelled on Apple Notes, with amber for the accent."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Charcoal — a warm near-black after Apple Notes, amber accent.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/theme-dusk.webp"
alt="The Today view in the Dusk theme: dark and warm, with an amber accent."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Dusk — dark and warm.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/theme-daylight.webp"
alt="The Today view in the Daylight theme: light and coolly neutral."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Daylight — light and coolly neutral.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/theme-paper.webp"
alt="The Today view in the Paper theme: light and warm, like paper."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Paper — light and warm, like paper.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/theme-contrast.webp"
alt="The Today view in the Contrast theme: black on white, for bright light."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Contrast — black on white, for bright light.</figcaption>
</figure>
</div>
</section>
<section id="features">
@@ -1120,6 +1199,15 @@
by itself — without touching when it is actually due.
</p>
</div>
<div class="cell">
<h3>Holds through a drop</h3>
<p>
A tunnel, a lift, a base station handing over — the app does not go
blank when the signal does. A save you make rides out a few seconds of
no connection and goes through when it comes back, and a reload opens
from what it last saw rather than the browser's offline page.
</p>
</div>
</div>
<p class="group-label">Connected to what you already use</p>
@@ -1154,9 +1242,10 @@
<h3>Your mail, as an inbox</h3>
<p>
Star a mail in Gmail, or flag one over IMAP 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.
it becomes a task — and the star or flag comes off once it has, so a
starred inbox empties as the tasks are made instead of only filling up.
Forward one to your own address instead and the subject is parsed like
anything else you type.
</p>
</div>
<div class="cell">
@@ -1237,6 +1326,25 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
Notes, with amber for the accent. And the app now holds through a short
network drop: a save rides out a few seconds of no signal, and a reload
opens from what it last saw rather than the browser's offline page.
</li>
<li>
<span class="ver">0.42</span>
A starred Gmail message loses its star once it has become a task, the way
a flagged IMAP mail already did — so a starred inbox empties itself as the
tasks are made instead of only filling up.
</li>
<li>
<span class="ver">0.42</span>
Notes read as three lines, the last one the project they are filed under;
the calendar lightened to pastel days with today marked at the top; and
two-factor set-up shows a QR code to scan.
</li>
<li>
<span class="ver">0.41</span>
A task you have delegated is marked where you meet it, not only on the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+344 -4
View File
@@ -64,6 +64,60 @@ function watchForTrouble(page) {
return trouble;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
* Syv dage er stadig det, den åbner på — det er den vandring, der er værd at gå om
* morgenen. Men "hvornår bliver den her måned travl" er et andet spørgsmål, og
* månedsgitteret svarer på det med form frem for med rækkefølge. Derfor kan
* listen sættes til 14 og 30 dage, og valget bliver liggende: horisonten er et
* spørgsmål om, hvor langt man planlægger, ikke om hvilken skærm man sidder ved —
* men det er den samme slags valg som gitter eller liste, så det ligger samme sted.
*/
test('Kommende kan se en måned frem, og horisonten huskes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('skifte olie om 20 dage');
await box.press('Enter');
await expect(box).toHaveValue('');
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const days = page.locator('section:has(> h2)');
const task = page.getByText('skifte olie', { exact: true });
// Syv dage er udgangspunktet, og opgaven ligger uden for dem. At den er væk her
// er halvdelen af prøven: uden den kunne de tredive dage nedenfor være syv, der
// tilfældigvis viste den.
await expect(days).toHaveCount(7);
await expect(task).toBeHidden();
await page.getByRole('button', { name: '30 dage', exact: true }).click();
await expect(days).toHaveCount(30);
await expect(task).toBeVisible();
// Ugenummeret står kun, når strimlen er længere end en uge — ellers siger det
// ingenting, man ikke kunne se i forvejen.
await expect(days.first().getByText(/uge \d+/i)).toBeVisible();
// Og valget overlever en genindlæsning.
await page.reload();
await expect(days).toHaveCount(30);
await expect(
page.getByRole('button', { name: '30 dage', exact: true }),
'horisonten blev ikke husket'
).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: '7 dage', exact: true }).click();
await expect(days).toHaveCount(7);
await expect(days.first().getByText(/uge \d+/i)).toBeHidden();
expect(trouble).toEqual([]);
});
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
@@ -298,11 +352,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
const appleIcon = await request.get('/apple-touch-icon.png');
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
// The service worker is what Web Push needs — and, since it also caches, what
// carries the app through a short network drop. SvelteKit serves it under this
// name and registers it itself; the old hand-registered /sw.js is gone.
const worker = await request.get('/service-worker.js');
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
// The source is minified, so the quotes around the event names are whatever the
// minifier chose — match either kind rather than one it might change under us.
const source = await worker.text();
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
});
/**
* The app survives a network drop: reloaded with the network cut, it opens from
* the cache and still shows the tasks it last saw, instead of the browser's own
* "you are offline" page.
*
* This is the whole point of the service worker's cache — not offline authoring,
* which the app does not claim, but a reload through a tunnel or a handover coming
* back to the app rather than to a dead tab. The retry in api.js covers the write
* side of the same drop; this covers the read side.
*/
test.describe('med service worker', () => {
// The rest of the suite blocks the worker (see playwright.config.js); this one
// test is about the worker, so it turns it back on for its own context.
test.use({ serviceWorkers: 'allow' });
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
// First load, online: the worker installs, and once it controls the page the
// shell and the reads it serves are in the cache.
await page.goto('/');
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// Wait until the worker actually controls this page. Until it does, a reload
// still goes to the network and the test proves nothing — the first load
// registers the worker but the page that triggered the install is not itself
// controlled until it activates and claims.
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
timeout: 15000
});
// A controlled reload, still online: now the navigation and its API reads pass
// through the worker and land in the cache, ready for the drop.
await page.reload();
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// The network is gone.
await context.setOffline(true);
try {
await page.reload();
// The shell came from the cache — the sidebar is here, not a browser error
// page — and so did the task list the app renders into it.
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
} finally {
// Never leave the shared context offline for whatever test runs next.
await context.setOffline(false);
}
});
});
/**
@@ -2730,6 +2838,238 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
expect(trouble).toEqual([]);
});
/**
* Note-links i en delt note.
*
* Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting.
* Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen
* ikke passede — og i en delt note er det første resultat næsten altid noten selv,
* fordi den nævner den titel, den linker til. Så åbnede linket den note, man
* allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt
* noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten
* findes, den er bare ikke delt med.
*/
test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({
browser,
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await gæstCtx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Linnea');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
// Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den
// delte peger på den anden.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Kristians prisblad');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Faelles aftale om afhentning\n');
await page.keyboard.type('[[Kristians prisblad]]');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Gæsten har en note af sin egen, som hun kan pege på.
await gæst.goto(noterURL);
await gæst.getByRole('button', { name: 'Ny note' }).click();
await gæst.getByRole('textbox', { name: 'Notens tekst' }).click();
await gæst.keyboard.type('Linneas egne maal');
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Linnea' });
await share.getByRole('button', { name: 'Tilføj' }).click();
const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' });
await expect(row).toBeVisible();
await row.locator('select').selectOption('editor');
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(body).toContainText('Kristians prisblad');
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
// Hendes eget link fører derhen, det siger.
await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Linneas egne maal'
);
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
// allerede stod i — hvilket ser ud som ingenting.
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
// Stadig i den delte note: linket flyttede hende ingen steder hen.
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Faelles aftale om afhentning'
);
await gæstCtx.close();
expect(trouble).toEqual([]);
});
/**
* Delingen tager de noter med, noten peger på.
*
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
* det, den *siger*, og et link er en del af det.
*/
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await ctx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
for (const text of [
'Leveringstider for pakker',
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
'Bankoplysninger som ingen skal se',
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
await expect(page.locator('.toast')).toContainText('de 2 noter');
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await gæst
.getByRole('textbox', { name: 'Notens tekst' })
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
.click();
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(hos).toContainText('Prisliste i kroner');
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Leveringstider for pakker'
);
// Men kun det, noten peger på. Resten er stadig ejerens alene.
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
).toHaveCount(0);
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
// knap med den samme titel på.
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
await page.locator('.sharewrap > button').click();
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
'følger med'
);
// Taget tilbage følger den med tilbage.
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
await page
.locator('.sharepanel .sharelist li')
.filter({ hasText: 'Mikkel' })
.getByRole('button', { name: 'Fjern adgang' })
.click();
await gæst.goto(noterURL);
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
).toHaveCount(0);
await ctx.close();
expect(trouble).toEqual([]);
});
/**
* `</>` er kode, ikke kilde.
*
* Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet,
* og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet
* program, så det er knappen, der skal give efter, ikke brugeren.
*/
test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByRole('textbox', { name: 'Notens tekst' });
await body.click();
await page.keyboard.type('Kodenote\n');
await page.keyboard.type('kør kommandoen npm først');
// Markér ét ord og gør det til kode.
await page.keyboard.press('Shift+Home');
await page.keyboard.press('Shift+ArrowRight');
await page.getByRole('button', { name: 'Kode', exact: true }).click();
await expect(body.locator('code')).toHaveCount(1);
// Noten er stadig rig tekst — kildevisningen er ikke slået til.
await expect(page.locator('textarea.source')).toHaveCount(0);
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
await body.blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await page.getByRole('button', { name: /Vis kilden/ }).click();
await expect(page.locator('textarea.source')).toHaveValue(/`/);
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+6
View File
@@ -51,6 +51,12 @@ export default defineConfig({
use: {
baseURL,
// Off by default. The app now ships a caching service worker, and once it
// controls a page Playwright's page.route() no longer sees the fetches it
// makes — so a test that forces a 500 to check the error path gets the real
// 200 through the worker instead, and reads as a broken feature. The one test
// that needs the real worker turns it back on for its own context.
serviceWorkers: 'block',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Pinned, because the app reads both. The sign-up form sends
+13
View File
@@ -73,6 +73,19 @@ const SHOTS = [
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
// Charcoal, the newest theme — a warm near-black modelled on Apple Notes, with
// amber where the green usually is. The notes list shows a theme's surfaces, text
// and one accent at once, so that is where the landing page carries it up close.
{ name: 'notes-charcoal', path: '/noter', theme: 'charcoal', prepare: openMeetingNote },
// The same Today view in each of the six themes, for a gallery that shows the
// whole range at a glance. One view throughout, so the only thing that changes
// from picture to picture is the theme itself. Names are the themes' own.
{ name: 'theme-northern-lights', path: '/', theme: 'dark' },
{ name: 'theme-charcoal', path: '/', theme: 'charcoal' },
{ name: 'theme-dusk', path: '/', theme: 'dusk' },
{ name: 'theme-daylight', path: '/', theme: 'light' },
{ name: 'theme-paper', path: '/', theme: 'paper' },
{ name: 'theme-contrast', path: '/', theme: 'contrast' },
// The new work, and in the light theme, which is what most people want to look
// at a landing page in. The calendar drawing tasks to their length, and a note
// somebody shared into the "Shared with me" group.
+30 -18
View File
@@ -210,27 +210,39 @@
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
}
/* Kul — a neutral dark grey, the greys of Apple Notes rather than the default's
faint green cast. The same green accent, so it still reads as verdande; only the
grounds, surfaces and inks turn to plain grey. */
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
than sharing --surface-raised with the list. The accent turns amber, and the
selected menu item, selected note and section headings each get a fallback token
so this theme can hit its colours without touching any other. */
[data-theme='charcoal'] {
--ground: #1c1c1c;
--surface: #232323;
--surface-raised: #2b2b2b;
--surface-sunken: #171717;
--line: #363636;
--line-strong: #474747;
--ground: #1e1e1e;
--surface: #2a3033;
--surface-raised: #242b2e; /* the note list */
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
--line: #34393b;
--line-strong: #414749;
--ink: #ededed;
--ink-muted: #a4a4a4;
/* Light enough to clear 4.5:1 against the ground — the quiet tier of dates and
counts is the first to disappear if it is too dim. */
--ink-faint: #8e8e8e;
--ink: #ffffff;
--ink-muted: #dcdcdc;
--ink-faint: #9ba0a2;
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #23332b80;
--accent-ink: #0d1411;
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
dark text rides on top of it. */
--accent: #ffce35;
--accent-hover: #ffd75a;
--accent-sunken: #ffce35;
--accent-ink: #1e1e1e;
/* Per-element, and only here — every other theme falls back to the shared token
in the var() calls that read these. */
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
--note-on-bg: #a78c26; /* a selected note in the list — gold */
--note-on-ink: #1e1e1e; /* dark text on the gold */
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
--p1: #cf6b6b;
--p2: #d69f5c;
+10
View File
@@ -418,6 +418,11 @@ export const api = {
getMailAddress: () => get('/mail-address'),
rotateMailAddress: () => post('/mail-address/rotate'),
// Krogen ind i indbakken. Selve skubbet sker ikke herfra — det er et andet
// program, der gør det — så der er kun adressen at hente og at skifte.
getHookURL: () => get('/hook-url'),
rotateHookURL: () => post('/hook-url/rotate'),
getGmail: () => get('/gmail'),
setGmail: (data) => put('/gmail', data),
disconnectGmail: () => del('/gmail'),
@@ -454,7 +459,12 @@ export const api = {
// path. Owner only; the list call also returns who it can still be shared with.
noteShares: (id) => get(`/notes/${id}/shares`),
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
// Deling ved adresse. Har adressen en konto, bliver det en almindelig deling —
// svaret siger hvem — og ellers en invitation, der venter på, at kontoen bliver
// oprettet.
shareNoteByEmail: (id, email, role) => post(`/notes/${id}/shares`, { email, role }),
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
revokeNoteInvite: (id, inviteId) => del(`/notes/${id}/invites/${inviteId}`),
// 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.
+46
View File
@@ -0,0 +1,46 @@
/**
* Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc.
*
* Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det
* halve af pointen med at få besked er at få den, mens man laver noget andet, og
* det eneste sted, appen kan sige noget dér, er på sit eget ikon.
*
* Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved
* hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`-
* lytteren i service-worker.js, som får tallet med i beskeden.
*
* Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der
* lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst.
*/
/**
* Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en
* eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør
* ingenting. Firefox kan ikke endnu.
*/
export function supported() {
return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
}
/**
* Sætter mærket til `n`, eller fjerner det ved nul.
*
* Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken,
* der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren
* siger nej.
*/
export function setBadge(n) {
if (!supported()) return;
const count = Number(n) || 0;
try {
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
done?.catch?.(() => {});
} catch {
// Nogle browsere kaster i stedet for at afvise. Det er det samme svar.
}
}
/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */
export function clearBadge() {
setBadge(0);
}
+198 -6
View File
@@ -20,7 +20,10 @@
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, notes = [], onchange, onsave, onopennote } = $props();
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
// to hentninger af den samme liste er to, der kan blive uenige.
let { note, notes = [], people = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -87,6 +90,7 @@
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '@', what: 'notes.syntaxPerson' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
@@ -164,6 +168,111 @@
changed();
}
/**
* Inline kode — `sådan her`.
*
* Den knap fandtes ikke, og `</>`, som i ethvert andet program betyder netop
* kode, sad på kildevisningen med teksten "Vis som kode" på. Så markerede man et
* ord og trykkede på den, og hele noten skiftede til Markdown. Det blev meldt
* som en fejl, og det var det også: knappen sagde ét og gjorde et andet. Nu gør
* `</>` det, tegnet siger, og kilden har fået sit eget mærke.
*
* Skrevet i hånden, fordi execCommand ikke har en kommando for kode. Markeringen
* læses som ren tekst — det er hele meningen med kode, at det ikke er formateret
* — og lægges tilbage inde i et <code>, som konverteringen allerede kender
* begge veje.
*/
function codeAround() {
const selection = window.getSelection();
let node = selection?.anchorNode;
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'CODE') return node;
node = node.parentNode;
}
return null;
}
/** Markerer ordet, markøren står i. Bruges når der ikke er markeret noget. */
function selectWordAtCaret() {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent ?? '';
let start = selection.anchorOffset;
let end = start;
while (start > 0 && !/\s/.test(text[start - 1])) start--;
while (end < text.length && !/\s/.test(text[end])) end++;
if (start === end) return;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
selection.removeAllRanges();
selection.addRange(range);
}
function selectContents(node) {
const range = document.createRange();
range.selectNodeContents(node);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function toggleCode() {
editor?.focus();
const selection = window.getSelection();
if (!selection?.rangeCount) return;
// Inde i en kodeblok er alting allerede kode; en <code> derinde kan hverken
// ses eller skrives ned, for en hegnet blok er tekst hele vejen igennem.
const block = currentBlock();
if (block?.tagName === 'PRE') return;
const existing = codeAround();
if (existing) {
// Ud igen: teksten bliver stående, mærkerne forsvinder. Markeringen lægges
// om den, så et tryk mere sætter den tilbage — knappen er en kontakt.
const text = document.createTextNode(existing.textContent ?? '');
existing.replaceWith(text);
selectContents(text);
readState();
changed();
return;
}
// Uden en markering er det ordet, markøren står i. Det er det, ⌘B gør i et
// tekstprogram, og bedre end et tomt <code>, der ikke kan ses og ikke kan
// skrives ned.
if (selection.isCollapsed) selectWordAtCaret();
const live = window.getSelection();
const text = live.toString();
if (!text) return;
// Bygget som en node frem for med `insertHTML`.
//
// Chromium renser den HTML, den får dér, gennem den samme vej som en
// indsætning: `<code>` kom ud som et `<span>` med skrifttypen og farven
// skrevet ind i sig, og et span er ikke noget, htmlToMarkdown kan skrive ned
// som kode. Målt — mærket forsvandt hver gang, og noten så rigtig ud, indtil
// den blev gemt.
const range = live.getRangeAt(0);
range.deleteContents();
const code = document.createElement('code');
code.textContent = text;
range.insertNode(code);
// Markøren efter mærket, ikke inde i det: ellers bliver det næste ord, man
// skriver, også til kode.
const after = document.createRange();
after.setStartAfter(code);
after.collapse(true);
live.removeAllRanges();
live.addRange(after);
readState();
changed();
}
/**
* Steps out of a code block on Enter.
*
@@ -223,7 +332,11 @@
underline: document.queryCommandState('underline'),
strike: document.queryCommandState('strikeThrough'),
bullet: document.queryCommandState('insertUnorderedList'),
numbered: document.queryCommandState('insertOrderedList')
numbered: document.queryCommandState('insertOrderedList'),
// Læst af DOM'en og ikke af execCommand: der findes ingen kommando
// for kode, hvilket er hele grunden til at knappen er skrevet i
// hånden herunder.
code: !!codeAround()
};
} catch {
// Some browsers throw on queryCommandState with no selection. The toolbar
@@ -400,6 +513,19 @@
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
}
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
// læse sætningen efter navnet med.
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
if (person) {
return {
kind: 'person',
node,
start: selection.anchorOffset - person[1].length,
term: person[1]
};
}
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (project) {
return {
@@ -453,7 +579,12 @@
// pixels for hvert bogstav, mens listen snævres ind.
if (!suggestions.length) menuAt = tagPosition(partial);
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
suggestions =
partial.kind === 'note'
? noteMatches(term)
: partial.kind === 'person'
? personMatches(term)
: projectMatches(term);
chosen = 0;
}
@@ -477,6 +608,16 @@
.sort((a, b) => rank(a.label, b.label, term));
}
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
function personMatches(term) {
return people
.filter((p) => p.name && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
.sort((a, b) => rank(a.label, b.label, term));
}
function rank(a, b, term) {
const al = a.toLowerCase();
const bl = b.toLowerCase();
@@ -641,6 +782,9 @@
if (key === 'u') {
event.preventDefault();
apply('underline');
} else if (key === 'e') {
event.preventDefault();
toggleCode();
} else if (key === 'x' && event.shiftKey) {
event.preventDefault();
apply('strikeThrough');
@@ -788,19 +932,50 @@
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
<s>S</s>
</button>
<!-- `</>` hører til her, mellem de andre mærker, fordi kode er et mærke som
fed og kursiv. Den sad på kildevisningen, og det var en fælde: tegnet
betyder kode overalt ellers, så folk markerede et ord og fik hele noten
om til Markdown. -->
<button
class:on={active.code}
onclick={toggleCode}
title="⌘E"
aria-pressed={!!active.code}
aria-label={t('notes.code')}
>
<span class="codemark">&lt;/&gt;</span>
</button>
<span class="sep" aria-hidden="true"></span>
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
anden måde at se det hele på. -->
anden måde at se det hele på — og med Markdown-mærket på, som er præcis
det, den viser. Et tegn, ingen forveksler med et format. -->
<button
class="src"
class:on={asSource}
onclick={toggleSource}
aria-pressed={asSource}
aria-label={t('notes.showSource')}
title={t('notes.showSource')}>&lt;/&gt;</button
title={t('notes.showSource')}
>
<svg viewBox="0 0 208 128" aria-hidden="true">
<rect
x="5"
y="5"
width="198"
height="118"
rx="14"
fill="none"
stroke="currentColor"
stroke-width="12"
/>
<path
d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zM155 98l-30-33h20V30h20v35h20z"
fill="currentColor"
/>
</svg>
</button>
</div>
{#if asSource}
@@ -868,6 +1043,10 @@
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else if item.kind === 'person'}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@@ -1132,11 +1311,24 @@
resize: none;
}
.src {
/* Kodeknappen bærer sit eget tegn i monospace — `</>` sat med brødskriften
ligner en tastefejl frem for et mærke. */
.codemark {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.src {
display: inline-flex;
align-items: center;
justify-content: center;
}
.src svg {
width: 17px;
height: auto;
}
/* The document styles. These are the whole of "it should look like Apple Notes":
a title that is a title, and body text that is comfortable to read for a page
at a time. */
+270
View File
@@ -0,0 +1,270 @@
<script>
/**
* Klokken.
*
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
* delt projekt, er en klokke, folk holder op med at se på.
*
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
*/
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { goto } from '$app/navigation';
let open = $state(false);
let wrap = $state(null);
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
comment: 'notif.comment'
};
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
function when(iso) {
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
if (minutes < 1) return t('notif.justNow');
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}
/**
* Hvor en besked fører hen.
*
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
* man går hen.
*/
function follow(n) {
open = false;
if (!n.read) app.markRead(n.id);
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
}
function outside(event) {
if (open && wrap && !wrap.contains(event.target)) open = false;
}
</script>
<svelte:window
onclick={outside}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
/>
<div class="wrap" bind:this={wrap}>
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
? t('notif.titleWithCount', { n: String(app.unread) })
: t('notif.title')}
title={t('notif.title')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10.3 21a2 2 0 003.4 0" />
</svg>
{#if app.unread}
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
{/if}
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
{/if}
</header>
{#if app.notifications.length}
<ul>
{#each app.notifications as n (n.id)}
<li>
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
<span class="what">{headline(n)}</span>
{#if n.body}<span class="about">{n.body}</span>{/if}
<span class="ago">{when(n.created_at)}</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="none">{t('notif.empty')}</p>
{/if}
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
}
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: var(--radius);
color: var(--ink-muted);
cursor: pointer;
}
.bell:hover,
.bell.on {
background: var(--surface-raised);
color: var(--ink);
}
.bell svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.count {
position: absolute;
top: 1px;
right: 0;
min-width: 15px;
padding: 0 3px;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--on-accent, #fff);
font-size: 10px;
line-height: 15px;
font-weight: 600;
text-align: center;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 40;
width: min(340px, calc(100vw - var(--s3) * 2));
max-height: 60vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
.panel header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
padding: var(--s2);
border-bottom: 1px solid var(--line);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.allread {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.allread:hover {
text-decoration: underline;
}
.panel ul {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: grid;
gap: 2px;
width: 100%;
padding: var(--s2);
border: none;
border-bottom: 1px solid var(--line);
background: none;
text-align: left;
cursor: pointer;
color: var(--ink-muted);
}
.row:hover {
background: var(--surface-raised);
}
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
kunne se hvad der var én besked. */
.row.unread .what {
font-weight: 600;
color: var(--ink);
}
.row.unread .what::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
vertical-align: middle;
border-radius: var(--radius-full);
background: var(--accent);
}
.what {
font-size: var(--text-sm);
}
.about {
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ago {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.none {
margin: 0;
padding: var(--s3) var(--s2);
text-align: center;
font-size: var(--text-xs);
color: var(--ink-faint);
}
</style>
+21 -2
View File
@@ -1,6 +1,7 @@
<script>
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { clearBadge } from '$lib/badge.js';
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
@@ -31,6 +32,19 @@
async function signOut() {
await api.logout();
// Mærket på ikonet er den forriges ulæste. Det står i Dock'en, også når
// ingen er logget ind, så det skal væk sammen med resten af dem.
clearBadge();
// Empty the offline data cache before leaving: the service worker keeps the
// last-seen task and note lists so a network drop does not blank the app, and
// the next person to sign in on this browser must not find them.
try {
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
await caches?.delete('verdande-api');
} catch {
// No service worker / no Cache API (older browser, private mode): nothing
// was cached, so nothing to clear.
}
location.href = '/';
}
@@ -1336,7 +1350,9 @@
h2 {
font-size: var(--menu-size);
font-weight: 600;
color: var(--ink);
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
falls back to the reading ink and keeps them bold and bright. */
color: var(--heading-ink, var(--ink));
}
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
@@ -1591,7 +1607,10 @@
in both themes. */
a.active {
background: var(--accent-sunken);
color: var(--ink);
/* On a solid accent (Kul's amber menu item) the label goes dark via
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
falls back to the reading ink. */
color: var(--nav-active-ink, var(--ink));
font-weight: 600;
}
+10 -2
View File
@@ -228,13 +228,21 @@
align-items: flex-start;
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
border-radius: var(--radius);
transition:
opacity var(--medium) var(--ease),
transform var(--medium) var(--ease);
}
.row:hover {
background: var(--surface);
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
target makes iOS spend the first tap applying the hover and only open on the
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
the task. The rounded corners are on the row itself, so the highlight is a
rounded box like a note in the list. */
@media (hover: hover) {
.row:hover {
background: var(--surface);
}
}
/* The exit: a short lift and fade, so completing something reads as the task
+5 -5
View File
@@ -1,11 +1,11 @@
/**
* Connectivity, as the app experiences it — not as the browser reports it.
*
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
* handing over. It is deliberately NOT offline support. The app is already loaded
* and its data is already in memory, so there is nothing to cache and nothing to
* open from cold; what a blip needs is for the save you just made to survive it,
* not for the whole app to run without a server.
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
* tunnel, a base station handing over. The READ side lives in the service worker,
* which keeps the last-seen task and note lists so a reload through a drop is not a
* blank screen. This store is the other half: it is what keeps the save you just made
* from being reverted under your hands while the network is briefly gone.
*
* The request layer drives this: it retries a failed request for a short window
* (see api.js) and tells this store while it is trying, so the interface can say
+50 -4
View File
@@ -22,8 +22,22 @@ export const da = {
'notes.unshared': 'Noten er din igen.',
// Deling med enkeltpersoner, ved siden af projektet.
'notes.groupSharedWithMe': 'Delt med mig',
// Klokken: hvad andre har gjort, som man skal vide. Sætningen skrives i fladen
// og ikke på serveren, så en engelsk flade også får en engelsk klokke.
'notif.title': 'Beskeder',
'notif.titleWithCount': 'Beskeder ({n} ulæste)',
'notif.empty': 'Ingen beskeder.',
'notif.markAllRead': 'Markér alle som læst',
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
'notif.hours': '{n} t. siden',
'notes.linkOpen': 'Åbn',
'notes.linkMissing': 'Noten blev ikke fundet.',
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
'notes.sharedBy': 'Delt af {name}',
'notes.sharePeople': 'Del med personer',
'notes.sharePick': 'Vælg en person …',
@@ -34,7 +48,25 @@ export const da = {
'notes.shareRemove': 'Fjern adgang',
'notes.shareNobody': 'Ikke delt med nogen endnu.',
'notes.sharedToast': 'Delt med {name}.',
'notes.sharedWithFollowers': 'Delt med {name} — og de {count} noter, den peger på.',
'notes.shareFollows': 'Følger med:',
'notes.shareVia': 'følger med “{note}”',
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
// Deling med en adresse. Ordene skal kunne bære begge udfald — en deling nu
// eller en invitation, der venter — uden at love det ene.
'notes.shareByEmail': 'Del med en e-mailadresse',
'notes.shareEmailPlaceholder': '[email protected]',
'notes.shareRole': 'Rolle',
'notes.shareInvite': 'Inviter',
'notes.invited': 'Invitationen er sendt til {email}.',
'notes.invitePending': 'inviteret',
'notes.inviteRevoke': 'Træk invitationen tilbage',
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
// Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her,
// fordi det sker uden at nogen åbner delepanelet.
'notes.syntaxPerson': 'en person',
'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
@@ -76,7 +108,8 @@ export const da = {
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.strike': 'Gennemstreget',
'notes.showSource': 'Vis som kode',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
'notes.source': 'Notens kildetekst',
'notes.styleTitle': 'Titel',
'notes.styleHeading': 'Overskrift',
@@ -451,6 +484,8 @@ export const da = {
'view.filterEmpty': 'Ingenting matcher lige nu.',
'view.filterFailed': 'Filteret kunne ikke køres.',
'view.upcoming': 'Kommende',
'view.horizon': 'Periode',
'view.days': '{n} dage',
'view.mode': 'Visning',
'view.list': 'Liste',
'view.week': 'Uge',
@@ -660,7 +695,7 @@ export const da = {
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Postkasser',
'int.mailboxesHint':
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave — flaget fjernes, når opgaven er oprettet. Kun én vej: gør du selv noget ved mailen bagefter, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
'int.addMailbox': 'Tilføj en postkasse',
'int.host': 'IMAP-server',
'int.username': 'Brugernavn',
@@ -676,7 +711,7 @@ export const da = {
'int.fetching': 'Henter …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave — stjernen fjernes så (en etiket bliver, hvor den er). Det du selv gør ved mailen bagefter, ændrer ikke opgaven.',
'int.connectGmail': 'Forbind Gmail',
'int.fromEnv':
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
@@ -716,6 +751,17 @@ export const da = {
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
'int.yourAddress': 'Din adresse',
'int.newAddress': 'Ny adresse',
// Krogen ind i indbakken: en URL, et andet program kan skubbe en opgave til.
'int.hookToTask': 'Skub fra andre programmer',
'int.hookHint':
'En adresse, andre programmer kan sende en opgave til — en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Linjen læses som i feltet øverst i fladen, så “i morgen”, “p1” og “#Projekt” betyder det samme her.',
'int.yourHook': 'Din adresse',
'int.hookHow':
'Send en POST med teksten som krop — ren tekst, JSON med et {text}-felt, eller et formularfelt. Første linje bliver opgaven, resten står under den. Adressen er en nøgle: den, der har den, kan lægge opgaver i din indbakke og ikke andet.',
'int.hookExample': 'Sådan ser det ud',
'int.newHook': 'Ny adresse',
'int.newHookQuestion':
'Vil du have en ny adresse? Den gamle holder op med at virke med det samme, og alt, der bruger den, skal rettes.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
+51 -4
View File
@@ -18,8 +18,23 @@ export const en = {
'notes.unshared': 'The note is yours again.',
// Sharing with individual people, alongside the project.
'notes.groupSharedWithMe': 'Shared with me',
// The bell: what other people did that you need to know about. The sentence is
// composed here rather than on the server, so an English interface gets an
// English bell.
'notif.title': 'Notifications',
'notif.titleWithCount': 'Notifications ({n} unread)',
'notif.empty': 'Nothing yet.',
'notif.markAllRead': 'Mark all as read',
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
'notif.hours': '{n} h ago',
'notes.linkOpen': 'Open',
'notes.linkMissing': 'That note could not be found.',
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
'notes.sharedBy': 'Shared by {name}',
'notes.sharePeople': 'Share with people',
'notes.sharePick': 'Choose someone …',
@@ -30,7 +45,25 @@ export const en = {
'notes.shareRemove': 'Remove access',
'notes.shareNobody': 'Not shared with anyone yet.',
'notes.sharedToast': 'Shared with {name}.',
'notes.sharedWithFollowers': 'Shared with {name} — along with the {count} notes it points at.',
'notes.shareFollows': 'Comes along:',
'notes.shareVia': 'came along with “{note}”',
'notes.unsharedPerson': '{name} can no longer see the note.',
// Sharing with an address. The words have to carry both outcomes — a share now,
// or an invitation that waits — without promising either one.
'notes.shareByEmail': 'Share with an email address',
'notes.shareEmailPlaceholder': '[email protected]',
'notes.shareRole': 'Role',
'notes.shareInvite': 'Invite',
'notes.invited': 'The invitation has been sent to {email}.',
'notes.invitePending': 'invited',
'notes.inviteRevoke': 'Withdraw the invitation',
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
// A mention. `@Name` in a note shares it with the person named — said here,
// because it happens without anybody opening the share panel.
'notes.syntaxPerson': 'a person',
'notes.mentionShared': 'The note has been shared with {names}, because you named them.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
'notes.format': 'Format',
@@ -72,7 +105,8 @@ export const en = {
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.strike': 'Strikethrough',
'notes.showSource': 'Show as code',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',
'notes.source': 'Note source',
'notes.styleTitle': 'Title',
'notes.styleHeading': 'Heading',
@@ -446,6 +480,8 @@ export const en = {
'view.filterEmpty': 'Nothing matches right now.',
'view.filterFailed': 'The filter could not be run.',
'view.upcoming': 'Upcoming',
'view.horizon': 'Range',
'view.days': '{n} days',
'view.mode': 'View',
'view.list': 'List',
'view.week': 'Week',
@@ -656,7 +692,7 @@ export const en = {
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Mailboxes',
'int.mailboxesHint':
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task — the flag comes off once it has. One way only: what you do to the mail afterwards does not change the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
'int.addMailbox': 'Add a mailbox',
'int.host': 'IMAP server',
'int.username': 'Username',
@@ -672,7 +708,7 @@ export const en = {
'int.fetching': 'Fetching …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
'Star an email, or give it a label, and it becomes a task — the star then comes off (a label stays where it is). What you do to the mail afterwards does not change the task.',
'int.connectGmail': 'Connect Gmail',
'int.fromEnv':
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
@@ -712,6 +748,17 @@ export const en = {
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
'int.yourAddress': 'Your address',
'int.newAddress': 'New address',
// The hook into the inbox: a URL another program can push a task to.
'int.hookToTask': 'Push from other programs',
'int.hookHint':
'An address other programs can send a task to — a shortcut on your phone, a script, any service that can call a URL. The line is read the way the box at the top of the app reads it, so “tomorrow”, “p1” and “#Project” mean the same here.',
'int.yourHook': 'Your address',
'int.hookHow':
'Send a POST with the text as the body — plain text, JSON with a {text} field, or a form field. The first line becomes the task and the rest sits underneath it. The address is a key: whoever has it can put tasks in your inbox, and nothing else.',
'int.hookExample': 'What it looks like',
'int.newHook': 'New address',
'int.newHookQuestion':
'Do you want a new address? The old one stops working immediately, and anything using it has to be changed.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
+7 -3
View File
@@ -26,9 +26,13 @@ export function permission() {
}
async function registration() {
// `ready` waits for an active worker; register() alone resolves while the
// worker is still installing, and subscribing against that fails.
await navigator.serviceWorker.register('/sw.js');
// SvelteKit registers src/service-worker.js itself on load, so normally there
// is already a registration here. Register it by hand only if there is not yet
// one — a push subscribe that races the app's own registration would otherwise
// find nothing. `ready` waits for an active worker; register() alone resolves
// while the worker is still installing, and subscribing against that fails.
const existing = await navigator.serviceWorker.getRegistration();
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
return navigator.serviceWorker.ready;
}

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