Compare commits

...
14 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 21:50:12 +02:00
39 changed files with 3913 additions and 93 deletions
+220 -3
View File
@@ -2718,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]
@@ -3017,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:
@@ -3080,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:
@@ -3126,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]
@@ -3663,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]
@@ -3929,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
@@ -4035,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
@@ -4524,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 }
+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": "sofie@example.dk", "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"] != "sofie@example.dk" {
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Prisliste\n2026")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "Sofie@Example.dk"})
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": "ny@example.dk"}); 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": "ny@example.dk"})
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": "ny@example.dk"}); 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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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": "fremmed@example.dk"})
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": "ny@example.dk"}); 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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
peterClient := owner.newUser(t, "peter@example.dk", "Peter")
peter := userID(t, owner, "peter@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
// 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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
_, 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": "sofie@example.dk", "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, "kristian@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "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))
}
+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:kristian@example.dk"}
}
// 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 hej@Sofie.dk", 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
+232
View File
@@ -2838,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('link@example.dk');
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('med@example.dk');
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

+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>
+4
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,9 @@
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.
+46 -2
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': 'navn@eksempel.dk',
'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',
@@ -718,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.',
+47 -2
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': 'name@example.com',
'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',
@@ -714,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.',
+55
View File
@@ -108,6 +108,7 @@ class AppState {
this.groups = groups;
this.people = people;
this.connect();
this.loadNotifications();
} catch (e) {
this.user = null;
} finally {
@@ -145,10 +146,64 @@ class AppState {
socket.onerror = () => socket.close();
}
// --- klokken -----------------------------------------------------------------
//
// Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje:
// hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En
// klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste —
// og en besked, man skal genindlæse for at se, er ikke en besked.
notifications = $state([]);
unread = $state(0);
async loadNotifications() {
try {
const r = await api.listNotifications();
this.notifications = r.notifications ?? [];
this.unread = r.unread ?? 0;
} catch {
// En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for.
}
}
/** Marks one read, or all of them when no id is given. */
async markRead(id) {
const previous = this.notifications;
const wasUnread = this.unread;
this.notifications = this.notifications.map((n) =>
!id || n.id === id ? { ...n, read: true } : n
);
this.unread = this.notifications.filter((n) => !n.read).length;
try {
await api.markNotificationsRead(id);
} catch {
this.notifications = previous;
this.unread = wasUnread;
}
}
/** Applies a change made by somebody else. */
#applyRemote(event) {
const task = event.payload;
switch (event.type) {
// Beskeder kommer på personens egen kanal, ikke projektets: en note delt
// med én person hører ikke til noget projekt, og der er intet rum at
// udsende den i.
case 'notification':
this.notifications = [
{ ...event.payload, read: false },
...this.notifications.filter((n) => n.id !== event.payload?.id)
].slice(0, 30);
this.unread = this.notifications.filter((n) => !n.read).length;
break;
// En rettelse, der er lagt oven i en besked, man allerede har. Tallet er
// uændret; linjen er en anden. Hentet frem for flettet, fordi serveren
// har det rigtige svar og listen er tredive rækker.
case 'notification.folded':
this.loadNotifications();
break;
case 'task.created':
case 'task.updated':
case 'task.completed':
+11
View File
@@ -1,6 +1,7 @@
<script>
import '../app.css';
import { app, theme, sidebar } from '$lib/stores.svelte.js';
import Notifications from '$lib/components/Notifications.svelte';
import { connectivity } from '$lib/connectivity.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -9,6 +10,7 @@
import SignIn from '$lib/components/SignIn.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
let { children } = $props();
@@ -52,6 +54,13 @@
app.load();
});
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
$effect(() => {
setBadge(app.unread);
});
/**
* Global keys.
*
@@ -169,6 +178,8 @@
<kbd>⌘K</kbd>
</button>
<Notifications />
<button
class="theme"
onclick={() => theme.toggle()}
@@ -289,6 +289,9 @@
let feedURL = $state('');
let mailAddress = $state('');
// Krogen ind i indbakken: den URL, en genvej på telefonen eller et script kan
// skubbe en linje tekst til.
let hookURL = $state('');
/** Null until loaded, so the warning does not flash before the answer is in. */
let mailConfigured = $state(null);
@@ -301,6 +304,10 @@
mailConfigured = r.configured;
})
.catch(() => {});
api
.getHookURL()
.then((r) => (hookURL = r.url))
.catch(() => {});
});
async function rotateFeed() {
@@ -323,6 +330,15 @@
}
}
async function rotateHook() {
if (!confirm(t('int.newHookQuestion'))) return;
try {
hookURL = (await api.rotateHookURL()).url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
@@ -674,6 +690,38 @@
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
{t('int.hookHint')}
</p>
</header>
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly />
<p class="hint">
{t('int.hookHow')}
</p>
</div>
<!-- Vist som den kommando, den er. Den, der skal sætte det op, skal kunne
prøve det af ét sted, før de bygger genvejen på telefonen — og se, at
kroppen bare er den linje, opgaven skal hedde. -->
<div class="field">
<label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
>
</div>
<div class="row">
<button class="secondary" onclick={() => copy(hookURL)}>{t('int.copy')}</button>
<button class="danger" onclick={rotateHook}>{t('int.newHook')}</button>
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.caldav')}</h2>
+331 -18
View File
@@ -192,11 +192,38 @@
// hentes kun for en note, man selv har lavet.
let shares = $state([]);
let shareCandidates = $state([]);
// De noter, denne note tager med sig, når den deles. Ejeren skal kunne se dem:
// en deling, der stille tager tre noter mere med, er rigtig og alligevel ikke
// til at overskue.
let shareFollows = $state([]);
let sharePick = $state('');
let shareRole = $state('viewer');
let shareBusy = $state(false);
// De inviterede: delinger, der venter på, at der bliver oprettet en konto.
// De står i panelet ved siden af dem, der er kommet — ellers er en invitation
// sendt i går usynlig, og så sender man den igen.
let shareInvites = $state([]);
let shareEmail = $state('');
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
// frem, og det skal kunne kopieres frem for at forsvinde.
let shareInviteLink = $state('');
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// Dem, et `@` kan betyde i den åbne note.
//
// I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt
// med: en omtale af en, der læser med i forvejen, er en almindelig ting at
// skrive, og den giver dem besked selv om den ikke giver dem adgang.
//
// I en andens note er det dem, man deler projekter med. En medredaktørs omtale
// deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens,
// og skal heller ikke være det.
const mentionable = $derived(
ownsSelected
? [...shareCandidates, ...shares.map((sh) => sh.user)]
: app.people.filter((p) => p.id !== app.user?.id)
);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
@@ -223,6 +250,10 @@
// under den nye.
shares = [];
shareCandidates = [];
shareFollows = [];
shareInvites = [];
shareEmail = '';
shareInviteLink = '';
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
@@ -232,6 +263,8 @@
if (!alive || selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
})
.catch(() => {});
return () => {
@@ -246,10 +279,20 @@
const person = shareCandidates.find((p) => p.id === sharePick);
shareBusy = true;
try {
await api.shareNote(selected.id, sharePick, shareRole);
const r = await api.shareNote(selected.id, sharePick, shareRole);
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
shareFollows = r?.follows ?? shareFollows;
// Sagt frem for stiltiende. Delingen tog flere noter med, og den, der
// delte, skal vide hvor mange, uden at skulle læse panelet efter.
app.toast(
shareFollows.length
? t('notes.sharedWithFollowers', {
name: person?.name ?? '',
count: String(shareFollows.length)
})
: t('notes.sharedToast', { name: person?.name ?? '' })
);
sharePick = '';
} catch (e) {
app.toast(humanMessage(e));
@@ -258,6 +301,54 @@
}
}
/**
* Deling med en adresse frem for et navn i listen.
*
* Ét felt til begge udfald. Man skriver adressen på den, der skal læse med, og
* det er serveren, der ved, om der er en konto bag: er der det, bliver det en
* deling med det samme, og ellers en invitation, der bliver til en deling, når
* kontoen oprettes. At skulle vide det på forhånd — og vælge det rigtige felt —
* er at skulle vide noget om instansens brugerliste for at dele en note.
*/
async function inviteByEmail() {
if (!selected || !shareEmail.trim() || shareBusy) return;
shareBusy = true;
shareInviteLink = '';
try {
const r = await api.shareNoteByEmail(selected.id, shareEmail.trim(), shareRole);
if (r?.invited) {
shareInvites = [...shareInvites, r.invited];
// Kan instansen ikke sende post, er linket det eneste, der findes:
// vist frem for slugt, så det kan gives videre ad anden vej.
if (!r.emailed && r.link) shareInviteLink = r.link;
app.toast(t('notes.invited', { email: r.invited.email }));
} else if (r?.user) {
shares = [...shares, { user: r.user, role: r.role ?? shareRole }].sort((a, b) =>
byName(a.user, b.user)
);
shareCandidates = shareCandidates.filter((p) => p.id !== r.user.id);
shareFollows = r?.follows ?? shareFollows;
app.toast(t('notes.sharedToast', { name: r.user.name ?? '' }));
}
shareEmail = '';
} catch (e) {
app.toast(humanMessage(e));
} finally {
shareBusy = false;
}
}
async function revokeInvite(inviteId, email) {
try {
await api.revokeNoteInvite(selected.id, inviteId);
shareInvites = shareInvites.filter((i) => i.id !== inviteId);
shareInviteLink = '';
app.toast(t('notes.inviteRevoked', { email }));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function changeShareRole(userId, role) {
try {
await api.shareNote(selected.id, userId, role);
@@ -583,6 +674,7 @@
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
await mentionsShared(id);
} catch (e) {
app.toast(humanMessage(e));
} finally {
@@ -590,6 +682,39 @@
}
}
/**
* Hvem gemningen delte noten med, fordi de blev nævnt i den.
*
* Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting
* blandt mange gemninger, og en tastepause skal ikke koste et opslag i
* delingerne, hver gang den falder. Panelet spørges i stedet — og kun når
* teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv
* bruger for at kigge efter.
*
* Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man
* skrev navnet på, skal sige det — også når panelet er lukket.
*/
async function mentionsShared(id) {
if (!ownsSelected || !draft.includes('@')) return;
const known = new Set(shares.map((sh) => sh.user.id));
try {
const r = await api.noteShares(id);
if (selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
const added = shares.filter((sh) => !known.has(sh.user.id));
if (added.length) {
app.toast(
t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') })
);
}
} catch {
// En liste, der ikke kunne hentes, er ikke en gemning, der gik galt.
}
}
/**
* Favourites are the pinned ones. The list already puts them first, so marking
* one is the same act as saying "keep this where I can see it" — one idea, not
@@ -665,17 +790,38 @@
* to their own pages. Until now these were text and nothing else: the note said
* it pointed somewhere and there was no way to go.
*/
/**
* Følger et link — og siger det, når det ikke kan følges.
*
* Et `[[link]]` peger på en note ved dens *titel*, så det skal slås op. Først i
* listen, der allerede er hentet, og ellers med en søgning, fordi listen kan
* være snævret ind af et søgeord.
*
* Kun en titel, der passer helt. Der stod `?? found[0]` her, så et link, der
* ikke kunne findes, åbnede den første note, søgningen tilfældigvis gav — og i
* en *delt* note er det næsten altid den note, man allerede står i: den nævner
* jo titlen, den linker til. Så linket så ud til ikke at gøre noget som helst,
* hvilket er præcis sådan det blev meldt. Et link, der rammer forbi, skal sige
* det, ikke ramme et andet sted.
*
* Beskeden nævner delingen, fordi det er den almindelige årsag: en note, der
* deles, deler ikke de noter, den peger på, og modtageren kan derfor se linket
* uden at kunne åbne det. Herfra kan de to tilfælde ikke skelnes — svaret er
* det samme, hvad enten noten ikke findes eller bare ikke er ens egen — og
* sætningen siger dem begge frem for at gætte på den ene.
*/
async function openLink(link) {
if (link.kind === 'note') {
const wanted = link.target_id.toLowerCase();
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
const wanted = link.target_id.trim().toLowerCase();
const titled = (n) => (n.title ?? '').trim().toLowerCase();
const here = notes.find((n) => titled(n) === wanted);
if (here) {
open(here);
return;
}
try {
const found = (await api.notes({ q: link.target_id })).notes ?? [];
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
const hit = found.find((n) => titled(n) === wanted);
if (hit) open(hit);
else app.toast(t('notes.linkMissing'));
} catch (e) {
@@ -931,6 +1077,7 @@
<NoteEditor
note={selected}
notes={notes}
people={mentionable}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
@@ -1003,25 +1150,68 @@
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
<!-- En adgang, der er fulgt med et link, er ikke valgt her og
kan ikke tages tilbage her: den følger den note, der peger
på denne. Rollen og krydset ville begge love noget, det
næste opslag rullede tilbage — så de står ikke. -->
{#if sh.linked}
<span class="viafollow"
>{t('notes.shareVia', { note: sh.via ?? '' })}</span
>
{:else}
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareFollows.length}
<!-- Hvad delingen tager med sig. Et link i en delt note skal føre
samme sted hen for begge, og det kræver, at det, den peger på,
også er delt — men så skal det stå her, hvor den, der deler,
kan se det. -->
<p class="follows">
{t('notes.shareFollows')}
{#each shareFollows as f, i (f.note_id)}<button
class="followlink"
onclick={() => openLink({ kind: 'note', target_id: f.title })}
>{f.title}</button
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
</p>
{/if}
{#if shareInvites.length}
<!-- Sendt, ikke taget imod endnu. Adressen er alt, hvad der findes
om personen indtil de dukker op — der er hverken navn eller
farve at vise, og det er selve pointen med linjen. -->
<ul class="invited">
{#each shareInvites as inv (inv.id)}
<li>
<span class="who">{inv.email}</span>
<span class="pending">{t('notes.invitePending')}</span>
<button
class="unshare"
onclick={() => revokeInvite(inv.id, inv.email)}
title={t('notes.inviteRevoke')}
aria-label={t('notes.inviteRevoke')}>×</button
>
</li>
{/each}
</ul>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
@@ -1043,6 +1233,40 @@
</button>
</div>
{/if}
<!-- Og den, der ikke står i listen. Ét felt til begge udfald: har
adressen en konto, bliver det en deling med det samme, og ellers
en invitation. Hvem der har en konto, er ikke noget, man skal
vide for at dele en note. -->
<form
class="addshare byemail"
onsubmit={(e) => {
e.preventDefault();
inviteByEmail();
}}
>
<input
bind:value={shareEmail}
type="email"
placeholder={t('notes.shareEmailPlaceholder')}
aria-label={t('notes.shareByEmail')}
/>
<select bind:value={shareRole} aria-label={t('notes.shareRole')}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button class="button" type="submit" disabled={!shareEmail || shareBusy}>
{t('notes.shareInvite')}
</button>
</form>
{#if shareInviteLink}
<!-- Ingen post at sende det med. Så er linket det eneste, der
findes, og det skal kunne læses og kopieres frem for at blive
lovet i en besked, der ikke kom af sted. -->
<p class="invitelink">
{t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
</p>
{/if}
</section>
</div>
{/if}
@@ -1889,6 +2113,95 @@
background: var(--surface-raised);
}
/* Adgang, der er fulgt med et link. Sat i stedet for rollen og krydset, altså
yderst til højre, hvor øjet allerede leder efter "hvad kan denne række". */
.sharelist .viafollow {
margin-left: auto;
color: var(--ink-faint);
font-size: var(--text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hvad delingen tager med sig. Under listen af folk, fordi det gælder dem alle
— ikke én linje pr. person, som ville sige det samme tre gange. */
.follows {
margin: var(--s1) 0 0;
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.followlink {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.followlink:hover {
text-decoration: underline;
}
/* De inviterede. Samme form som listen ovenover, fordi det er den samme slags
linje — en person, noten er delt med — og forskellen er, at de ikke er kommet
endnu. Den forskel siges med ordet til højre, ikke med en anden form. */
.invited {
list-style: none;
margin: 2px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.invited li {
display: flex;
align-items: center;
gap: var(--s1);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.invited .who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invited .pending {
margin-left: auto;
color: var(--ink-faint);
white-space: nowrap;
}
/* Adressefeltet får sin egen række af samme grund som personvælgeren: en
e-mailadresse klemt ned ved siden af en rolle og en knap bliver et felt, man
ikke kan læse det, man har skrevet i. */
.byemail input {
flex: 1 1 100%;
min-width: 0;
font-size: var(--text-xs);
padding: 2px var(--s1);
}
.invitelink {
margin: var(--s1) 0 0;
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.invitelink input {
width: 100%;
margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1);
}
.addshare {
display: flex;
align-items: center;
+13
View File
@@ -145,6 +145,19 @@ self.addEventListener('push', (event) => {
}
const title = payload.title || 'verdande';
// Mærket på appens ikon — i Dock'en på en Mac, på proceslinjen på en pc.
//
// Sat her, fordi det her er det eneste, der kører, når appen ikke er åben: det
// er netop dér, et tal på ikonet er det, der siger, at der er noget nyt. Tallet
// kommer med beskeden frem for at blive talt op her; en service-worker husker
// ingenting mellem to opvågninger, så et mærke, den selv talte, ville kun kunne
// gå opad.
//
// Kan browseren ikke sætte mærker, sker der ingenting. Det er ikke en fejl at
// melde om: beskeden selv kom frem, og den er hovedsagen.
if (typeof payload.unread === 'number' && 'setAppBadge' in self.navigator) {
self.navigator.setAppBadge(payload.unread).catch(() => {});
}
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',