Files
verdande/internal/store/mentions.go
T
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:08:12 +02:00

117 lines
3.4 KiB
Go

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])
}