Changeset 0.17.3 (#78)
This commit is contained in:
50
server/notelinks/extract.go
Normal file
50
server/notelinks/extract.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package notelinks
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]].
|
||||
// Title first char rejects [ and ] to avoid matching inside [[[triple]]].
|
||||
var wikiRe = regexp.MustCompile(`(!?)\[\[([^\[\]|][^\]|]*?)(?:\|([^\]]+?))?\]\]`)
|
||||
|
||||
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown content.
|
||||
// Returns deduplicated links preserving first occurrence.
|
||||
// Ignores matches preceded by [ (e.g. [[[triple]]]) since Go regexp lacks lookbehind.
|
||||
func ExtractWikilinks(content string) []models.NoteLink {
|
||||
indices := wikiRe.FindAllStringSubmatchIndex(content, -1)
|
||||
seen := make(map[string]bool)
|
||||
var links []models.NoteLink
|
||||
|
||||
for _, idx := range indices {
|
||||
matchStart := idx[0] // start of full match (including optional !)
|
||||
|
||||
// Skip if preceded by [ — rejects [[[triple]]] patterns
|
||||
if matchStart > 0 && content[matchStart-1] == '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract submatches by index pairs: idx[2*n], idx[2*n+1]
|
||||
title := strings.TrimSpace(content[idx[4]:idx[5]]) // group 2: title
|
||||
|
||||
key := strings.ToLower(title)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
link := models.NoteLink{
|
||||
TargetTitle: title,
|
||||
IsTransclusion: content[idx[2]:idx[3]] == "!", // group 1
|
||||
}
|
||||
// group 3: display text (optional — idx[6:7] may be -1)
|
||||
if idx[6] >= 0 && idx[7] >= 0 {
|
||||
link.DisplayText = strings.TrimSpace(content[idx[6]:idx[7]])
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
122
server/notelinks/extract_test.go
Normal file
122
server/notelinks/extract_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package notelinks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractWikilinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want int
|
||||
checks func(t *testing.T, links []struct{ title, display string; transclusion bool })
|
||||
}{
|
||||
{
|
||||
name: "no links",
|
||||
content: "Just some plain text with [single brackets]",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single link",
|
||||
content: "See [[Project Notes]] for details",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Project Notes" {
|
||||
t.Errorf("title = %q, want %q", links[0].title, "Project Notes")
|
||||
}
|
||||
if links[0].transclusion {
|
||||
t.Error("should not be transclusion")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "link with display text",
|
||||
content: "Check the [[Architecture Doc|arch doc]] here",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Architecture Doc" {
|
||||
t.Errorf("title = %q", links[0].title)
|
||||
}
|
||||
if links[0].display != "arch doc" {
|
||||
t.Errorf("display = %q", links[0].display)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transclusion",
|
||||
content: "Embed this: ![[Meeting Notes]]",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Meeting Notes" {
|
||||
t.Errorf("title = %q", links[0].title)
|
||||
}
|
||||
if !links[0].transclusion {
|
||||
t.Error("should be transclusion")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple links deduplicated",
|
||||
content: "See [[Alpha]] and [[Beta]] and [[Alpha]] again",
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive dedup",
|
||||
content: "See [[Project]] and [[project]]",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Project" {
|
||||
t.Errorf("title = %q, want first occurrence", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed links and transclusions",
|
||||
content: "Link to [[Alpha]], embed ![[Beta|b]], and [[Gamma]]",
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
content: "See [[ Spaced Title ]] here",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Spaced Title" {
|
||||
t.Errorf("title = %q, want trimmed", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested brackets ignored",
|
||||
content: "Not a link: [[[triple]]] but [[Valid]] is",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Valid" {
|
||||
t.Errorf("title = %q, want Valid", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty brackets ignored",
|
||||
content: "Empty [[]] should not match",
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
links := ExtractWikilinks(tt.content)
|
||||
if len(links) != tt.want {
|
||||
t.Fatalf("got %d links, want %d", len(links), tt.want)
|
||||
}
|
||||
if tt.checks != nil {
|
||||
simplified := make([]struct{ title, display string; transclusion bool }, len(links))
|
||||
for i, l := range links {
|
||||
simplified[i] = struct{ title, display string; transclusion bool }{
|
||||
title: l.TargetTitle, display: l.DisplayText, transclusion: l.IsTransclusion,
|
||||
}
|
||||
}
|
||||
tt.checks(t, simplified)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user