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>
102 lines
2.8 KiB
Go
102 lines
2.8 KiB
Go
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
|
|
}
|