Den anden halvdel af den melding, der begyndte det her: linkene i en delt note førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note er at dele det, den *siger*, og et link er en del af det. note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en person har en note — "fulgte med" er en anden sætning end "du delte den" — og de udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren selv har sat. SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne handlinger: en deling, en fjernet deling, en gemning der har ændret links eller titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede. Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås ad to veje. Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med. Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens egne gemninger om. Begge dele har en Go-prøve. Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note med dér. Det, der står imod, er synlighed — linket står som tekst i ejerens egen note, og panelet viser hver note, der er fulgt med, og hvem der har den. Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven for monospace at taste sin titel ind i den forrige note, som editoren så tegnede væk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// NoteShare is one person's direct grant on a note: who they are, and what they
|
|
// may do. The person is carried whole rather than by id, because every caller of
|
|
// this wants to show them — a name and an avatar colour, never a bare id.
|
|
type NoteShare struct {
|
|
User Person `json:"user"`
|
|
Role Role `json:"role"`
|
|
}
|
|
|
|
// ShareNote grants a person a role on a note, or changes the role if they already
|
|
// had one. The owner is passed so the row records who did the sharing; the caller
|
|
// has already checked that they may.
|
|
//
|
|
// Sharing a note with its own owner is refused: the owner already has everything a
|
|
// share could give, and a row saying otherwise is a contradiction waiting to
|
|
// confuse the access check.
|
|
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
|
if !role.Valid() || role == RoleOwner {
|
|
return errors.New("store: a note can be shared as viewer or editor")
|
|
}
|
|
var createdBy sql.NullString
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
|
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if createdBy.String == userID {
|
|
return errors.New("store: a note is already the owner's")
|
|
}
|
|
_, err := db.ExecContext(ctx, `
|
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
|
noteID, userID, role, byUserID, time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
|
// error: the end state the caller asked for — this person cannot see this note —
|
|
// is the same either way.
|
|
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
|
return err
|
|
}
|
|
|
|
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
|
// name so the list under a note does not reshuffle every time it is opened.
|
|
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT u.id, u.name, u.avatar_color, s.role
|
|
FROM note_shares s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.note_id = ?
|
|
ORDER BY lower(u.name)`, noteID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []NoteShare
|
|
for rows.Next() {
|
|
var s NoteShare
|
|
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// NoteShareRole is the role a person holds on a note through a direct share, if
|
|
// any. The bool is whether a share exists at all — distinct from a share whose
|
|
// role could not be read — so a caller does not have to treat "no share" as an
|
|
// error.
|
|
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
|
var role Role
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if !role.Valid() {
|
|
// A role the database holds but this build does not understand is no access,
|
|
// never a guess — the same rule project roles follow.
|
|
return "", false, nil
|
|
}
|
|
return role, true, nil
|
|
}
|
|
|
|
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
|
// the user. It answers the Delt med mig question in one query rather than one per
|
|
// note, and takes the ids it is scoping so it never has to load a person's whole
|
|
// world to mark a single page.
|
|
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
|
out := map[string]bool{}
|
|
if len(noteIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
args := make([]any, 0, len(noteIDs)+1)
|
|
args = append(args, userID)
|
|
for _, id := range noteIDs {
|
|
args = append(args, id)
|
|
}
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
|
args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = true
|
|
}
|
|
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
|
|
}
|