From 3afa6cca93e1a7040303e2e2f0f71f84b2012546 Mon Sep 17 00:00:00 2001 From: Paul De Velder Date: Mon, 23 Feb 2026 16:11:08 +0100 Subject: [PATCH] feat(memory): add note save, read, and search operations Add SaveNote() with frontmatter generation and created-date preservation, ReadNote() for reading full note content, and Search() with AND-logic tag filtering and case-insensitive query matching against titles, tags, and aliases. --- pkg/memory/vault.go | 109 ++++++++++++++++++++++ pkg/memory/vault_test.go | 190 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) diff --git a/pkg/memory/vault.go b/pkg/memory/vault.go index 33a3f4632..04a4d8cf6 100644 --- a/pkg/memory/vault.go +++ b/pkg/memory/vault.go @@ -225,6 +225,115 @@ func (v *Vault) writeIndex(notes []NoteMeta) error { return os.WriteFile(indexPath, []byte(sb.String()), 0o644) } +// SaveNote writes a markdown note with generated frontmatter and updates the index. +// If the note already exists, the original created date is preserved. +func (v *Vault) SaveNote(relPath string, meta NoteMeta, body string) error { + absPath := filepath.Join(v.memoryDir, filepath.FromSlash(relPath)) + + // Preserve created date from existing note + today := time.Now().Format("2006-01-02") + created := today + if data, err := os.ReadFile(absPath); err == nil { + existing, _ := ParseFrontmatter(string(data)) + if existing.Created != "" { + created = existing.Created + } + } + + // Build frontmatter + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("title: %s\n", meta.Title)) + sb.WriteString(fmt.Sprintf("created: %s\n", created)) + sb.WriteString(fmt.Sprintf("updated: %s\n", today)) + if len(meta.Tags) > 0 { + sb.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(meta.Tags, ", "))) + } + if len(meta.Aliases) > 0 { + sb.WriteString(fmt.Sprintf("aliases: [%s]\n", strings.Join(meta.Aliases, ", "))) + } + sb.WriteString("---\n\n") + sb.WriteString(body) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + if err := os.WriteFile(absPath, []byte(sb.String()), 0o644); err != nil { + return fmt.Errorf("failed to write note: %w", err) + } + + // Update index after save + return v.RebuildIndex() +} + +// ReadNote reads the full content of a note by its relative path within the vault. +func (v *Vault) ReadNote(relPath string) (string, error) { + data, err := os.ReadFile(filepath.Join(v.memoryDir, filepath.FromSlash(relPath))) + if err != nil { + return "", err + } + return string(data), nil +} + +// Search finds notes matching the given query and/or tags. +// Tags use AND logic: a note must have all specified tags to match. +// Query matches against title, tags, and aliases (case-insensitive). +func (v *Vault) Search(query string, tags []string) ([]NoteMeta, error) { + notes, err := v.ScanAll() + if err != nil { + return nil, err + } + + var results []NoteMeta + queryLower := strings.ToLower(query) + + for _, n := range notes { + // Tag filter (AND logic) + if len(tags) > 0 && !hasAllTags(n.Tags, tags) { + continue + } + + // Query filter + if query != "" { + titleMatch := strings.Contains(strings.ToLower(n.Title), queryLower) + tagMatch := containsAnyLower(n.Tags, queryLower) + aliasMatch := containsAnyLower(n.Aliases, queryLower) + if !titleMatch && !tagMatch && !aliasMatch { + continue + } + } + + results = append(results, n) + } + + return results, nil +} + +// hasAllTags returns true if noteTags contains all of the required tags. +func hasAllTags(noteTags, required []string) bool { + tagSet := make(map[string]bool, len(noteTags)) + for _, t := range noteTags { + tagSet[strings.ToLower(t)] = true + } + for _, r := range required { + if !tagSet[strings.ToLower(r)] { + return false + } + } + return true +} + +// containsAnyLower returns true if any item in the slice contains the query (case-insensitive). +func containsAnyLower(items []string, queryLower string) bool { + for _, item := range items { + if strings.Contains(strings.ToLower(item), queryLower) { + return true + } + } + return false +} + // 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 7f226a8a1..48c3924ff 100644 --- a/pkg/memory/vault_test.go +++ b/pkg/memory/vault_test.go @@ -383,6 +383,196 @@ func TestReadIndex_Missing(t *testing.T) { } } +// --- SaveNote tests --- + +func TestSaveNote_New(t *testing.T) { + dir := t.TempDir() + vault := NewVault(dir) + + meta := NoteMeta{ + Title: "Test Note", + Tags: []string{"test", "example"}, + Aliases: []string{"test-alias"}, + } + err := vault.SaveNote("topics/test-note.md", meta, "This is the body content.") + if err != nil { + t.Fatalf("SaveNote error: %v", err) + } + + // Verify file was created with correct content + data, err := os.ReadFile(filepath.Join(dir, "topics", "test-note.md")) + if err != nil { + t.Fatalf("Failed to read saved note: %v", err) + } + + content := string(data) + if !strings.Contains(content, "title: Test Note") { + t.Error("Saved note missing title in frontmatter") + } + if !strings.Contains(content, "tags: [test, example]") { + t.Error("Saved note missing tags in frontmatter") + } + if !strings.Contains(content, "aliases: [test-alias]") { + t.Error("Saved note missing aliases in frontmatter") + } + if !strings.Contains(content, "created:") { + t.Error("Saved note missing created date") + } + if !strings.Contains(content, "This is the body content.") { + t.Error("Saved note missing body content") + } + + // Verify index was updated + index := vault.ReadIndex() + if !strings.Contains(index, "Test Note") { + t.Error("Index not updated after SaveNote") + } +} + +func TestSaveNote_UpdateExisting(t *testing.T) { + dir := t.TempDir() + vault := NewVault(dir) + + // Save initial note + meta1 := NoteMeta{ + Title: "Original Title", + Tags: []string{"v1"}, + } + vault.SaveNote("note.md", meta1, "Original body.") + + // Read back to get the created date + data1, _ := os.ReadFile(filepath.Join(dir, "note.md")) + origMeta, _ := ParseFrontmatter(string(data1)) + origCreated := origMeta.Created + + // Update the same note + meta2 := NoteMeta{ + Title: "Updated Title", + Tags: []string{"v2"}, + } + err := vault.SaveNote("note.md", meta2, "Updated body.") + if err != nil { + t.Fatalf("SaveNote update error: %v", err) + } + + // Verify created date was preserved + data2, _ := os.ReadFile(filepath.Join(dir, "note.md")) + updatedMeta, _ := ParseFrontmatter(string(data2)) + + if updatedMeta.Created != origCreated { + t.Errorf("Created date changed: %q -> %q", origCreated, updatedMeta.Created) + } + if updatedMeta.Title != "Updated Title" { + t.Errorf("Title = %q, want %q", updatedMeta.Title, "Updated Title") + } +} + +// --- ReadNote tests --- + +func TestReadNote(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "test.md", "# Test\nContent.") + + vault := NewVault(dir) + content, err := vault.ReadNote("test.md") + if err != nil { + t.Fatalf("ReadNote error: %v", err) + } + if content != "# Test\nContent." { + t.Errorf("ReadNote = %q, want %q", content, "# Test\nContent.") + } +} + +func TestReadNote_Missing(t *testing.T) { + dir := t.TempDir() + vault := NewVault(dir) + _, err := vault.ReadNote("nonexistent.md") + if err == nil { + t.Error("ReadNote should error for missing file") + } +} + +// --- Search tests --- + +func TestSearch_ByTags(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "a.md", "---\ntitle: Note A\ntags: [go, errors]\n---\nContent A.") + writeTestFile(t, dir, "b.md", "---\ntitle: Note B\ntags: [go, testing]\n---\nContent B.") + writeTestFile(t, dir, "c.md", "---\ntitle: Note C\ntags: [python]\n---\nContent C.") + + vault := NewVault(dir) + results, err := vault.Search("", []string{"go"}) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + + // Search with AND logic: must have both tags + results2, err := vault.Search("", []string{"go", "errors"}) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results2) != 1 { + t.Fatalf("got %d results for AND search, want 1", len(results2)) + } + if results2[0].Title != "Note A" { + t.Errorf("Title = %q, want %q", results2[0].Title, "Note A") + } +} + +func TestSearch_ByQuery(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go-errors.md", "---\ntitle: Go Error Patterns\ntags: [go]\n---\nContent.") + writeTestFile(t, dir, "python.md", "---\ntitle: Python Basics\ntags: [python]\n---\nContent.") + + vault := NewVault(dir) + results, err := vault.Search("Error", nil) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Title != "Go Error Patterns" { + t.Errorf("Title = %q, want %q", results[0].Title, "Go Error Patterns") + } +} + +func TestSearch_Combined(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "a.md", "---\ntitle: Go Errors\ntags: [go, errors]\n---\nContent.") + writeTestFile(t, dir, "b.md", "---\ntitle: Go Testing\ntags: [go, testing]\n---\nContent.") + writeTestFile(t, dir, "c.md", "---\ntitle: Python Errors\ntags: [python, errors]\n---\nContent.") + + vault := NewVault(dir) + results, err := vault.Search("Go", []string{"errors"}) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Title != "Go Errors" { + t.Errorf("Title = %q, want %q", results[0].Title, "Go Errors") + } +} + +func TestSearch_NoResults(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "a.md", "---\ntitle: Note A\ntags: [go]\n---\nContent.") + + vault := NewVault(dir) + results, err := vault.Search("nonexistent", nil) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results) != 0 { + t.Errorf("got %d results, want 0", len(results)) + } +} + // --- Test helpers --- func writeTestFile(t *testing.T, dir, relPath, content string) {