public API cleanup

This commit is contained in:
Levi Neely 2025-11-27 10:02:31 +01:00
parent f4102dbce5
commit 4441600775
10 changed files with 280 additions and 261 deletions

View File

@ -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)
}

View File

@ -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.

View File

@ -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 {

20
main.go
View File

@ -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")
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}
})
}
}

View File

@ -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 (

View File

@ -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)
}
}
})
}
}
}