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:
parent
4004ee92fb
commit
0b7006c0e3
2 changed files with 338 additions and 1 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 = ¬es[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 = ¬es[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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue