feat(memory): add vault scanning and index generation

Add ScanAll() for recursive directory scanning with frontmatter parsing,
RebuildIndex() for generating _index.md with notes table, tags, and
aliases sections, and ReadIndex() for reading the current index.
This commit is contained in:
Paul De Velder 2026-02-23 16:09:52 +01:00
parent 4004ee92fb
commit 0b7006c0e3
2 changed files with 338 additions and 1 deletions

View file

@ -1,6 +1,13 @@
package memory package memory
import "strings" import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// NoteMeta represents parsed frontmatter metadata from a single markdown note. // NoteMeta represents parsed frontmatter metadata from a single markdown note.
type NoteMeta struct { type NoteMeta struct {
@ -93,6 +100,131 @@ func parseBracketList(s string) []string {
return result return result
} }
// ScanAll walks the memory directory recursively and returns metadata for all
// markdown notes. It skips _index.md and non-.md files. Notes without
// frontmatter get their title inferred from the filename.
func (v *Vault) ScanAll() ([]NoteMeta, error) {
var notes []NoteMeta
err := filepath.WalkDir(v.memoryDir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if !strings.HasSuffix(path, ".md") {
return nil
}
rel, _ := filepath.Rel(v.memoryDir, path)
rel = filepath.ToSlash(rel)
if rel == "_index.md" {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil // skip unreadable files
}
meta, body := ParseFrontmatter(string(content))
meta.RelPath = rel
meta.Links = ExtractWikilinks(body)
if meta.Title == "" {
base := filepath.Base(rel)
meta.Title = strings.TrimSuffix(base, ".md")
}
notes = append(notes, meta)
return nil
})
return notes, err
}
// RebuildIndex performs a full scan of all notes and regenerates _index.md.
func (v *Vault) RebuildIndex() error {
notes, err := v.ScanAll()
if err != nil {
return err
}
return v.writeIndex(notes)
}
// ReadIndex reads and returns the current _index.md content.
// Returns an empty string if the index file does not exist.
func (v *Vault) ReadIndex() string {
data, err := os.ReadFile(filepath.Join(v.memoryDir, "_index.md"))
if err != nil {
return ""
}
return string(data)
}
// writeIndex generates _index.md from the given notes list.
func (v *Vault) writeIndex(notes []NoteMeta) error {
// Sort notes by updated date (newest first), then by title
sort.Slice(notes, func(i, j int) bool {
if notes[i].Updated != notes[j].Updated {
return notes[i].Updated > notes[j].Updated
}
return notes[i].Title < notes[j].Title
})
// Collect unique tags and count
tagNotes := make(map[string][]string) // tag -> list of paths
for _, n := range notes {
for _, tag := range n.Tags {
tagNotes[tag] = append(tagNotes[tag], n.RelPath)
}
}
// Collect aliases
type aliasEntry struct {
alias string
path string
}
var aliases []aliasEntry
for _, n := range notes {
for _, a := range n.Aliases {
aliases = append(aliases, aliasEntry{alias: a, path: n.RelPath})
}
}
var sb strings.Builder
sb.WriteString("# Memory Vault Index\n")
sb.WriteString(fmt.Sprintf("<!-- Auto-generated. Do not edit manually. -->\n"))
sb.WriteString(fmt.Sprintf("<!-- Last rebuilt: %s -->\n", time.Now().Format(time.RFC3339)))
sb.WriteString(fmt.Sprintf("<!-- Notes: %d | Tags: %d -->\n", len(notes), len(tagNotes)))
// Notes table
sb.WriteString("\n## Notes\n\n")
sb.WriteString("| Title | Path | Tags | Updated |\n")
sb.WriteString("|-------|------|------|----------|\n")
for _, n := range notes {
tags := strings.Join(n.Tags, ", ")
sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", n.Title, n.RelPath, tags, n.Updated))
}
// Tags section
sb.WriteString("\n## Tags\n\n")
sortedTags := make([]string, 0, len(tagNotes))
for tag := range tagNotes {
sortedTags = append(sortedTags, tag)
}
sort.Strings(sortedTags)
for _, tag := range sortedTags {
paths := tagNotes[tag]
sb.WriteString(fmt.Sprintf("- **%s** (%d): %s\n", tag, len(paths), strings.Join(paths, ", ")))
}
// Aliases section
if len(aliases) > 0 {
sb.WriteString("\n## Aliases\n\n")
sort.Slice(aliases, func(i, j int) bool {
return aliases[i].alias < aliases[j].alias
})
for _, a := range aliases {
sb.WriteString(fmt.Sprintf("- %s -> %s\n", a.alias, a.path))
}
}
indexPath := filepath.Join(v.memoryDir, "_index.md")
return os.WriteFile(indexPath, []byte(sb.String()), 0o644)
}
// ExtractWikilinks finds all [[target]] references in body text. // ExtractWikilinks finds all [[target]] references in body text.
// Returns a slice of link targets with the brackets stripped. // Returns a slice of link targets with the brackets stripped.
func ExtractWikilinks(body string) []string { func ExtractWikilinks(body string) []string {

View file

@ -1,6 +1,9 @@
package memory package memory
import ( import (
"os"
"path/filepath"
"strings"
"testing" "testing"
) )
@ -189,8 +192,210 @@ func TestExtractWikilinks_Adjacent(t *testing.T) {
assertStringSlice(t, "links", links, []string{"one", "two"}) assertStringSlice(t, "links", links, []string{"one", "two"})
} }
// --- ScanAll tests ---
func TestScanAll_MultipleNotes(t *testing.T) {
dir := t.TempDir()
// Note with frontmatter in a subfolder
writeTestFile(t, dir, "topics/go-errors.md", `---
title: Go Error Patterns
created: 2026-02-20
updated: 2026-02-23
tags: [go, errors]
aliases: [error-handling]
---
Content about Go errors. See [[testing-guide]].`)
// Note without frontmatter at root
writeTestFile(t, dir, "quick-note.md", "# Quick Note\n\nJust some text.")
// Daily note
writeTestFile(t, dir, "202602/20260223.md", `---
title: 2026-02-23
created: 2026-02-23
updated: 2026-02-23
tags: [daily]
---
Today's notes.`)
// _index.md should be skipped
writeTestFile(t, dir, "_index.md", "# Index\nShould be skipped.")
// Non-md file should be skipped
writeTestFile(t, dir, "notes.txt", "Not a markdown file.")
vault := NewVault(dir)
notes, err := vault.ScanAll()
if err != nil {
t.Fatalf("ScanAll error: %v", err)
}
if len(notes) != 3 {
t.Fatalf("got %d notes, want 3", len(notes))
}
// Find the go-errors note and verify metadata
var goErrors *NoteMeta
for i := range notes {
if strings.Contains(notes[i].RelPath, "go-errors") {
goErrors = &notes[i]
break
}
}
if goErrors == nil {
t.Fatal("go-errors note not found in scan results")
}
if goErrors.Title != "Go Error Patterns" {
t.Errorf("Title = %q, want %q", goErrors.Title, "Go Error Patterns")
}
assertStringSlice(t, "Tags", goErrors.Tags, []string{"go", "errors"})
assertStringSlice(t, "Links", goErrors.Links, []string{"testing-guide"})
// Note without frontmatter should use filename as title
var quickNote *NoteMeta
for i := range notes {
if strings.Contains(notes[i].RelPath, "quick-note") {
quickNote = &notes[i]
break
}
}
if quickNote == nil {
t.Fatal("quick-note not found in scan results")
}
if quickNote.Title != "quick-note" {
t.Errorf("Title = %q, want %q (inferred from filename)", quickNote.Title, "quick-note")
}
}
func TestScanAll_EmptyDir(t *testing.T) {
dir := t.TempDir()
vault := NewVault(dir)
notes, err := vault.ScanAll()
if err != nil {
t.Fatalf("ScanAll error: %v", err)
}
if len(notes) != 0 {
t.Errorf("got %d notes, want 0", len(notes))
}
}
// --- RebuildIndex tests ---
func TestRebuildIndex(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "go-patterns.md", `---
title: Go Patterns
created: 2026-02-20
updated: 2026-02-23
tags: [go, patterns]
aliases: [golang-patterns]
---
Go patterns content.`)
writeTestFile(t, dir, "hardware.md", `---
title: Hardware Setup
created: 2026-02-18
updated: 2026-02-22
tags: [hardware, setup]
---
Hardware content.`)
vault := NewVault(dir)
err := vault.RebuildIndex()
if err != nil {
t.Fatalf("RebuildIndex error: %v", err)
}
// Verify _index.md was created
indexPath := filepath.Join(dir, "_index.md")
data, err := os.ReadFile(indexPath)
if err != nil {
t.Fatalf("Failed to read _index.md: %v", err)
}
index := string(data)
// Check structure
if !strings.Contains(index, "# Memory Vault Index") {
t.Error("Index missing header")
}
if !strings.Contains(index, "Auto-generated") {
t.Error("Index missing auto-generated comment")
}
if !strings.Contains(index, "## Notes") {
t.Error("Index missing Notes section")
}
if !strings.Contains(index, "## Tags") {
t.Error("Index missing Tags section")
}
// Check note entries
if !strings.Contains(index, "Go Patterns") {
t.Error("Index missing Go Patterns entry")
}
if !strings.Contains(index, "Hardware Setup") {
t.Error("Index missing Hardware Setup entry")
}
// Check tags
if !strings.Contains(index, "**go**") {
t.Error("Index missing 'go' tag")
}
if !strings.Contains(index, "**hardware**") {
t.Error("Index missing 'hardware' tag")
}
// Check aliases
if !strings.Contains(index, "## Aliases") {
t.Error("Index missing Aliases section")
}
if !strings.Contains(index, "golang-patterns") {
t.Error("Index missing golang-patterns alias")
}
}
// --- ReadIndex tests ---
func TestReadIndex_Exists(t *testing.T) {
dir := t.TempDir()
indexContent := "# Memory Vault Index\n\nSome index content."
writeTestFile(t, dir, "_index.md", indexContent)
vault := NewVault(dir)
got := vault.ReadIndex()
if got != indexContent {
t.Errorf("ReadIndex = %q, want %q", got, indexContent)
}
}
func TestReadIndex_Missing(t *testing.T) {
dir := t.TempDir()
vault := NewVault(dir)
got := vault.ReadIndex()
if got != "" {
t.Errorf("ReadIndex = %q, want empty for missing index", got)
}
}
// --- Test helpers --- // --- Test helpers ---
func writeTestFile(t *testing.T, dir, relPath, content string) {
t.Helper()
fullPath := filepath.Join(dir, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
t.Fatalf("Failed to create dir for %s: %v", relPath, err)
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write %s: %v", relPath, err)
}
}
func assertStringSlice(t *testing.T, name string, got, want []string) { func assertStringSlice(t *testing.T, name string, got, want []string) {
t.Helper() t.Helper()
if len(got) != len(want) { if len(got) != len(want) {