Compare commits

...
Author SHA1 Message Date
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
10 changed files with 749 additions and 38 deletions
+37 -1
View File
@@ -3126,10 +3126,20 @@ paths:
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
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 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:
@@ -3141,7 +3151,16 @@ paths:
user_id: { type: string }
role: { type: string, enum: [viewer, editor] }
responses:
"204": { description: Shared }
"200":
description: Shared
content:
application/json:
schema:
type: object
properties:
follows:
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
@@ -4035,6 +4054,23 @@ 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 }
Mailbox:
type: object
+72
View File
@@ -204,9 +204,49 @@ 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)
}
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)
}
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
@@ -242,6 +282,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 +290,41 @@ 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)
}
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 +348,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)
}
+73 -4
View File
@@ -77,12 +77,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,12 +128,41 @@ 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})
writeJSON(w, http.StatusOK, map[string]any{
"shares": out,
"candidates": cand,
"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 {
@@ -159,7 +213,15 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
s.storeError(w, r, "share note", err)
return
}
w.WriteHeader(http.StatusNoContent)
// 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(), userFrom(r.Context()).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)})
}
// handleUnshareNote takes a person's access away again.
@@ -177,5 +239,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")
}
}
@@ -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;
+241
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"time"
)
@@ -131,3 +133,242 @@ 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
}
+111 -15
View File
@@ -2875,13 +2875,13 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
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('Prisliste for foraaret');
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('Aftale om levering\n');
await page.keyboard.type('[[Prisliste for foraaret]]');
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å.
@@ -2891,7 +2891,7 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await gæst.keyboard.type('Linneas egne maal');
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
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();
@@ -2901,19 +2901,19 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await expect(row).toBeVisible();
await row.locator('select').selectOption('editor');
// Hun skriver sit eget link ind i den delte note.
// 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').getByRole('button', { name: /Aftale om levering/ }).click();
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('Prisliste for foraaret');
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]]');
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').getByRole('button', { name: /Aftale om levering/ }).click();
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.
@@ -2922,22 +2922,118 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
'Linneas egne maal'
);
// Ejerens link peger på en note, hun ikke har fået. Den må ikke lande et
// tilfældigt sted — og den må sige hvorfor.
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
// 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 expect(igen).toContainText('Prisliste for foraaret');
await igen.locator('a.notelink', { hasText: 'Prisliste for foraaret' }).click();
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(
'Aftale om levering'
'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.
*
+3
View File
@@ -34,6 +34,9 @@ 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.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
+3
View File
@@ -30,6 +30,9 @@ 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.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
+87 -15
View File
@@ -192,6 +192,10 @@
// 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);
@@ -223,6 +227,7 @@
// under den nye.
shares = [];
shareCandidates = [];
shareFollows = [];
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
@@ -232,6 +237,7 @@
if (!alive || selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
})
.catch(() => {});
return () => {
@@ -246,10 +252,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));
@@ -1024,25 +1040,49 @@
></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 shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
@@ -1910,6 +1950,38 @@
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;
}
.addshare {
display: flex;
align-items: center;