diff --git a/pkg/memory/vault.go b/pkg/memory/vault.go index 5e5ca01a8..33a3f4632 100644 --- a/pkg/memory/vault.go +++ b/pkg/memory/vault.go @@ -1,6 +1,13 @@ package memory -import "strings" +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) // NoteMeta represents parsed frontmatter metadata from a single markdown note. type NoteMeta struct { @@ -93,6 +100,131 @@ func parseBracketList(s string) []string { 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("\n")) + sb.WriteString(fmt.Sprintf("\n", time.Now().Format(time.RFC3339))) + sb.WriteString(fmt.Sprintf("\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. // Returns a slice of link targets with the brackets stripped. func ExtractWikilinks(body string) []string { diff --git a/pkg/memory/vault_test.go b/pkg/memory/vault_test.go index 60a70450e..7f226a8a1 100644 --- a/pkg/memory/vault_test.go +++ b/pkg/memory/vault_test.go @@ -1,6 +1,9 @@ package memory import ( + "os" + "path/filepath" + "strings" "testing" ) @@ -189,8 +192,210 @@ func TestExtractWikilinks_Adjacent(t *testing.T) { 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 --- +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) { t.Helper() if len(got) != len(want) {