feat(memory): add vault core with frontmatter parsing and wikilink extraction
Add the memory package with NoteMeta struct, hand-rolled YAML frontmatter parser, bracket list parser, and [[wikilink]] extractor. These are the building blocks for the Obsidian-inspired memory vault system.
This commit is contained in:
parent
12ba462ae5
commit
4004ee92fb
2 changed files with 319 additions and 0 deletions
114
pkg/memory/vault.go
Normal file
114
pkg/memory/vault.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package memory
|
||||
|
||||
import "strings"
|
||||
|
||||
// NoteMeta represents parsed frontmatter metadata from a single markdown note.
|
||||
type NoteMeta struct {
|
||||
Title string
|
||||
Created string
|
||||
Updated string
|
||||
Tags []string
|
||||
Aliases []string
|
||||
RelPath string
|
||||
Links []string
|
||||
}
|
||||
|
||||
// Vault manages the memory vault: scanning, indexing, and searching notes.
|
||||
type Vault struct {
|
||||
memoryDir string
|
||||
}
|
||||
|
||||
// NewVault creates a new Vault rooted at the given memory directory.
|
||||
func NewVault(memoryDir string) *Vault {
|
||||
return &Vault{memoryDir: memoryDir}
|
||||
}
|
||||
|
||||
// ParseFrontmatter extracts YAML frontmatter from markdown content.
|
||||
// Frontmatter is delimited by "---\n" at the start and a subsequent "\n---".
|
||||
// Returns the parsed metadata and the body (everything after the closing delimiter).
|
||||
// If no valid frontmatter is found, returns a zero NoteMeta and the full content as body.
|
||||
func ParseFrontmatter(content string) (NoteMeta, string) {
|
||||
if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") {
|
||||
return NoteMeta{}, content
|
||||
}
|
||||
|
||||
// Find closing delimiter after the opening "---\n"
|
||||
rest := content[4:]
|
||||
endIdx := strings.Index(rest, "\n---")
|
||||
if endIdx < 0 {
|
||||
return NoteMeta{}, content
|
||||
}
|
||||
|
||||
fmBlock := rest[:endIdx]
|
||||
body := rest[endIdx+4:] // skip "\n---"
|
||||
|
||||
meta := NoteMeta{}
|
||||
for _, line := range strings.Split(fmBlock, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:colonIdx])
|
||||
val := strings.TrimSpace(line[colonIdx+1:])
|
||||
|
||||
switch key {
|
||||
case "title":
|
||||
meta.Title = val
|
||||
case "created":
|
||||
meta.Created = val
|
||||
case "updated":
|
||||
meta.Updated = val
|
||||
case "tags":
|
||||
meta.Tags = parseBracketList(val)
|
||||
case "aliases":
|
||||
meta.Aliases = parseBracketList(val)
|
||||
}
|
||||
}
|
||||
|
||||
return meta, body
|
||||
}
|
||||
|
||||
// parseBracketList parses a bracket-delimited list like "[a, b, c]" into a string slice.
|
||||
// Also handles plain comma-separated values without brackets.
|
||||
// Returns nil for empty input or empty brackets.
|
||||
func parseBracketList(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractWikilinks finds all [[target]] references in body text.
|
||||
// Returns a slice of link targets with the brackets stripped.
|
||||
func ExtractWikilinks(body string) []string {
|
||||
var links []string
|
||||
for {
|
||||
start := strings.Index(body, "[[")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(body[start+2:], "]]")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
link := body[start+2 : start+2+end]
|
||||
links = append(links, link)
|
||||
body = body[start+2+end+2:]
|
||||
}
|
||||
return links
|
||||
}
|
||||
205
pkg/memory/vault_test.go
Normal file
205
pkg/memory/vault_test.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- ParseFrontmatter tests ---
|
||||
|
||||
func TestParseFrontmatter_Valid(t *testing.T) {
|
||||
content := `---
|
||||
title: Go Error Patterns
|
||||
created: 2026-02-23
|
||||
updated: 2026-02-23
|
||||
tags: [go, patterns, errors]
|
||||
aliases: [error-handling, go-errors]
|
||||
---
|
||||
|
||||
# Go Error Patterns
|
||||
|
||||
Content here.`
|
||||
|
||||
meta, body := ParseFrontmatter(content)
|
||||
|
||||
if meta.Title != "Go Error Patterns" {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, "Go Error Patterns")
|
||||
}
|
||||
if meta.Created != "2026-02-23" {
|
||||
t.Errorf("Created = %q, want %q", meta.Created, "2026-02-23")
|
||||
}
|
||||
if meta.Updated != "2026-02-23" {
|
||||
t.Errorf("Updated = %q, want %q", meta.Updated, "2026-02-23")
|
||||
}
|
||||
assertStringSlice(t, "Tags", meta.Tags, []string{"go", "patterns", "errors"})
|
||||
assertStringSlice(t, "Aliases", meta.Aliases, []string{"error-handling", "go-errors"})
|
||||
|
||||
expectedBody := "\n\n# Go Error Patterns\n\nContent here."
|
||||
if body != expectedBody {
|
||||
t.Errorf("Body = %q, want %q", body, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter_Empty(t *testing.T) {
|
||||
content := "# Just a heading\n\nSome content."
|
||||
|
||||
meta, body := ParseFrontmatter(content)
|
||||
|
||||
if meta.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", meta.Title)
|
||||
}
|
||||
if body != content {
|
||||
t.Errorf("Body should equal original content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter_Partial(t *testing.T) {
|
||||
content := `---
|
||||
title: Only Title
|
||||
tags: [single]
|
||||
---
|
||||
|
||||
Body text.`
|
||||
|
||||
meta, body := ParseFrontmatter(content)
|
||||
|
||||
if meta.Title != "Only Title" {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, "Only Title")
|
||||
}
|
||||
if meta.Created != "" {
|
||||
t.Errorf("Created = %q, want empty", meta.Created)
|
||||
}
|
||||
if meta.Updated != "" {
|
||||
t.Errorf("Updated = %q, want empty", meta.Updated)
|
||||
}
|
||||
assertStringSlice(t, "Tags", meta.Tags, []string{"single"})
|
||||
if len(meta.Aliases) != 0 {
|
||||
t.Errorf("Aliases = %v, want empty", meta.Aliases)
|
||||
}
|
||||
|
||||
expectedBody := "\n\nBody text."
|
||||
if body != expectedBody {
|
||||
t.Errorf("Body = %q, want %q", body, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter_NoClosingDelimiter(t *testing.T) {
|
||||
content := `---
|
||||
title: Broken
|
||||
tags: [oops]
|
||||
|
||||
No closing delimiter here.`
|
||||
|
||||
meta, body := ParseFrontmatter(content)
|
||||
|
||||
if meta.Title != "" {
|
||||
t.Errorf("Title = %q, want empty for malformed frontmatter", meta.Title)
|
||||
}
|
||||
if body != content {
|
||||
t.Errorf("Body should equal original content for malformed frontmatter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter_EmptyContent(t *testing.T) {
|
||||
meta, body := ParseFrontmatter("")
|
||||
|
||||
if meta.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", meta.Title)
|
||||
}
|
||||
if body != "" {
|
||||
t.Errorf("Body = %q, want empty", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter_OnlyFrontmatter(t *testing.T) {
|
||||
content := `---
|
||||
title: No Body
|
||||
---
|
||||
`
|
||||
meta, body := ParseFrontmatter(content)
|
||||
|
||||
if meta.Title != "No Body" {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, "No Body")
|
||||
}
|
||||
if body != "\n" {
|
||||
t.Errorf("Body = %q, want %q", body, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseBracketList tests ---
|
||||
|
||||
func TestParseBracketList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"multiple items", "[a, b, c]", []string{"a", "b", "c"}},
|
||||
{"single item", "[only]", []string{"only"}},
|
||||
{"empty brackets", "[]", nil},
|
||||
{"empty string", "", nil},
|
||||
{"spaces around items", "[ foo , bar , baz ]", []string{"foo", "bar", "baz"}},
|
||||
{"no brackets", "plain, text", []string{"plain", "text"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseBracketList(tt.input)
|
||||
assertStringSlice(t, "result", got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExtractWikilinks tests ---
|
||||
|
||||
func TestExtractWikilinks_None(t *testing.T) {
|
||||
links := ExtractWikilinks("No links here, just plain text.")
|
||||
if len(links) != 0 {
|
||||
t.Errorf("got %v, want empty", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWikilinks_Single(t *testing.T) {
|
||||
links := ExtractWikilinks("See [[go-patterns]] for details.")
|
||||
assertStringSlice(t, "links", links, []string{"go-patterns"})
|
||||
}
|
||||
|
||||
func TestExtractWikilinks_Multiple(t *testing.T) {
|
||||
body := `Check [[error-handling]] and [[testing-guide]].
|
||||
Also see [[deployment]].`
|
||||
links := ExtractWikilinks(body)
|
||||
assertStringSlice(t, "links", links, []string{"error-handling", "testing-guide", "deployment"})
|
||||
}
|
||||
|
||||
func TestExtractWikilinks_Unclosed(t *testing.T) {
|
||||
links := ExtractWikilinks("Broken [[link without closing.")
|
||||
if len(links) != 0 {
|
||||
t.Errorf("got %v, want empty for unclosed link", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWikilinks_Empty(t *testing.T) {
|
||||
links := ExtractWikilinks("")
|
||||
if len(links) != 0 {
|
||||
t.Errorf("got %v, want empty", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWikilinks_Adjacent(t *testing.T) {
|
||||
links := ExtractWikilinks("[[one]][[two]]")
|
||||
assertStringSlice(t, "links", links, []string{"one", "two"})
|
||||
}
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
func assertStringSlice(t *testing.T, name string, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s: got %v (len %d), want %v (len %d)", name, got, len(got), want, len(want))
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("%s[%d] = %q, want %q", name, i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue