feat(workspace): backlinks at the end of a note

GET /documents/{id}/backlinks lists the notes the user can open whose
blocks link [[Title]] (also |alias and #section, case-insensitive),
excluding the note itself and the trash. The collapsible "Verlinkt auf
diese Notiz" section shows each note with folder and the linking bullets;
a click jumps straight to the bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:00:57 +01:00
1 parent f69f74fdc7
commit 677fe0fc0a
11 files changed
+303

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
package storage
import (
"errors"
"regexp"
"sort"
"strings"
)
// Backlinks ("Verlinkt auf diese Notiz") lists the notes whose blocks contain a wiki link [[Title]]
// to a note. Matching ignores case and surrounding spaces like the link click in the editor;
// [[Title|Text]] and [[Title#Abschnitt]] count as well. Only notes the user can open are listed,
// never the note itself or notes in the trash.
// ErrNoAccess is returned when the user cannot open the note.
var ErrNoAccess = errors.New("Kein Zugriff auf diese Notiz")
var wikiLinkRe = regexp.MustCompile(`\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]`)
const maxBacklinkBlocks = 5
type BacklinkBlock struct {
ID string `json:"id"`
Content string `json:"content"`
}
type Backlink struct {
DocID string `json:"docId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []BacklinkBlock `json:"blocks"`
More int `json:"more,omitempty"` // further matching blocks not listed
}
func linksTo(content, title string) bool {
for _, m := range wikiLinkRe.FindAllStringSubmatch(content, -1) {
if strings.EqualFold(strings.TrimSpace(m[1]), title) {
return true
}
}
return false
}
func (s *Store) GetBacklinks(userID, docID string) ([]Backlink, error) {
if s.DocRole(userID, docID) == "" {
return nil, ErrNoAccess
}
var title string
if err := s.db.QueryRow(`SELECT title FROM documents WHERE id = ?`, docID).Scan(&title); err != nil {
return nil, err
}
title = strings.TrimSpace(title)
out := []Backlink{}
if title == "" {
return out, nil
}
// The tree of the user: what they can open, with their own folder paths
docs, err := s.GetDocuments(userID)
if err != nil {
return nil, err
}
byID := make(map[string]int, len(docs))
for i, d := range docs {
byID[d.ID] = i
}
rows, err := s.db.Query(`SELECT id, document_id, content FROM blocks
WHERE deleted = 0 AND document_id != ? AND content LIKE '%[[%'
ORDER BY document_id, order_key`, docID)
if err != nil {
return nil, err
}
defer rows.Close()
index := map[string]int{}
for rows.Next() {
var blockID, sourceID, content string
if rows.Scan(&blockID, &sourceID, &content) != nil {
continue
}
di, visible := byID[sourceID]
if !visible || !linksTo(content, title) {
continue
}
pos, seen := index[sourceID]
if !seen {
d := docs[di]
out = append(out, Backlink{DocID: d.ID, Title: d.Title, Icon: d.Icon, FolderPath: d.FolderPath})
pos = len(out) - 1
index[sourceID] = pos
}
if len(out[pos].Blocks) < maxBacklinkBlocks {
out[pos].Blocks = append(out[pos].Blocks, BacklinkBlock{ID: blockID, Content: content})
} else {
out[pos].More++
}
}
sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title) })
return out, nil
}
+48
View File
@@ -0,0 +1,48 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestBacklinks(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "backlinks.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
block := func(id, content string) []domain.DocumentBlock {
return []domain.DocumentBlock{{ID: id, Type: "bullet", Content: content, Level: 0}}
}
target, _ := store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "", block("t1", "Server [[Homelab]] selbst"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Einkauf", "🛒", "Privat", block("a1", "Kabel für [[ homelab |das Labor]] kaufen"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Ohne", "", "", block("a2", "Homelab ohne Klammern, [[Homelabor]]"), nil, false)
trashed, _ := store.CreateDocument(anna.ID, "", "Alt", "", "", block("a3", "[[Homelab#Netz]]"), nil, false)
_ = store.DeleteDocument(anna.ID, trashed.ID)
bens, _ := store.CreateDocument(ben.ID, "", "Bens Plan", "", "", block("b1", "siehe [[Homelab]]"), nil, false)
links, err := store.GetBacklinks(anna.ID, target.ID)
if err != nil {
t.Fatalf("GetBacklinks: %v", err)
}
if len(links) != 1 || links[0].Title != "Einkauf" || links[0].FolderPath != "Privat" || len(links[0].Blocks) != 1 || links[0].Blocks[0].ID != "a1" {
t.Fatalf("expected only Einkauf (alias link), not itself, plain text, other titles, trash or Ben's private note: %+v", links)
}
// Ben's note counts once Anna can see it
if _, err := store.CreateDocShare(ben.ID, bens.ID, anna.ID, "read"); err != nil {
t.Fatalf("share: %v", err)
}
if links, _ := store.GetBacklinks(anna.ID, target.ID); len(links) != 2 {
t.Errorf("shared note should appear: %+v", links)
}
if _, err := store.GetBacklinks(ben.ID, target.ID); err == nil {
t.Errorf("Ben cannot open Homelab, so no backlinks for him")
}
}