Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48509e3073 | ||
|
|
916a0c0224 | ||
|
|
ff75302cf8 | ||
|
|
08d703e3af |
+37
-1
@@ -3126,10 +3126,20 @@ paths:
|
|||||||
candidates:
|
candidates:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Person" }
|
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" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Share a note with a person, or change the role they hold.
|
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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3141,7 +3151,16 @@ paths:
|
|||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
role: { type: string, enum: [viewer, editor] }
|
||||||
responses:
|
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" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
@@ -4035,6 +4054,23 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
user: { $ref: "#/components/schemas/Person" }
|
user: { $ref: "#/components/schemas/Person" }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
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:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -204,9 +204,49 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "create note", err)
|
s.internal(w, r, "create note", err)
|
||||||
return
|
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)
|
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) {
|
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||||
var body noteBody
|
var body noteBody
|
||||||
if err := decodeJSON(w, r, &body); err != nil {
|
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
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
|
before := n.Body
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -249,13 +290,41 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
n.Pinned = *body.Pinned
|
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 {
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||||
s.internal(w, r, "update note", err)
|
s.internal(w, r, "update note", err)
|
||||||
return
|
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)
|
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) {
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,6 +348,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "delete note", err)
|
s.internal(w, r, "delete note", err)
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,12 +77,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list note shares", err)
|
s.internal(w, r, "list note shares", err)
|
||||||
return
|
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))
|
out := make([]noteShareJSON, 0, len(shares))
|
||||||
already := map[string]bool{}
|
already := map[string]bool{}
|
||||||
for _, sh := range shares {
|
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{
|
out = append(out, noteShareJSON{
|
||||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||||
Role: string(sh.Role),
|
Role: string(sh.Role),
|
||||||
|
Linked: via[sh.User.ID] != "",
|
||||||
|
Via: via[sh.User.ID],
|
||||||
})
|
})
|
||||||
already[sh.User.ID] = true
|
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})
|
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 {
|
type noteShareJSON struct {
|
||||||
User personJSON `json:"user"`
|
User personJSON `json:"user"`
|
||||||
Role string `json:"role"`
|
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 {
|
type shareNoteRequest struct {
|
||||||
@@ -159,7 +213,15 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storeError(w, r, "share note", err)
|
s.storeError(w, r, "share note", err)
|
||||||
return
|
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.
|
// 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)
|
s.internal(w, r, "unshare note", err)
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
|||||||
// Share it as a viewer.
|
// Share it as a viewer.
|
||||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
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)
|
t.Fatalf("share: status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
|||||||
|
|
||||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
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")
|
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)
|
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||||
}
|
}
|
||||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
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")
|
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;
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,3 +133,242 @@ func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []stri
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2838,6 +2838,238 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
|||||||
expect(trouble).toEqual([]);
|
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 ({
|
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -164,6 +164,111 @@
|
|||||||
changed();
|
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.
|
* Steps out of a code block on Enter.
|
||||||
*
|
*
|
||||||
@@ -223,7 +328,11 @@
|
|||||||
underline: document.queryCommandState('underline'),
|
underline: document.queryCommandState('underline'),
|
||||||
strike: document.queryCommandState('strikeThrough'),
|
strike: document.queryCommandState('strikeThrough'),
|
||||||
bullet: document.queryCommandState('insertUnorderedList'),
|
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 {
|
} catch {
|
||||||
// Some browsers throw on queryCommandState with no selection. The toolbar
|
// Some browsers throw on queryCommandState with no selection. The toolbar
|
||||||
@@ -641,6 +750,9 @@
|
|||||||
if (key === 'u') {
|
if (key === 'u') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
apply('underline');
|
apply('underline');
|
||||||
|
} else if (key === 'e') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleCode();
|
||||||
} else if (key === 'x' && event.shiftKey) {
|
} else if (key === 'x' && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
apply('strikeThrough');
|
apply('strikeThrough');
|
||||||
@@ -788,19 +900,50 @@
|
|||||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||||
<s>S</s>
|
<s>S</s>
|
||||||
</button>
|
</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"></></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<span class="sep" aria-hidden="true"></span>
|
<span class="sep" aria-hidden="true"></span>
|
||||||
|
|
||||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
<!-- 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
|
<button
|
||||||
class="src"
|
class="src"
|
||||||
class:on={asSource}
|
class:on={asSource}
|
||||||
onclick={toggleSource}
|
onclick={toggleSource}
|
||||||
aria-pressed={asSource}
|
aria-pressed={asSource}
|
||||||
aria-label={t('notes.showSource')}
|
aria-label={t('notes.showSource')}
|
||||||
title={t('notes.showSource')}></></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>
|
</div>
|
||||||
|
|
||||||
{#if asSource}
|
{#if asSource}
|
||||||
@@ -1132,11 +1275,24 @@
|
|||||||
resize: none;
|
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-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
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":
|
/* 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
|
a title that is a title, and body text that is comfortable to read for a page
|
||||||
at a time. */
|
at a time. */
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const da = {
|
|||||||
// Deling med enkeltpersoner, ved siden af projektet.
|
// Deling med enkeltpersoner, ved siden af projektet.
|
||||||
'notes.groupSharedWithMe': 'Delt med mig',
|
'notes.groupSharedWithMe': 'Delt med mig',
|
||||||
'notes.linkOpen': 'Åbn',
|
'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.sharedBy': 'Delt af {name}',
|
||||||
'notes.sharePeople': 'Del med personer',
|
'notes.sharePeople': 'Del med personer',
|
||||||
'notes.sharePick': 'Vælg en person …',
|
'notes.sharePick': 'Vælg en person …',
|
||||||
@@ -34,6 +34,9 @@ export const da = {
|
|||||||
'notes.shareRemove': 'Fjern adgang',
|
'notes.shareRemove': 'Fjern adgang',
|
||||||
'notes.shareNobody': 'Ikke delt med nogen endnu.',
|
'notes.shareNobody': 'Ikke delt med nogen endnu.',
|
||||||
'notes.sharedToast': 'Delt med {name}.',
|
'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.unsharedPerson': '{name} kan ikke længere se noten.',
|
||||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||||
'notes.save': 'Gem',
|
'notes.save': 'Gem',
|
||||||
@@ -76,7 +79,8 @@ export const da = {
|
|||||||
'notes.copied': 'Kopieret',
|
'notes.copied': 'Kopieret',
|
||||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.showSource': 'Vis som kode',
|
'notes.code': 'Kode',
|
||||||
|
'notes.showSource': 'Vis kilden (Markdown)',
|
||||||
'notes.source': 'Notens kildetekst',
|
'notes.source': 'Notens kildetekst',
|
||||||
'notes.styleTitle': 'Titel',
|
'notes.styleTitle': 'Titel',
|
||||||
'notes.styleHeading': 'Overskrift',
|
'notes.styleHeading': 'Overskrift',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const en = {
|
|||||||
// Sharing with individual people, alongside the project.
|
// Sharing with individual people, alongside the project.
|
||||||
'notes.groupSharedWithMe': 'Shared with me',
|
'notes.groupSharedWithMe': 'Shared with me',
|
||||||
'notes.linkOpen': 'Open',
|
'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.sharedBy': 'Shared by {name}',
|
||||||
'notes.sharePeople': 'Share with people',
|
'notes.sharePeople': 'Share with people',
|
||||||
'notes.sharePick': 'Choose someone …',
|
'notes.sharePick': 'Choose someone …',
|
||||||
@@ -30,6 +30,9 @@ export const en = {
|
|||||||
'notes.shareRemove': 'Remove access',
|
'notes.shareRemove': 'Remove access',
|
||||||
'notes.shareNobody': 'Not shared with anyone yet.',
|
'notes.shareNobody': 'Not shared with anyone yet.',
|
||||||
'notes.sharedToast': 'Shared with {name}.',
|
'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.unsharedPerson': '{name} can no longer see the note.',
|
||||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||||
'notes.save': 'Save',
|
'notes.save': 'Save',
|
||||||
@@ -72,7 +75,8 @@ export const en = {
|
|||||||
'notes.copied': 'Copied',
|
'notes.copied': 'Copied',
|
||||||
'notes.copyFailed': 'Could not copy',
|
'notes.copyFailed': 'Could not copy',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.showSource': 'Show as code',
|
'notes.code': 'Code',
|
||||||
|
'notes.showSource': 'Show the source (Markdown)',
|
||||||
'notes.source': 'Note source',
|
'notes.source': 'Note source',
|
||||||
'notes.styleTitle': 'Title',
|
'notes.styleTitle': 'Title',
|
||||||
'notes.styleHeading': 'Heading',
|
'notes.styleHeading': 'Heading',
|
||||||
|
|||||||
@@ -192,6 +192,10 @@
|
|||||||
// hentes kun for en note, man selv har lavet.
|
// hentes kun for en note, man selv har lavet.
|
||||||
let shares = $state([]);
|
let shares = $state([]);
|
||||||
let shareCandidates = $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 sharePick = $state('');
|
||||||
let shareRole = $state('viewer');
|
let shareRole = $state('viewer');
|
||||||
let shareBusy = $state(false);
|
let shareBusy = $state(false);
|
||||||
@@ -223,6 +227,7 @@
|
|||||||
// under den nye.
|
// under den nye.
|
||||||
shares = [];
|
shares = [];
|
||||||
shareCandidates = [];
|
shareCandidates = [];
|
||||||
|
shareFollows = [];
|
||||||
sharePick = '';
|
sharePick = '';
|
||||||
if (!id || selected.created_by !== app.user?.id) return;
|
if (!id || selected.created_by !== app.user?.id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -232,6 +237,7 @@
|
|||||||
if (!alive || selected?.id !== id) return;
|
if (!alive || selected?.id !== id) return;
|
||||||
shares = r.shares ?? [];
|
shares = r.shares ?? [];
|
||||||
shareCandidates = r.candidates ?? [];
|
shareCandidates = r.candidates ?? [];
|
||||||
|
shareFollows = r.follows ?? [];
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -246,10 +252,20 @@
|
|||||||
const person = shareCandidates.find((p) => p.id === sharePick);
|
const person = shareCandidates.find((p) => p.id === sharePick);
|
||||||
shareBusy = true;
|
shareBusy = true;
|
||||||
try {
|
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));
|
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
|
||||||
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
|
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 = '';
|
sharePick = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
@@ -665,17 +681,38 @@
|
|||||||
* to their own pages. Until now these were text and nothing else: the note said
|
* 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.
|
* 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) {
|
async function openLink(link) {
|
||||||
if (link.kind === 'note') {
|
if (link.kind === 'note') {
|
||||||
const wanted = link.target_id.toLowerCase();
|
const wanted = link.target_id.trim().toLowerCase();
|
||||||
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
const titled = (n) => (n.title ?? '').trim().toLowerCase();
|
||||||
|
const here = notes.find((n) => titled(n) === wanted);
|
||||||
if (here) {
|
if (here) {
|
||||||
open(here);
|
open(here);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
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);
|
if (hit) open(hit);
|
||||||
else app.toast(t('notes.linkMissing'));
|
else app.toast(t('notes.linkMissing'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1003,25 +1040,49 @@
|
|||||||
></span>
|
></span>
|
||||||
{sh.user.name}
|
{sh.user.name}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<!-- En adgang, der er fulgt med et link, er ikke valgt her og
|
||||||
value={sh.role}
|
kan ikke tages tilbage her: den følger den note, der peger
|
||||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
på denne. Rollen og krydset ville begge love noget, det
|
||||||
>
|
næste opslag rullede tilbage — så de står ikke. -->
|
||||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
{#if sh.linked}
|
||||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
<span class="viafollow"
|
||||||
</select>
|
>{t('notes.shareVia', { note: sh.via ?? '' })}</span
|
||||||
<button
|
>
|
||||||
class="unshare"
|
{:else}
|
||||||
onclick={() => removeShare(sh.user.id)}
|
<select
|
||||||
title={t('notes.shareRemove')}
|
value={sh.role}
|
||||||
aria-label={t('notes.shareRemove')}>×</button
|
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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="none">{t('notes.shareNobody')}</span>
|
<span class="none">{t('notes.shareNobody')}</span>
|
||||||
{/if}
|
{/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}
|
{#if shareCandidates.length}
|
||||||
<div class="addshare">
|
<div class="addshare">
|
||||||
<select bind:value={sharePick}>
|
<select bind:value={sharePick}>
|
||||||
@@ -1889,6 +1950,38 @@
|
|||||||
background: var(--surface-raised);
|
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 {
|
.addshare {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user