From 44416007755b78d6c3510bca689c3eb2fc2df3e8 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:02:31 +0100 Subject: [PATCH] public API cleanup --- cmd/Drename/main.go | 2 +- internal/disk/loader.go | 2 +- internal/p9/server/server.go | 9 +- main.go | 20 +- pkg/encoding/frontmatter/frontmatter.go | 4 +- pkg/encoding/frontmatter/frontmatter_test.go | 4 +- pkg/encoding/results/results.go | 73 ++++++++ pkg/encoding/results/results_test.go | 186 +++++++++++++++++++ pkg/metadata/metadata.go | 59 ------ pkg/metadata/metadata_test.go | 182 +----------------- 10 files changed, 280 insertions(+), 261 deletions(-) create mode 100644 pkg/encoding/results/results.go create mode 100644 pkg/encoding/results/results_test.go diff --git a/cmd/Drename/main.go b/cmd/Drename/main.go index 66e1ed4..4c2852c 100644 --- a/cmd/Drename/main.go +++ b/cmd/Drename/main.go @@ -192,7 +192,7 @@ func windowMode(args []string) error { // Update frontmatter if applicable if supportsFrontmatter && len(body) > 0 { // Parse existing frontmatter to preserve Identifier and FileType - existing, fileType, err := frontmatter.Unmarshal(string(body), ext) + existing, fileType, err := frontmatter.Unmarshal(body, ext) if err != nil { return fmt.Errorf("failed to parse existing frontmatter: %w", err) } diff --git a/internal/disk/loader.go b/internal/disk/loader.go index 6ee600a..d8119ea 100644 --- a/internal/disk/loader.go +++ b/internal/disk/loader.go @@ -88,7 +88,7 @@ func ExtractFrontMatter(path string) (*metadata.FrontMatter, metadata.FileType, return nil, "", fmt.Errorf("failed to read file: %w", err) } - return frontmatter.Unmarshal(string(content), ext) + return frontmatter.Unmarshal(content, ext) } // UpdateFrontMatter updates the front matter in a file. diff --git a/internal/p9/server/server.go b/internal/p9/server/server.go index 17cceef..df14fe3 100644 --- a/internal/p9/server/server.go +++ b/internal/p9/server/server.go @@ -32,6 +32,7 @@ package fs import ( "denote/internal/disk" + "denote/pkg/encoding/results" "denote/pkg/metadata" "fmt" "io" @@ -820,7 +821,7 @@ func (s *server) pathToNoteIndex(path string) int { func (s *server) getIndexContent() string { if s.filterQuery == "" { // No filter: return all notes - return string(s.notes.Bytes()) + return string(results.Marshal(s.notes)) } // Return filtered notes @@ -828,7 +829,7 @@ func (s *server) getIndexContent() string { for _, note := range s.filteredNotes { filtered = append(filtered, note) } - return string(filtered.Bytes()) + return string(results.Marshal(filtered)) } func (s *server) getBacklinks(targetID string) string { @@ -837,8 +838,8 @@ func (s *server) getBacklinks(targetID string) string { denoteDir := s.denoteDir s.mu.RUnlock() - results := disk.FindBacklinks(targetID, denoteDir, notes) - return string(results.Bytes()) + res := disk.FindBacklinks(targetID, denoteDir, notes) + return string(results.Marshal(res)) } func (s *server) getFileContent(path string) string { diff --git a/main.go b/main.go index ddd22c8..50f16c4 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( p9client "denote/internal/p9/client" p9server "denote/internal/p9/server" "denote/pkg/encoding/frontmatter" + "denote/pkg/encoding/results" "denote/pkg/metadata" "fmt" "log" @@ -36,14 +37,13 @@ func readIndex(f *client.Fsys) (metadata.Results, error) { return nil, fmt.Errorf("failed to read index: %w", err) } - var results metadata.Results - results, err = results.FromString(indexContent) + rs, err := results.Unmarshal([]byte(indexContent)) if err != nil { return nil, err } // Populate Path for each note - for _, note := range results { + for _, note := range rs { path, err := p9client.ReadFile(f, "n/"+note.Identifier+"/path") if err != nil { return nil, fmt.Errorf("failed to read path for %s: %w", note.Identifier, err) @@ -51,7 +51,7 @@ func readIndex(f *client.Fsys) (metadata.Results, error) { note.Path = path } - return results, nil + return rs, nil } // setFilter sets or clears the filter on the 9P server. @@ -180,18 +180,17 @@ func main() { } // Parse index - var results metadata.Results - results, err = results.FromString(indexContent) + rs, err := results.Unmarshal([]byte(indexContent)) if err != nil { return err } - if len(results) == 0 { + if len(rs) == 0 { return fmt.Errorf("no note found with identifier %s", identifier) } // Read path for first result - notePath, err = p9client.ReadFile(f, "n/"+results[0].Identifier+"/path") + notePath, err = p9client.ReadFile(f, "n/"+rs[0].Identifier+"/path") return err }) if err != nil { @@ -357,8 +356,7 @@ func main() { } // Parse the window content - var emptyResults metadata.Results - updated, err := emptyResults.FromString(string(body)) + updated, err := results.Unmarshal(body) if err != nil { log.Printf("failed to parse window: %v", err) break @@ -463,7 +461,7 @@ func performSearch(w *acme.Win, searchText string) { func refreshWindow(w *acme.Win, rs metadata.Results) { w.Addr(",") - w.Write("data", rs.Bytes()) + w.Write("data", results.Marshal(rs)) w.Ctl("show") } diff --git a/pkg/encoding/frontmatter/frontmatter.go b/pkg/encoding/frontmatter/frontmatter.go index e7040ae..c9e3798 100644 --- a/pkg/encoding/frontmatter/frontmatter.go +++ b/pkg/encoding/frontmatter/frontmatter.go @@ -78,9 +78,9 @@ func Marshal(fm *metadata.FrontMatter, fileType metadata.FileType) []byte { // Unmarshal extracts front matter from file content. // ext should be the file extension (e.g., ".md", ".org", ".txt"). // Returns the parsed frontmatter and the detected FileType. -func Unmarshal(content string, ext string) (*metadata.FrontMatter, metadata.FileType, error) { +func Unmarshal(content []byte, ext string) (*metadata.FrontMatter, metadata.FileType, error) { ext = strings.ToLower(ext) - text := content + text := string(content) fm := &metadata.FrontMatter{} var fileType metadata.FileType diff --git a/pkg/encoding/frontmatter/frontmatter_test.go b/pkg/encoding/frontmatter/frontmatter_test.go index 1bf5943..bdabd9e 100644 --- a/pkg/encoding/frontmatter/frontmatter_test.go +++ b/pkg/encoding/frontmatter/frontmatter_test.go @@ -339,7 +339,7 @@ identifier: 20240101T120000 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, fileType, err := Unmarshal(tt.content, tt.ext) + got, fileType, err := Unmarshal([]byte(tt.content), tt.ext) if err != nil { t.Fatalf("Unmarshal() error = %v", err) } @@ -369,7 +369,7 @@ func TestUnmarshalMissingFields(t *testing.T) { date: 2024-01-01 ---` - got, _, err := Unmarshal(content, ".md") + got, _, err := Unmarshal([]byte(content), ".md") if err != nil { t.Fatalf("Unmarshal() error = %v", err) } diff --git a/pkg/encoding/results/results.go b/pkg/encoding/results/results.go new file mode 100644 index 0000000..60b9b78 --- /dev/null +++ b/pkg/encoding/results/results.go @@ -0,0 +1,73 @@ +package results + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "denote/pkg/metadata" +) + +// Marshal serializes Results to a pipe-delimited byte format. +// Format: identifier | title | tags (comma-separated) +func Marshal(rs metadata.Results) []byte { + var buf strings.Builder + for _, e := range rs { + title := e.Title + if title == "" { + title = "(untitled)" + } + + tags := strings.Join(e.Tags, ",") + fmt.Fprintf(&buf, "%s | %s | %s\n", e.Identifier, title, tags) + } + return []byte(buf.String()) +} + +// Unmarshal parses pipe-delimited byte data into Results. +// Format: identifier | title | tags (comma-separated) +func Unmarshal(data []byte) (metadata.Results, error) { + var results metadata.Results + lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) + // Allow lowercase Latin letters, other letters (CJK, etc.), and digits, no spaces + tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Lo}\p{Nd}]+,)*[\p{Ll}\p{Lo}\p{Nd}]+$`) + + for lineNum, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + + parts := bytes.Split(line, []byte("|")) + if len(parts) != 3 { + return nil, fmt.Errorf("line %d: expected 3 columns, got %d (line: %q)", lineNum+1, len(parts), line) + } + + identifier := string(bytes.TrimSpace(parts[0])) + title := string(bytes.TrimSpace(parts[1])) + tagsStr := string(bytes.TrimSpace(parts[2])) + + if identifier == "" { + return nil, fmt.Errorf("line %d: identifier cannot be empty", lineNum+1) + } + + var tags []string + if tagsStr != "" { + if !tagPattern.MatchString(tagsStr) { + return nil, fmt.Errorf("line %d: tags must be comma-delimited lowercase unicode words (no spaces): got '%s'", lineNum+1, tagsStr) + } + tags = strings.Split(tagsStr, ",") + } else { + tags = []string{} + } + + results = append(results, &metadata.Metadata{ + Identifier: identifier, + Title: title, + Tags: tags, + }) + } + + return results, nil +} diff --git a/pkg/encoding/results/results_test.go b/pkg/encoding/results/results_test.go new file mode 100644 index 0000000..14bce81 --- /dev/null +++ b/pkg/encoding/results/results_test.go @@ -0,0 +1,186 @@ +package results + +import ( + "denote/pkg/metadata" + "slices" + "testing" +) + +// TestMarshal validates serialization +func TestMarshal(t *testing.T) { + tests := []struct { + name string + input metadata.Results + want string + }{ + { + name: "single note with tags", + input: metadata.Results{ + { + Identifier: "20240101T120000", + Title: "Test Note", + Tags: []string{"tag1", "tag2"}, + }, + }, + want: "20240101T120000 | Test Note | tag1,tag2\n", + }, + { + name: "note without tags", + input: metadata.Results{ + { + Identifier: "20240101T120000", + Title: "Simple Note", + Tags: []string{}, + }, + }, + want: "20240101T120000 | Simple Note | \n", + }, + { + name: "note without title", + input: metadata.Results{ + { + Identifier: "20240101T120000", + Title: "", + Tags: []string{"tag"}, + }, + }, + want: "20240101T120000 | (untitled) | tag\n", + }, + { + name: "multiple notes", + input: metadata.Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, + }, + want: "20240101T120000 | First | a\n20240102T120000 | Second | b,c\n", + }, + { + name: "empty results", + input: metadata.Results{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(Marshal(tt.input)) + if got != tt.want { + t.Errorf("Marshal() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestUnmarshal validates parsing from byte format +func TestUnmarshal(t *testing.T) { + tests := []struct { + name string + input []byte + want metadata.Results + wantErr bool + }{ + { + name: "single note with tags", + input: []byte("20240101T120000 | Test Note | tag1,tag2"), + want: metadata.Results{ + {Identifier: "20240101T120000", Title: "Test Note", Tags: []string{"tag1", "tag2"}}, + }, + wantErr: false, + }, + { + name: "note without tags", + input: []byte("20240101T120000 | Simple Note | "), + want: metadata.Results{ + {Identifier: "20240101T120000", Title: "Simple Note", Tags: []string{}}, + }, + wantErr: false, + }, + { + name: "multiple notes", + input: []byte("20240101T120000 | First | a\n20240102T120000 | Second | b,c"), + want: metadata.Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, + }, + wantErr: false, + }, + { + name: "empty input", + input: []byte(""), + want: nil, + wantErr: false, + }, + { + name: "wrong column count", + input: []byte("20240101T120000 | Title"), + want: nil, + wantErr: true, + }, + { + name: "empty identifier", + input: []byte(" | Title | tags"), + want: nil, + wantErr: true, + }, + { + name: "invalid tags with spaces", + input: []byte("20240101T120000 | Title | tag with spaces"), + want: nil, + wantErr: true, + }, + { + name: "invalid tags with uppercase", + input: []byte("20240101T120000 | Title | Tag1,tag2"), + want: nil, + wantErr: true, + }, + { + name: "valid lowercase unicode tags", + input: []byte("20240101T120000 | Title | tag1,测试,αβγ"), + want: metadata.Results{ + {Identifier: "20240101T120000", Title: "Title", Tags: []string{"tag1", "测试", "αβγ"}}, + }, + wantErr: false, + }, + { + name: "blank lines ignored", + input: []byte("20240101T120000 | First | a\n\n20240102T120000 | Second | b"), + want: metadata.Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b"}}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Unmarshal(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if len(got) != len(tt.want) { + t.Fatalf("Unmarshal() length = %d, want %d", len(got), len(tt.want)) + } + + for i := range got { + if got[i].Identifier != tt.want[i].Identifier { + t.Errorf("Result[%d].Identifier = %q, want %q", i, got[i].Identifier, tt.want[i].Identifier) + } + if got[i].Title != tt.want[i].Title { + t.Errorf("Result[%d].Title = %q, want %q", i, got[i].Title, tt.want[i].Title) + } + if !slices.Equal(got[i].Tags, tt.want[i].Tags) { + t.Errorf("Result[%d].Tags = %v, want %v", i, got[i].Tags, tt.want[i].Tags) + } + } + }) + } +} diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 5e96cb3..7d33ccf 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -21,65 +21,6 @@ type Metadata struct { type Results []*Metadata -func (es Results) Bytes() []byte { - var buf strings.Builder - for _, e := range es { - title := e.Title - if title == "" { - title = "(untitled)" - } - - tags := strings.Join(e.Tags, ",") - fmt.Fprintf(&buf, "%s | %s | %s\n", e.Identifier, title, tags) - } - return []byte(buf.String()) -} - -func (es Results) FromString(data string) (Results, error) { - var results Results - lines := strings.Split(strings.TrimSpace(data), "\n") - // Allow lowercase Latin letters, other letters (CJK, etc.), and digits, no spaces - tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Lo}\p{Nd}]+,)*[\p{Ll}\p{Lo}\p{Nd}]+$`) - - for lineNum, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Split(line, "|") - if len(parts) != 3 { - return nil, fmt.Errorf("line %d: expected 3 columns, got %d (line: %q)", lineNum+1, len(parts), line) - } - - identifier := strings.TrimSpace(parts[0]) - title := strings.TrimSpace(parts[1]) - tagsStr := strings.TrimSpace(parts[2]) - - if identifier == "" { - return nil, fmt.Errorf("line %d: identifier cannot be empty", lineNum+1) - } - - var tags []string - if tagsStr != "" { - if !tagPattern.MatchString(tagsStr) { - return nil, fmt.Errorf("line %d: tags must be comma-delimited lowercase unicode words (no spaces): got '%s'", lineNum+1, tagsStr) - } - tags = strings.Split(tagsStr, ",") - } else { - tags = []string{} - } - - results = append(results, &Metadata{ - Identifier: identifier, - Title: title, - Tags: tags, - }) - } - - return results, nil -} - type SortBy string const ( diff --git a/pkg/metadata/metadata_test.go b/pkg/metadata/metadata_test.go index a35d9f9..0ebebd8 100644 --- a/pkg/metadata/metadata_test.go +++ b/pkg/metadata/metadata_test.go @@ -503,184 +503,4 @@ func TestSort(t *testing.T) { t.Errorf("Third item title = %q, want %q", testData[2].Title, "zebra") } }) -} - -// TestResultsBytes validates serialization -func TestResultsBytes(t *testing.T) { - tests := []struct { - name string - input Results - want string - }{ - { - name: "single note with tags", - input: Results{ - { - Identifier: "20240101T120000", - Title: "Test Note", - Tags: []string{"tag1", "tag2"}, - }, - }, - want: "20240101T120000 | Test Note | tag1,tag2\n", - }, - { - name: "note without tags", - input: Results{ - { - Identifier: "20240101T120000", - Title: "Simple Note", - Tags: []string{}, - }, - }, - want: "20240101T120000 | Simple Note | \n", - }, - { - name: "note without title", - input: Results{ - { - Identifier: "20240101T120000", - Title: "", - Tags: []string{"tag"}, - }, - }, - want: "20240101T120000 | (untitled) | tag\n", - }, - { - name: "multiple notes", - input: Results{ - {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, - {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, - }, - want: "20240101T120000 | First | a\n20240102T120000 | Second | b,c\n", - }, - { - name: "empty results", - input: Results{}, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := string(tt.input.Bytes()) - if got != tt.want { - t.Errorf("Results.Bytes() = %q, want %q", got, tt.want) - } - }) - } -} - -// TestResultsFromString validates parsing from string format -func TestResultsFromString(t *testing.T) { - tests := []struct { - name string - input string - want Results - wantErr bool - }{ - { - name: "single note with tags", - input: "20240101T120000 | Test Note | tag1,tag2", - want: Results{ - {Identifier: "20240101T120000", Title: "Test Note", Tags: []string{"tag1", "tag2"}}, - }, - wantErr: false, - }, - { - name: "note without tags", - input: "20240101T120000 | Simple Note | ", - want: Results{ - {Identifier: "20240101T120000", Title: "Simple Note", Tags: []string{}}, - }, - wantErr: false, - }, - { - name: "multiple notes", - input: "20240101T120000 | First | a\n20240102T120000 | Second | b,c", - want: Results{ - {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, - {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, - }, - wantErr: false, - }, - { - name: "empty input", - input: "", - want: nil, - wantErr: false, - }, - { - name: "wrong column count", - input: "20240101T120000 | Title", - want: nil, - wantErr: true, - }, - { - name: "empty identifier", - input: " | Title | tags", - want: nil, - wantErr: true, - }, - { - name: "invalid tags with spaces", - input: "20240101T120000 | Title | tag with spaces", - want: nil, - wantErr: true, - }, - { - name: "invalid tags with uppercase", - input: "20240101T120000 | Title | Tag1,tag2", - want: nil, - wantErr: true, - }, - { - name: "valid lowercase unicode tags", - input: "20240101T120000 | Title | tag1,测试,αβγ", - want: Results{ - {Identifier: "20240101T120000", Title: "Title", Tags: []string{"tag1", "测试", "αβγ"}}, - }, - wantErr: false, - }, - { - name: "blank lines ignored", - input: "20240101T120000 | First | a\n\n20240102T120000 | Second | b", - want: Results{ - {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, - {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b"}}, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var r Results - got, err := r.FromString(tt.input) - - if (err != nil) != tt.wantErr { - t.Errorf("Results.FromString() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if tt.wantErr { - return - } - - if len(got) != len(tt.want) { - t.Fatalf("Results.FromString() length = %d, want %d", len(got), len(tt.want)) - } - - for i := range got { - if got[i].Identifier != tt.want[i].Identifier { - t.Errorf("Result[%d].Identifier = %q, want %q", i, got[i].Identifier, tt.want[i].Identifier) - } - if got[i].Title != tt.want[i].Title { - t.Errorf("Result[%d].Title = %q, want %q", i, got[i].Title, tt.want[i].Title) - } - if !slices.Equal(got[i].Tags, tt.want[i].Tags) { - t.Errorf("Result[%d].Tags = %v, want %v", i, got[i].Tags, tt.want[i].Tags) - } - } - }) - } -} +} \ No newline at end of file