Merge pull request #64 from kristianwind/tag-en-person-i-en-note
feat(noter): nævn en person i en note, og den er delt med dem
This commit is contained in:
@@ -3017,6 +3017,12 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Write a note.
|
summary: Write a note.
|
||||||
|
description: >
|
||||||
|
`#Projekt` files the note against a project and `[[Another note]]` links to
|
||||||
|
one. A third, `@Name`, shares the note with that person as a viewer and
|
||||||
|
tells them — only the owner's mentions share, and only a name that has just
|
||||||
|
appeared in the text counts, so a note saved on every typing pause does not
|
||||||
|
share itself twice.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3080,6 +3086,11 @@ paths:
|
|||||||
patch:
|
patch:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Change a note. Every field is optional; what is sent is what changes.
|
summary: Change a note. Every field is optional; what is sent is what changes.
|
||||||
|
description: >
|
||||||
|
A name written with an `@` that was not in the text before shares the note
|
||||||
|
with that person as a viewer and tells them. Only the owner's mentions
|
||||||
|
share — an editor may write the name, but giving somebody else's note away
|
||||||
|
is the owner's alone — and a role already granted is never lowered by one.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"is lost. Two things in the text are read out of it and become links: " +
|
"is lost. Two things in the text are read out of it and become links: " +
|
||||||
"`#Projekt` files the note against that project, so it appears on the " +
|
"`#Projekt` files the note against that project, so it appears on the " +
|
||||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
"links to a note by its title. A third, `@Navn`, shares the note with that " +
|
||||||
|
"person and tells them — so only write one when the note is meant for them. " +
|
||||||
|
"Use this to bring notes in from elsewhere — " +
|
||||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||||
if strings.TrimSpace(n.Body) != "" {
|
if strings.TrimSpace(n.Body) != "" {
|
||||||
s.followLinks(r, n)
|
s.followLinks(r, n)
|
||||||
|
s.shareWithMentioned(r, n, "")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, n)
|
writeJSON(w, http.StatusCreated, n)
|
||||||
}
|
}
|
||||||
@@ -247,6 +248,108 @@ func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
|
||||||
|
//
|
||||||
|
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
|
||||||
|
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
|
||||||
|
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
|
||||||
|
//
|
||||||
|
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
|
||||||
|
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
|
||||||
|
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
|
||||||
|
//
|
||||||
|
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
|
||||||
|
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
|
||||||
|
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
|
||||||
|
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
|
||||||
|
// kun besked til en, der kan se noten i forvejen.
|
||||||
|
//
|
||||||
|
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
|
||||||
|
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
|
||||||
|
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
|
||||||
|
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
|
||||||
|
// fortæller dem, at noten er blevet rettet.
|
||||||
|
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
|
||||||
|
told := map[string]bool{}
|
||||||
|
if n == nil || n.CreatedBy == "" {
|
||||||
|
return told
|
||||||
|
}
|
||||||
|
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
|
||||||
|
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
|
||||||
|
// deles med den, der har den.
|
||||||
|
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("people for mentions", "err", err, "note", n.ID)
|
||||||
|
return told
|
||||||
|
}
|
||||||
|
|
||||||
|
was := map[string]bool{}
|
||||||
|
for _, id := range store.MentionsIn(before, people) {
|
||||||
|
was[id] = true
|
||||||
|
}
|
||||||
|
fresh := []string{}
|
||||||
|
for _, id := range store.MentionsIn(n.Body, people) {
|
||||||
|
if !was[id] {
|
||||||
|
fresh = append(fresh, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fresh) == 0 {
|
||||||
|
return told
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
owner := actor.ID == n.CreatedBy
|
||||||
|
shared := false
|
||||||
|
for _, id := range fresh {
|
||||||
|
if !owner {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
|
||||||
|
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
|
||||||
|
// skrev deres navn i teksten.
|
||||||
|
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
|
||||||
|
s.log.Warn("mention share role", "err", err, "note", n.ID)
|
||||||
|
continue
|
||||||
|
} else if has {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
|
||||||
|
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shared = true
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
// Og det, noten peger på, følger med delingen — samme vej som en deling
|
||||||
|
// sat i panelet.
|
||||||
|
s.followLinks(r, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
|
||||||
|
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
|
||||||
|
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
|
||||||
|
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
|
||||||
|
return told
|
||||||
|
}
|
||||||
|
canSee := map[string]bool{}
|
||||||
|
for _, id := range audience {
|
||||||
|
canSee[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range fresh {
|
||||||
|
if !canSee[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||||
|
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
|
||||||
|
})
|
||||||
|
told[id] = true
|
||||||
|
}
|
||||||
|
return told
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
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 {
|
||||||
@@ -310,7 +413,11 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
||||||
// det ville lære folk at overse dem alle sammen.
|
// det ville lære folk at overse dem alle sammen.
|
||||||
if before != n.Body {
|
if before != n.Body {
|
||||||
s.notifyNoteChanged(r, n)
|
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
|
||||||
|
// ikke også "rettede en note" om den samme gemning — det er én handling, og
|
||||||
|
// den ene sætning siger mere end den anden.
|
||||||
|
mentioned := s.shareWithMentioned(r, n, before)
|
||||||
|
s.notifyNoteChanged(r, n, mentioned)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, n)
|
writeJSON(w, http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
|
||||||
|
// noten, og den, der bliver nævnt, får besked om det.
|
||||||
|
func TestMentioningSomebodySharesTheNote(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||||
|
sofie := userID(t, owner, "[email protected]")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ret noten: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delt, som læser.
|
||||||
|
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||||
|
if err != nil || !has || role != "viewer" {
|
||||||
|
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
|
||||||
|
// handling, og den ene sætning siger mere end den anden.
|
||||||
|
list, unread := notifications(t, other)
|
||||||
|
if len(list) != 1 || unread != 1 {
|
||||||
|
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
||||||
|
}
|
||||||
|
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
|
||||||
|
t.Errorf("besked = %v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
|
||||||
|
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
|
||||||
|
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
|
||||||
|
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
|
||||||
|
if list, _ := notifications(t, other); len(list) != 1 {
|
||||||
|
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
|
||||||
|
// omtaler mere.
|
||||||
|
list, _ := notifications(t, other)
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
|
||||||
|
}
|
||||||
|
if firstOf(t, list)["kind"] != "note.changed" {
|
||||||
|
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
|
||||||
|
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
|
||||||
|
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
editor := owner.newUser(t, "[email protected]", "Sofie")
|
||||||
|
sofie := userID(t, owner, "[email protected]")
|
||||||
|
peterClient := owner.newUser(t, "[email protected]", "Peter")
|
||||||
|
peter := userID(t, owner, "[email protected]")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("del noten")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("medredaktøren retter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
|
||||||
|
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
|
||||||
|
}
|
||||||
|
// Og Peter får ikke besked om en note, han ikke kan åbne.
|
||||||
|
if list, _ := notifications(t, peterClient); len(list) != 0 {
|
||||||
|
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
|
||||||
|
}
|
||||||
|
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
|
||||||
|
if list, _ := notifications(t, owner); len(list) == 0 {
|
||||||
|
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
|
||||||
|
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
|
||||||
|
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
owner.newUser(t, "[email protected]", "Sofie")
|
||||||
|
sofie := userID(t, owner, "[email protected]")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("del noten")
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||||
|
if err != nil || role != "editor" {
|
||||||
|
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,10 +143,13 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
|||||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
// it — folded into one line per note while it is unread.
|
// it — folded into one line per note while it is unread.
|
||||||
//
|
//
|
||||||
|
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
|
||||||
|
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
|
||||||
|
//
|
||||||
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
||||||
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
||||||
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
||||||
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
|
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,7 @@ func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
|
|||||||
title := actor.Name + " rettede en note"
|
title := actor.Name + " rettede en note"
|
||||||
body := n.Title
|
body := n.Title
|
||||||
for _, userID := range audience {
|
for _, userID := range audience {
|
||||||
if userID == actor.ID {
|
if userID == actor.ID || except[userID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Fold first, and only write a row when there was nothing to fold into.
|
// Fold first, and only write a row when there was nothing to fold into.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MentionsIn reads the people named with an @ out of a body, and returns their
|
||||||
|
// ids in the order they are written.
|
||||||
|
//
|
||||||
|
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
|
||||||
|
// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville
|
||||||
|
// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der
|
||||||
|
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
|
||||||
|
// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke.
|
||||||
|
//
|
||||||
|
// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang,
|
||||||
|
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
|
||||||
|
//
|
||||||
|
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
|
||||||
|
// efternavn hængende bagefter — også når begge findes som konti.
|
||||||
|
func MentionsIn(body string, people []Person) []string {
|
||||||
|
if body == "" || len(people) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
|
||||||
|
byLength := make([]Person, len(people))
|
||||||
|
copy(byLength, people)
|
||||||
|
sort.SliceStable(byLength, func(i, j int) bool {
|
||||||
|
return len(byLength[i].Name) > len(byLength[j].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
|
||||||
|
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
|
||||||
|
// noten ville blive delt med en, der ikke står i den.
|
||||||
|
claimed := make([]bool, len(body))
|
||||||
|
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
|
||||||
|
|
||||||
|
for _, p := range byLength {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(p.Name))
|
||||||
|
if name == "" || p.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
needle := "@" + name
|
||||||
|
for at := 0; ; {
|
||||||
|
i := strings.Index(lower[at:], needle)
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += at
|
||||||
|
at = i + len(needle)
|
||||||
|
|
||||||
|
end := i + len(needle)
|
||||||
|
if overlaps(claimed, i, end) || !isMention(body, i, end) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i; j < end; j++ {
|
||||||
|
claimed[j] = true
|
||||||
|
}
|
||||||
|
if _, seen := found[p.ID]; !seen {
|
||||||
|
found[p.ID] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(found))
|
||||||
|
for id := range found {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlaps(claimed []bool, from, to int) bool {
|
||||||
|
for i := from; i < to && i < len(claimed); i++ {
|
||||||
|
if claimed[i] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMention er de to kanter om navnet.
|
||||||
|
//
|
||||||
|
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
|
||||||
|
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
|
||||||
|
// efter snabel-a'et.
|
||||||
|
//
|
||||||
|
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
|
||||||
|
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
|
||||||
|
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
|
||||||
|
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
|
||||||
|
func isMention(body string, from, to int) bool {
|
||||||
|
runes := []rune(body[:from])
|
||||||
|
if len(runes) > 0 {
|
||||||
|
prev := runes[len(runes)-1]
|
||||||
|
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := body[to:]
|
||||||
|
if rest == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
next := []rune(rest)
|
||||||
|
if next[0] == 's' || next[0] == 'S' {
|
||||||
|
next = next[1:]
|
||||||
|
if len(next) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMentionsIn(t *testing.T) {
|
||||||
|
people := []Person{
|
||||||
|
{ID: "sofie", Name: "Sofie"},
|
||||||
|
{ID: "sofie-jensen", Name: "Sofie Jensen"},
|
||||||
|
{ID: "kristian", Name: "Kristian"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"ingen omtale", "En helt almindelig note om noget", nil},
|
||||||
|
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
|
||||||
|
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
|
||||||
|
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
|
||||||
|
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
|
||||||
|
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
|
||||||
|
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
|
||||||
|
// med den, der står i den, og ikke også med en, der ikke gør.
|
||||||
|
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
|
||||||
|
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
|
||||||
|
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
|
||||||
|
{"e-mailadresse", "skriv til [email protected]", nil},
|
||||||
|
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
|
||||||
|
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
|
||||||
|
{"ukendt navn", "@Ingen her", nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := MentionsIn(tc.body, people)
|
||||||
|
if len(got) == 0 && len(tc.want) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
|
||||||
|
// tomt svar frem for et gæt på, hvad et @ mon betød.
|
||||||
|
func TestMentionsInWithoutPeople(t *testing.T) {
|
||||||
|
if got := MentionsIn("@Sofie hej", nil); got != nil {
|
||||||
|
t.Errorf("got %v, want nothing to resolve against", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
|
||||||
|
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
|
||||||
|
// to hentninger af den samme liste er to, der kan blive uenige.
|
||||||
|
let { note, notes = [], people = [], onchange, onsave, onopennote } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -87,6 +90,7 @@
|
|||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
{ mark: '#', what: 'notes.syntaxProject' },
|
{ mark: '#', what: 'notes.syntaxProject' },
|
||||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||||
|
{ mark: '@', what: 'notes.syntaxPerson' },
|
||||||
{ mark: '⌘B', what: 'notes.bold' },
|
{ mark: '⌘B', what: 'notes.bold' },
|
||||||
{ mark: '⌘U', what: 'notes.underline' }
|
{ mark: '⌘U', what: 'notes.underline' }
|
||||||
];
|
];
|
||||||
@@ -509,6 +513,19 @@
|
|||||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
|
||||||
|
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
|
||||||
|
// læse sætningen efter navnet med.
|
||||||
|
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
|
||||||
|
if (person) {
|
||||||
|
return {
|
||||||
|
kind: 'person',
|
||||||
|
node,
|
||||||
|
start: selection.anchorOffset - person[1].length,
|
||||||
|
term: person[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
if (project) {
|
if (project) {
|
||||||
return {
|
return {
|
||||||
@@ -562,7 +579,12 @@
|
|||||||
// pixels for hvert bogstav, mens listen snævres ind.
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
suggestions =
|
||||||
|
partial.kind === 'note'
|
||||||
|
? noteMatches(term)
|
||||||
|
: partial.kind === 'person'
|
||||||
|
? personMatches(term)
|
||||||
|
: projectMatches(term);
|
||||||
chosen = 0;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +608,16 @@
|
|||||||
.sort((a, b) => rank(a.label, b.label, term));
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
|
||||||
|
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
|
||||||
|
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
|
||||||
|
function personMatches(term) {
|
||||||
|
return people
|
||||||
|
.filter((p) => p.name && p.name.toLowerCase().includes(term))
|
||||||
|
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
function rank(a, b, term) {
|
function rank(a, b, term) {
|
||||||
const al = a.toLowerCase();
|
const al = a.toLowerCase();
|
||||||
const bl = b.toLowerCase();
|
const bl = b.toLowerCase();
|
||||||
@@ -1011,6 +1043,10 @@
|
|||||||
{#if item.kind === 'note'}
|
{#if item.kind === 'note'}
|
||||||
<span class="dot note" aria-hidden="true">[[</span>
|
<span class="dot note" aria-hidden="true">[[</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{:else if item.kind === 'person'}
|
||||||
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
@{item.label}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
'note.shared': 'notif.noteShared',
|
'note.shared': 'notif.noteShared',
|
||||||
|
'note.mention': 'notif.mention',
|
||||||
comment: 'notif.comment'
|
comment: 'notif.comment'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const da = {
|
|||||||
'notif.assigned': '{name} gav dig en opgave',
|
'notif.assigned': '{name} gav dig en opgave',
|
||||||
'notif.noteChanged': '{name} rettede en note',
|
'notif.noteChanged': '{name} rettede en note',
|
||||||
'notif.noteShared': '{name} delte en note med dig',
|
'notif.noteShared': '{name} delte en note med dig',
|
||||||
|
'notif.mention': '{name} nævnte dig i en note',
|
||||||
'notif.comment': '{name} kommenterede',
|
'notif.comment': '{name} kommenterede',
|
||||||
'notif.justNow': 'lige nu',
|
'notif.justNow': 'lige nu',
|
||||||
'notif.minutes': '{n} min. siden',
|
'notif.minutes': '{n} min. siden',
|
||||||
@@ -62,6 +63,10 @@ export const da = {
|
|||||||
'notes.inviteRevoke': 'Træk invitationen tilbage',
|
'notes.inviteRevoke': 'Træk invitationen tilbage',
|
||||||
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
|
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
|
||||||
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
|
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
|
||||||
|
// Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her,
|
||||||
|
// fordi det sker uden at nogen åbner delepanelet.
|
||||||
|
'notes.syntaxPerson': 'en person',
|
||||||
|
'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.',
|
||||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||||
'notes.save': 'Gem',
|
'notes.save': 'Gem',
|
||||||
'notes.format': 'Formatér',
|
'notes.format': 'Formatér',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const en = {
|
|||||||
'notif.assigned': '{name} gave you a task',
|
'notif.assigned': '{name} gave you a task',
|
||||||
'notif.noteChanged': '{name} edited a note',
|
'notif.noteChanged': '{name} edited a note',
|
||||||
'notif.noteShared': '{name} shared a note with you',
|
'notif.noteShared': '{name} shared a note with you',
|
||||||
|
'notif.mention': '{name} mentioned you in a note',
|
||||||
'notif.comment': '{name} commented',
|
'notif.comment': '{name} commented',
|
||||||
'notif.justNow': 'just now',
|
'notif.justNow': 'just now',
|
||||||
'notif.minutes': '{n} min ago',
|
'notif.minutes': '{n} min ago',
|
||||||
@@ -59,6 +60,10 @@ export const en = {
|
|||||||
'notes.inviteRevoke': 'Withdraw the invitation',
|
'notes.inviteRevoke': 'Withdraw the invitation',
|
||||||
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
|
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
|
||||||
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
|
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
|
||||||
|
// A mention. `@Name` in a note shares it with the person named — said here,
|
||||||
|
// because it happens without anybody opening the share panel.
|
||||||
|
'notes.syntaxPerson': 'a person',
|
||||||
|
'notes.mentionShared': 'The note has been shared with {names}, because you named them.',
|
||||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||||
'notes.save': 'Save',
|
'notes.save': 'Save',
|
||||||
'notes.format': 'Format',
|
'notes.format': 'Format',
|
||||||
|
|||||||
@@ -209,6 +209,21 @@
|
|||||||
let shareInviteLink = $state('');
|
let shareInviteLink = $state('');
|
||||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||||
|
|
||||||
|
// Dem, et `@` kan betyde i den åbne note.
|
||||||
|
//
|
||||||
|
// I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt
|
||||||
|
// med: en omtale af en, der læser med i forvejen, er en almindelig ting at
|
||||||
|
// skrive, og den giver dem besked selv om den ikke giver dem adgang.
|
||||||
|
//
|
||||||
|
// I en andens note er det dem, man deler projekter med. En medredaktørs omtale
|
||||||
|
// deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens,
|
||||||
|
// og skal heller ikke være det.
|
||||||
|
const mentionable = $derived(
|
||||||
|
ownsSelected
|
||||||
|
? [...shareCandidates, ...shares.map((sh) => sh.user)]
|
||||||
|
: app.people.filter((p) => p.id !== app.user?.id)
|
||||||
|
);
|
||||||
|
|
||||||
// The whole of sharing — a project and named people — lives in a popover behind
|
// The whole of sharing — a project and named people — lives in a popover behind
|
||||||
// one button, so the note's footer stays a status and three buttons rather than
|
// one button, so the note's footer stays a status and three buttons rather than
|
||||||
// a row that runs off its own width.
|
// a row that runs off its own width.
|
||||||
@@ -659,6 +674,7 @@
|
|||||||
// Only if it is still the one on screen: switching notes mid-save must not
|
// Only if it is still the one on screen: switching notes mid-save must not
|
||||||
// drag the previous one's title back onto this one.
|
// drag the previous one's title back onto this one.
|
||||||
if (selected?.id === id) selected = saved;
|
if (selected?.id === id) selected = saved;
|
||||||
|
await mentionsShared(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -666,6 +682,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvem gemningen delte noten med, fordi de blev nævnt i den.
|
||||||
|
*
|
||||||
|
* Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting
|
||||||
|
* blandt mange gemninger, og en tastepause skal ikke koste et opslag i
|
||||||
|
* delingerne, hver gang den falder. Panelet spørges i stedet — og kun når
|
||||||
|
* teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv
|
||||||
|
* bruger for at kigge efter.
|
||||||
|
*
|
||||||
|
* Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man
|
||||||
|
* skrev navnet på, skal sige det — også når panelet er lukket.
|
||||||
|
*/
|
||||||
|
async function mentionsShared(id) {
|
||||||
|
if (!ownsSelected || !draft.includes('@')) return;
|
||||||
|
const known = new Set(shares.map((sh) => sh.user.id));
|
||||||
|
try {
|
||||||
|
const r = await api.noteShares(id);
|
||||||
|
if (selected?.id !== id) return;
|
||||||
|
shares = r.shares ?? [];
|
||||||
|
shareCandidates = r.candidates ?? [];
|
||||||
|
shareFollows = r.follows ?? [];
|
||||||
|
shareInvites = r.invites ?? [];
|
||||||
|
const added = shares.filter((sh) => !known.has(sh.user.id));
|
||||||
|
if (added.length) {
|
||||||
|
app.toast(
|
||||||
|
t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// En liste, der ikke kunne hentes, er ikke en gemning, der gik galt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Favourites are the pinned ones. The list already puts them first, so marking
|
* Favourites are the pinned ones. The list already puts them first, so marking
|
||||||
* one is the same act as saying "keep this where I can see it" — one idea, not
|
* one is the same act as saying "keep this where I can see it" — one idea, not
|
||||||
@@ -1028,6 +1077,7 @@
|
|||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
|
people={mentionable}
|
||||||
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
|
|||||||
Reference in New Issue
Block a user