From ab74339821acfa83998631914feb8911fd512f1f Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:51:14 +0100 Subject: [PATCH] API cleanup --- cmd/Drename/main.go | 7 +- internal/disk/callbacks.go | 67 +- internal/disk/loader.go | 49 +- internal/disk/sync.go | 4 +- main.go | 11 +- .../frontmatter}/frontmatter.go | 95 +-- pkg/encoding/frontmatter/frontmatter_test.go | 387 ++++++++++ pkg/metadata/frontmatter_test.go | 679 ------------------ pkg/metadata/metadata.go | 46 +- pkg/metadata/metadata_test.go | 116 +-- pkg/util/util.go | 69 ++ pkg/util/util_test.go | 215 ++++++ 12 files changed, 795 insertions(+), 950 deletions(-) rename pkg/{metadata => encoding/frontmatter}/frontmatter.go (66%) create mode 100644 pkg/encoding/frontmatter/frontmatter_test.go delete mode 100644 pkg/metadata/frontmatter_test.go create mode 100644 pkg/util/util.go create mode 100644 pkg/util/util_test.go diff --git a/cmd/Drename/main.go b/cmd/Drename/main.go index 0440704..66e1ed4 100644 --- a/cmd/Drename/main.go +++ b/cmd/Drename/main.go @@ -1,8 +1,9 @@ package main import ( - "denote/pkg/metadata" p9client "denote/internal/p9/client" + "denote/pkg/encoding/frontmatter" + "denote/pkg/util" "fmt" "log" "os" @@ -191,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, err := metadata.ParseFrontMatter(string(body), ext) + existing, fileType, err := frontmatter.Unmarshal(string(body), ext) if err != nil { return fmt.Errorf("failed to parse existing frontmatter: %w", err) } @@ -202,7 +203,7 @@ func windowMode(args []string) error { existing.Signature = signature // Apply updated frontmatter to content - newContent, err := metadata.Apply(string(body), existing) + newContent, err := util.Apply(string(body), existing, fileType) if err != nil { return fmt.Errorf("failed to update frontmatter: %w", err) } diff --git a/internal/disk/callbacks.go b/internal/disk/callbacks.go index f9cb918..42a1459 100644 --- a/internal/disk/callbacks.go +++ b/internal/disk/callbacks.go @@ -1,8 +1,9 @@ package disk import ( - "denote/pkg/metadata" p9client "denote/internal/p9/client" + "denote/pkg/encoding/frontmatter" + "denote/pkg/metadata" "fmt" "log" "os" @@ -33,50 +34,39 @@ func HandleUpdateEvent(f *client.Fsys, identifier, denoteDir string) error { } } - // Only update frontmatter if file exists (skip if not yet created via Put) - if _, err := os.Stat(path); err == nil && SupportsFrontMatter(path) { - ext := strings.ToLower(filepath.Ext(path)) - var fileType metadata.FileType - switch ext { - case ".org": - fileType = metadata.FileTypeOrg - case ".md": - fileType = metadata.FileTypeMdYaml - case ".txt": - fileType = metadata.FileTypeTxt - } - - fm := &metadata.FrontMatter{ - Title: title, - Tags: tags, - Identifier: identifier, - Signature: signature, - FileType: fileType, - } - - if err := UpdateFrontMatter(path, fm); err != nil { - log.Printf("failed to update front matter for %s: %v", identifier, err) - } + // Only update if file exists (skip if not yet created via Put) + if _, err := os.Stat(path); err != nil { + return nil } - // After updating content, check if a rename is needed + // Only process files with frontmatter + if !SupportsFrontMatter(path) { + return nil + } + + // Parse existing frontmatter to get FileType and current metadata + existing, fileType, err := ExtractFrontMatter(path) + if err != nil { + log.Printf("failed to extract frontmatter for %s: %v", identifier, err) + return nil + } + + // Update fields from 9P metadata + existing.Title = title + existing.Tags = tags + existing.Signature = signature + + // Build new filename using updated metadata dir := filepath.Dir(path) if dir == "." { dir = denoteDir } - // Get original filename and extract extension by removing the denote components - originalFilename := filepath.Base(path) - // Find where the extension starts (after tags or after title) - extStart := strings.Index(originalFilename, ".") - if extStart == -1 { - extStart = len(originalFilename) - } - fullExt := originalFilename[extStart:] - - filename := metadata.BuildFilename(identifier, signature, title, tags, fullExt) + ext := frontmatter.GetExtension(fileType) + filename := metadata.BuildFilename(existing, ext) newPath := filepath.Join(dir, filename) + // If filename changed, update path in 9P (which might trigger rename) if newPath != path { newPath = filepath.Clean(newPath) absNew, err := filepath.Abs(newPath) @@ -96,6 +86,11 @@ func HandleUpdateEvent(f *client.Fsys, identifier, denoteDir string) error { } } + // Apply updated frontmatter to file content + if err := UpdateFrontMatter(path, existing, fileType); err != nil { + log.Printf("failed to update front matter for %s: %v", identifier, err) + } + return nil } diff --git a/internal/disk/loader.go b/internal/disk/loader.go index e0ce223..0eb56d7 100644 --- a/internal/disk/loader.go +++ b/internal/disk/loader.go @@ -1,10 +1,13 @@ package disk import ( + "denote/pkg/encoding/frontmatter" "denote/pkg/metadata" + "denote/pkg/util" "fmt" "os" "path/filepath" + "regexp" "strings" ) @@ -14,6 +17,39 @@ func SupportsFrontMatter(path string) bool { return ext == ".org" || ext == ".md" || ext == ".txt" } +// extractTitleFromContent extracts the title from file content. +// ext should be the file extension (e.g., ".md", ".org", ".txt"). +// Returns empty string if no title found or unsupported extension. +func extractTitleFromContent(content string, ext string) string { + ext = strings.ToLower(ext) + if ext != ".org" && ext != ".md" && ext != ".txt" { + return "" + } + + // Try org-mode #+title: first, then fall back to first heading + if ext == ".org" { + if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(content); m != nil { + return strings.TrimSpace(m[1]) + } + // Fallback to first heading (lines starting with *) + if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(content); m != nil { + return strings.TrimSpace(m[1]) + } + } + + // Try markdown YAML front matter title: first, then fall back to # header + if ext == ".md" { + if m := regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`).FindStringSubmatch(content); m != nil { + return strings.TrimSpace(strings.Trim(m[1], `"`)) + } + if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(content); m != nil { + return strings.TrimSpace(m[1]) + } + } + + return "" +} + // ExtractMetadata extracts metadata from a file (combines filename and content parsing). // This is the I/O wrapper around metadata's pure functions. func ExtractMetadata(path string) (*metadata.Metadata, error) { @@ -35,7 +71,7 @@ func ExtractMetadata(path string) (*metadata.Metadata, error) { } // Try to extract title from content - if title := metadata.ExtractTitleFromContent(string(content), ext); title != "" { + if title := extractTitleFromContent(string(content), ext); title != "" { note.Title = title } @@ -43,19 +79,20 @@ func ExtractMetadata(path string) (*metadata.Metadata, error) { } // ExtractFrontMatter reads a file and parses its front matter. -func ExtractFrontMatter(path string) (*metadata.FrontMatter, error) { +// Returns the parsed FrontMatter and the detected FileType. +func ExtractFrontMatter(path string) (*frontmatter.FrontMatter, frontmatter.FileType, error) { ext := strings.ToLower(filepath.Ext(path)) content, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) + return nil, "", fmt.Errorf("failed to read file: %w", err) } - return metadata.ParseFrontMatter(string(content), ext) + return frontmatter.Unmarshal(string(content), ext) } // UpdateFrontMatter updates the front matter in a file. -func UpdateFrontMatter(path string, fm *metadata.FrontMatter) error { +func UpdateFrontMatter(path string, fm *frontmatter.FrontMatter, fileType frontmatter.FileType) error { // Only update frontmatter for supported file types if !SupportsFrontMatter(path) { return nil @@ -68,7 +105,7 @@ func UpdateFrontMatter(path string, fm *metadata.FrontMatter) error { } // Apply front matter to content - newContent, err := metadata.Apply(string(content), fm) + newContent, err := util.Apply(string(content), fm, fileType) if err != nil { return fmt.Errorf("failed to apply front matter: %w", err) } diff --git a/internal/disk/sync.go b/internal/disk/sync.go index 24c194d..1409100 100644 --- a/internal/disk/sync.go +++ b/internal/disk/sync.go @@ -134,7 +134,7 @@ func registerNewNote(path string) error { // syncFrontMatter reads the file's front matter and writes it to 9P metadata func syncFrontMatter(path, identifier string) error { // Extract front matter from file - fm, err := ExtractFrontMatter(path) + fm, _, err := ExtractFrontMatter(path) if err != nil { return err } @@ -266,7 +266,7 @@ func GetAll() error { // getDenoteFile reloads a text file with front matter from disk to 9P func getDenoteFile(f *client.Fsys, path, identifier string) error { // Extract front matter from file - fm, err := ExtractFrontMatter(path) + fm, _, err := ExtractFrontMatter(path) if err != nil { return err } diff --git a/main.go b/main.go index 5a5f29c..ad86431 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "denote/internal/disk" p9client "denote/internal/p9/client" p9server "denote/internal/p9/server" + "denote/pkg/encoding/frontmatter" "denote/pkg/metadata" "fmt" "log" @@ -21,7 +22,7 @@ import ( const ( wname = "/Denote/" - ftype = metadata.FileTypeMdYaml + ftype = frontmatter.FileTypeMdYaml ) var denoteDir = os.Getenv("HOME") + "/doc" @@ -113,11 +114,11 @@ func handleNewEvent(f *client.Fsys, identifier, denoteDir string) error { } newIdentifier := metadata.GenerateIdentifier() - ext := metadata.GetExtension(ftype) - filename := metadata.BuildFilename(newIdentifier, signature, title, tags, ext) + fm := frontmatter.New(title, signature, tags, newIdentifier) + ext := frontmatter.GetExtension(ftype) + filename := metadata.BuildFilename(fm, ext) path := filepath.Join(targetDir, filename) - fm := metadata.NewFrontMatter(title, signature, tags, ftype, newIdentifier) - content := string(fm.Bytes()) + content := string(frontmatter.Marshal(fm, ftype)) if err := p9client.WriteFile(f, "n/"+identifier+"/path", path); err != nil { return fmt.Errorf("failed to update path in metadata: %w", err) diff --git a/pkg/metadata/frontmatter.go b/pkg/encoding/frontmatter/frontmatter.go similarity index 66% rename from pkg/metadata/frontmatter.go rename to pkg/encoding/frontmatter/frontmatter.go index 59b8a0c..5489e9f 100644 --- a/pkg/metadata/frontmatter.go +++ b/pkg/encoding/frontmatter/frontmatter.go @@ -1,4 +1,4 @@ -package metadata +package frontmatter import ( "fmt" @@ -89,35 +89,36 @@ type FrontMatter struct { Tags []string Identifier string Signature string - FileType FileType } -// Bytes returns the formatted frontmatter content as bytes -func (fm *FrontMatter) Bytes() []byte { - template := templates[fm.FileType] +// Marshal returns the formatted frontmatter content as bytes +func Marshal(fm *FrontMatter, fileType FileType) []byte { + template := templates[fileType] dateStr := time.Now().Format("2006-01-02 Mon 15:04") // For org-mode, wrap date in brackets for timestamp - if fm.FileType == FileTypeOrg { + if fileType == FileTypeOrg { dateStr = "[" + dateStr + "]" } - keywordsStr := FormatTags(fm.Tags, fm.FileType) + keywordsStr := FormatTags(fm.Tags, fileType) content := fmt.Sprintf(template, fm.Title, dateStr, keywordsStr, fm.Identifier, fm.Signature) return []byte(content) } -// ParseFrontMatter extracts front matter from file content. +// Unmarshal extracts front matter from file content. // ext should be the file extension (e.g., ".md", ".org", ".txt"). -func ParseFrontMatter(content string, ext string) (*FrontMatter, error) { +// Returns the parsed frontmatter and the detected FileType. +func Unmarshal(content string, ext string) (*FrontMatter, FileType, error) { ext = strings.ToLower(ext) text := content fm := &FrontMatter{} + var fileType FileType switch ext { case ".org": - fm.FileType = FileTypeOrg + fileType = FileTypeOrg if m := regexp.MustCompile(`(?m)^#\+title:[ \t]*(.+)$`).FindStringSubmatch(text); m != nil { fm.Title = strings.TrimSpace(m[1]) } @@ -135,7 +136,7 @@ func ParseFrontMatter(content string, ext string) (*FrontMatter, error) { // Try YAML first yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`) if m := yamlRe.FindStringSubmatch(text); m != nil { - fm.FileType = FileTypeMdYaml + fileType = FileTypeMdYaml yamlContent := m[1] if m := regexp.MustCompile(`(?m)^title:[ \t]*["']?(.+?)["']?$`).FindStringSubmatch(yamlContent); m != nil { fm.Title = strings.TrimSpace(m[1]) @@ -157,7 +158,7 @@ func ParseFrontMatter(content string, ext string) (*FrontMatter, error) { // Try TOML tomlRe := regexp.MustCompile(`(?ms)^\+\+\+\n(.*?)\n\+\+\+`) if m := tomlRe.FindStringSubmatch(text); m != nil { - fm.FileType = FileTypeMdToml + fileType = FileTypeMdToml tomlContent := m[1] if m := regexp.MustCompile(`(?m)^title[ \t]*=[ \t]*["']?(.+?)["']?$`).FindStringSubmatch(tomlContent); m != nil { fm.Title = strings.TrimSpace(m[1]) @@ -179,7 +180,7 @@ func ParseFrontMatter(content string, ext string) (*FrontMatter, error) { } case ".txt": - fm.FileType = FileTypeTxt + fileType = FileTypeTxt if m := regexp.MustCompile(`(?m)^title:[ \t]*(.+)$`).FindStringSubmatch(text); m != nil { fm.Title = strings.TrimSpace(m[1]) } @@ -194,77 +195,15 @@ func ParseFrontMatter(content string, ext string) (*FrontMatter, error) { } } - return fm, nil + return fm, fileType, nil } -// Apply applies front matter to file content, replacing existing front matter if present. -// originalContent is the current file content, fm is the new front matter to apply. -func Apply(originalContent string, fm *FrontMatter) (string, error) { - text := originalContent - newFrontMatter := string(fm.Bytes()) - - var newText string - switch fm.FileType { - case FileTypeOrg: - // Find end of front matter (first blank line or non-#+ line) - lines := strings.Split(text, "\n") - endIdx := 0 - for i, line := range lines { - if i > 0 && (line == "" || !strings.HasPrefix(line, "#+")) { - endIdx = i - break - } - } - // Skip any blank lines after frontmatter since template includes one - for endIdx < len(lines) && lines[endIdx] == "" { - endIdx++ - } - if endIdx > 0 { - newText = newFrontMatter + strings.Join(lines[endIdx:], "\n") - } else { - newText = newFrontMatter + text - } - - case FileTypeMdYaml: - // Replace YAML front matter (match trailing blank lines to avoid duplication) - re := regexp.MustCompile(`(?s)^---\n.*?\n---\n\n*`) - if re.MatchString(text) { - newText = re.ReplaceAllString(text, newFrontMatter) - } else { - newText = newFrontMatter + text - } - - case FileTypeMdToml: - // Replace TOML front matter (match trailing blank lines to avoid duplication) - re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n\n*`) - if re.MatchString(text) { - newText = re.ReplaceAllString(text, newFrontMatter) - } else { - newText = newFrontMatter + text - } - - case FileTypeTxt: - // Replace text front matter (match trailing blank lines to avoid duplication) - re := regexp.MustCompile(`(?s)^title:.*?\n-+\n\n*`) - if re.MatchString(text) { - newText = re.ReplaceAllString(text, newFrontMatter) - } else { - newText = newFrontMatter + text - } - default: - return "", fmt.Errorf("unsupported file type: %s", fm.FileType) - } - - return newText, nil -} - -// NewFrontMatter creates a new FrontMatter struct from given parameters -func NewFrontMatter(title, signature string, tags []string, fileType FileType, identifier string) *FrontMatter { +// New creates a new FrontMatter struct from given parameters +func New(title, signature string, tags []string, identifier string) *FrontMatter { return &FrontMatter{ Title: title, Tags: tags, Identifier: identifier, Signature: signature, - FileType: fileType, } } diff --git a/pkg/encoding/frontmatter/frontmatter_test.go b/pkg/encoding/frontmatter/frontmatter_test.go new file mode 100644 index 0000000..536c410 --- /dev/null +++ b/pkg/encoding/frontmatter/frontmatter_test.go @@ -0,0 +1,387 @@ +package frontmatter + +import ( + "slices" + "strings" + "testing" +) + +// TestFormatTags validates tag formatting for different file types +// Maps to dt-denote--format-front-matter from original tests +func TestFormatTags(t *testing.T) { + tests := []struct { + name string + tags []string + fileType FileType + want string + }{ + { + name: "org with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: FileTypeOrg, + want: ":tag1:tag2:", + }, + { + name: "org with single tag", + tags: []string{"single"}, + fileType: FileTypeOrg, + want: ":single:", + }, + { + name: "org with empty tags", + tags: []string{}, + fileType: FileTypeOrg, + want: "", + }, + { + name: "md-yaml with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: FileTypeMdYaml, + want: "[tag1, tag2]", + }, + { + name: "md-yaml with single tag", + tags: []string{"single"}, + fileType: FileTypeMdYaml, + want: "[single]", + }, + { + name: "md-yaml with empty tags", + tags: []string{}, + fileType: FileTypeMdYaml, + want: "", + }, + { + name: "md-toml with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: FileTypeMdToml, + want: "[tag1, tag2]", + }, + { + name: "md-toml with empty tags", + tags: []string{}, + fileType: FileTypeMdToml, + want: "", + }, + { + name: "txt with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: FileTypeTxt, + want: "tag1 tag2", + }, + { + name: "txt with single tag", + tags: []string{"single"}, + fileType: FileTypeTxt, + want: "single", + }, + { + name: "txt with empty tags", + tags: []string{}, + fileType: FileTypeTxt, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatTags(tt.tags, tt.fileType) + if got != tt.want { + t.Errorf("FormatTags(%v, %q) = %q, want %q", tt.tags, tt.fileType, got, tt.want) + } + }) + } +} + +// TestFrontMatterBytes validates front matter formatting for all file types +// Maps to dt-denote--format-front-matter from original tests +func TestFrontMatterBytes(t *testing.T) { + identifier := "20240101T120000" + title := "Test Note" + tags := []string{"tag1", "tag2"} + + tests := []struct { + name string + fileType FileType + wantContains []string + wantNotContains []string + }{ + { + name: "org format", + fileType: FileTypeOrg, + wantContains: []string{ + "#+title: Test Note", + "#+filetags: :tag1:tag2:", + "#+identifier: 20240101T120000", + "#+date:", + }, + }, + { + name: "md-yaml format", + fileType: FileTypeMdYaml, + wantContains: []string{ + "---", + "title: Test Note", + "tags: [tag1, tag2]", + "identifier: 20240101T120000", + "date:", + }, + }, + { + name: "md-toml format", + fileType: FileTypeMdToml, + wantContains: []string{ + "+++", + "title = Test Note", + "tags = [tag1, tag2]", + "identifier = 20240101T120000", + "date =", + }, + }, + { + name: "txt format", + fileType: FileTypeTxt, + wantContains: []string{ + "title: Test Note", + "tags: tag1 tag2", + "identifier: 20240101T120000", + "date:", + "---------------------------", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm := New(title, "", tags, identifier) + got := string(Marshal(fm, tt.fileType)) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("FrontMatter.Bytes() missing %q\nGot:\n%s", want, got) + } + } + + for _, notWant := range tt.wantNotContains { + if strings.Contains(got, notWant) { + t.Errorf("FrontMatter.Bytes() should not contain %q\nGot:\n%s", notWant, got) + } + } + }) + } +} + +// TestFrontMatterBytesEmptyTags validates front matter formatting with no tags +func TestFrontMatterBytesEmptyTags(t *testing.T) { + identifier := "20240101T120000" + title := "Test Note" + tags := []string{} + + tests := []struct { + name string + fileType FileType + wantTags string + }{ + { + name: "org with empty tags", + fileType: FileTypeOrg, + wantTags: "#+filetags:", + }, + { + name: "md-yaml with empty tags", + fileType: FileTypeMdYaml, + wantTags: "tags:", + }, + { + name: "md-toml with empty tags", + fileType: FileTypeMdToml, + wantTags: "tags =", + }, + { + name: "txt with empty tags", + fileType: FileTypeTxt, + wantTags: "tags:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm := New(title, "", tags, identifier) + got := string(Marshal(fm, tt.fileType)) + + if !strings.Contains(got, tt.wantTags) { + t.Errorf("FrontMatter.Bytes() with empty tags should contain %q\nGot:\n%s", + tt.wantTags, got) + } + }) + } +} + +// TestUnmarshal validates front matter parsing from content +func TestUnmarshal(t *testing.T) { + tests := []struct { + name string + content string + ext string + wantTitle string + wantTags []string + wantIdentifier string + wantFileType FileType + }{ + { + name: "org format", + content: `#+title: Org Note +#+date: [2024-01-01 Mon 12:00] +#+filetags: :work:emacs: +#+identifier: 20240101T120000 + +* First Heading`, + ext: ".org", + wantTitle: "Org Note", + wantTags: []string{"work", "emacs"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeOrg, + }, + { + name: "org with single tag", + content: `#+title: Single Tag +#+filetags: :single: +#+identifier: 20240101T120000`, + ext: ".org", + wantTitle: "Single Tag", + wantTags: []string{"single"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeOrg, + }, + { + name: "org without tags", + content: `#+title: No Tags +#+identifier: 20240101T120000`, + ext: ".org", + wantTitle: "No Tags", + wantTags: nil, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeOrg, + }, + { + name: "markdown yaml", + content: `--- +title: Markdown Note +date: 2024-01-01 +tags: [work, personal] +identifier: 20240101T120000 +--- + +# Content`, + ext: ".md", + wantTitle: "Markdown Note", + wantTags: []string{"work", "personal"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeMdYaml, + }, + { + name: "markdown yaml with quoted title", + content: `--- +title: "Quoted Title" +tags: [test] +identifier: 20240101T120000 +---`, + ext: ".md", + wantTitle: "Quoted Title", + wantTags: []string{"test"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeMdYaml, + }, + { + name: "markdown toml", + content: `+++ +title = TOML Note +date = 2024-01-01 +tags = [rust, go] +identifier = 20240101T120000 ++++ + +Content`, + ext: ".md", + wantTitle: "TOML Note", + wantTags: []string{"rust", "go"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeMdToml, + }, + { + name: "txt format", + content: `title: Plain Text +date: 2024-01-01 +tags: simple plain +identifier: 20240101T120000 +--------------------------- + +Content here`, + ext: ".txt", + wantTitle: "Plain Text", + wantTags: []string{"simple", "plain"}, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeTxt, + }, + { + name: "txt without tags", + content: `title: No Tags Text +identifier: 20240101T120000 +---------------------------`, + ext: ".txt", + wantTitle: "No Tags Text", + wantTags: nil, + wantIdentifier: "20240101T120000", + wantFileType: FileTypeTxt, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, fileType, err := Unmarshal(tt.content, tt.ext) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got.Title != tt.wantTitle { + t.Errorf("Unmarshal().Title = %q, want %q", got.Title, tt.wantTitle) + } + + if !slices.Equal(got.Tags, tt.wantTags) { + t.Errorf("Unmarshal().Tags = %v, want %v", got.Tags, tt.wantTags) + } + + if got.Identifier != tt.wantIdentifier { + t.Errorf("Unmarshal().Identifier = %q, want %q", got.Identifier, tt.wantIdentifier) + } + + if fileType != tt.wantFileType { + t.Errorf("Unmarshal() fileType = %q, want %q", fileType, tt.wantFileType) + } + }) + } +} + +// TestUnmarshalMissingFields validates handling of missing fields +func TestUnmarshalMissingFields(t *testing.T) { + content := `--- +date: 2024-01-01 +---` + + got, _, err := Unmarshal(content, ".md") + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + // Should have empty values, not error + if got.Title != "" { + t.Errorf("Unmarshal().Title = %q, want empty", got.Title) + } + if got.Identifier != "" { + t.Errorf("Unmarshal().Identifier = %q, want empty", got.Identifier) + } + if got.Tags != nil { + t.Errorf("Unmarshal().Tags = %v, want nil", got.Tags) + } +} + diff --git a/pkg/metadata/frontmatter_test.go b/pkg/metadata/frontmatter_test.go deleted file mode 100644 index 7b6c9ac..0000000 --- a/pkg/metadata/frontmatter_test.go +++ /dev/null @@ -1,679 +0,0 @@ -package metadata - -import ( - "slices" - "strings" - "testing" -) - -// TestFormatTags validates tag formatting for different file types -// Maps to dt-denote--format-front-matter from original tests -func TestFormatTags(t *testing.T) { - tests := []struct { - name string - tags []string - fileType FileType - want string - }{ - { - name: "org with multiple tags", - tags: []string{"tag1", "tag2"}, - fileType: FileTypeOrg, - want: ":tag1:tag2:", - }, - { - name: "org with single tag", - tags: []string{"single"}, - fileType: FileTypeOrg, - want: ":single:", - }, - { - name: "org with empty tags", - tags: []string{}, - fileType: FileTypeOrg, - want: "", - }, - { - name: "md-yaml with multiple tags", - tags: []string{"tag1", "tag2"}, - fileType: FileTypeMdYaml, - want: "[tag1, tag2]", - }, - { - name: "md-yaml with single tag", - tags: []string{"single"}, - fileType: FileTypeMdYaml, - want: "[single]", - }, - { - name: "md-yaml with empty tags", - tags: []string{}, - fileType: FileTypeMdYaml, - want: "", - }, - { - name: "md-toml with multiple tags", - tags: []string{"tag1", "tag2"}, - fileType: FileTypeMdToml, - want: "[tag1, tag2]", - }, - { - name: "md-toml with empty tags", - tags: []string{}, - fileType: FileTypeMdToml, - want: "", - }, - { - name: "txt with multiple tags", - tags: []string{"tag1", "tag2"}, - fileType: FileTypeTxt, - want: "tag1 tag2", - }, - { - name: "txt with single tag", - tags: []string{"single"}, - fileType: FileTypeTxt, - want: "single", - }, - { - name: "txt with empty tags", - tags: []string{}, - fileType: FileTypeTxt, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FormatTags(tt.tags, tt.fileType) - if got != tt.want { - t.Errorf("FormatTags(%v, %q) = %q, want %q", tt.tags, tt.fileType, got, tt.want) - } - }) - } -} - -// TestFrontMatterBytes validates front matter formatting for all file types -// Maps to dt-denote--format-front-matter from original tests -func TestFrontMatterBytes(t *testing.T) { - identifier := "20240101T120000" - title := "Test Note" - tags := []string{"tag1", "tag2"} - - tests := []struct { - name string - fileType FileType - wantContains []string - wantNotContains []string - }{ - { - name: "org format", - fileType: FileTypeOrg, - wantContains: []string{ - "#+title: Test Note", - "#+filetags: :tag1:tag2:", - "#+identifier: 20240101T120000", - "#+date:", - }, - }, - { - name: "md-yaml format", - fileType: FileTypeMdYaml, - wantContains: []string{ - "---", - "title: Test Note", - "tags: [tag1, tag2]", - "identifier: 20240101T120000", - "date:", - }, - }, - { - name: "md-toml format", - fileType: FileTypeMdToml, - wantContains: []string{ - "+++", - "title = Test Note", - "tags = [tag1, tag2]", - "identifier = 20240101T120000", - "date =", - }, - }, - { - name: "txt format", - fileType: FileTypeTxt, - wantContains: []string{ - "title: Test Note", - "tags: tag1 tag2", - "identifier: 20240101T120000", - "date:", - "---------------------------", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fm := NewFrontMatter(title, "", tags, tt.fileType, identifier) - got := string(fm.Bytes()) - - for _, want := range tt.wantContains { - if !strings.Contains(got, want) { - t.Errorf("FrontMatter.Bytes() missing %q\nGot:\n%s", want, got) - } - } - - for _, notWant := range tt.wantNotContains { - if strings.Contains(got, notWant) { - t.Errorf("FrontMatter.Bytes() should not contain %q\nGot:\n%s", notWant, got) - } - } - }) - } -} - -// TestFrontMatterBytesEmptyTags validates front matter formatting with no tags -func TestFrontMatterBytesEmptyTags(t *testing.T) { - identifier := "20240101T120000" - title := "Test Note" - tags := []string{} - - tests := []struct { - name string - fileType FileType - wantTags string - }{ - { - name: "org with empty tags", - fileType: FileTypeOrg, - wantTags: "#+filetags:", - }, - { - name: "md-yaml with empty tags", - fileType: FileTypeMdYaml, - wantTags: "tags:", - }, - { - name: "md-toml with empty tags", - fileType: FileTypeMdToml, - wantTags: "tags =", - }, - { - name: "txt with empty tags", - fileType: FileTypeTxt, - wantTags: "tags:", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fm := NewFrontMatter(title, "", tags, tt.fileType, identifier) - got := string(fm.Bytes()) - - if !strings.Contains(got, tt.wantTags) { - t.Errorf("FrontMatter.Bytes() with empty tags should contain %q\nGot:\n%s", - tt.wantTags, got) - } - }) - } -} - -// TestParseFrontMatter validates front matter parsing from content -func TestParseFrontMatter(t *testing.T) { - tests := []struct { - name string - content string - ext string - wantTitle string - wantTags []string - wantIdentifier string - wantFileType FileType - }{ - { - name: "org format", - content: `#+title: Org Note -#+date: [2024-01-01 Mon 12:00] -#+filetags: :work:emacs: -#+identifier: 20240101T120000 - -* First Heading`, - ext: ".org", - wantTitle: "Org Note", - wantTags: []string{"work", "emacs"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeOrg, - }, - { - name: "org with single tag", - content: `#+title: Single Tag -#+filetags: :single: -#+identifier: 20240101T120000`, - ext: ".org", - wantTitle: "Single Tag", - wantTags: []string{"single"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeOrg, - }, - { - name: "org without tags", - content: `#+title: No Tags -#+identifier: 20240101T120000`, - ext: ".org", - wantTitle: "No Tags", - wantTags: nil, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeOrg, - }, - { - name: "markdown yaml", - content: `--- -title: Markdown Note -date: 2024-01-01 -tags: [work, personal] -identifier: 20240101T120000 ---- - -# Content`, - ext: ".md", - wantTitle: "Markdown Note", - wantTags: []string{"work", "personal"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeMdYaml, - }, - { - name: "markdown yaml with quoted title", - content: `--- -title: "Quoted Title" -tags: [test] -identifier: 20240101T120000 ----`, - ext: ".md", - wantTitle: "Quoted Title", - wantTags: []string{"test"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeMdYaml, - }, - { - name: "markdown toml", - content: `+++ -title = TOML Note -date = 2024-01-01 -tags = [rust, go] -identifier = 20240101T120000 -+++ - -Content`, - ext: ".md", - wantTitle: "TOML Note", - wantTags: []string{"rust", "go"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeMdToml, - }, - { - name: "txt format", - content: `title: Plain Text -date: 2024-01-01 -tags: simple plain -identifier: 20240101T120000 ---------------------------- - -Content here`, - ext: ".txt", - wantTitle: "Plain Text", - wantTags: []string{"simple", "plain"}, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeTxt, - }, - { - name: "txt without tags", - content: `title: No Tags Text -identifier: 20240101T120000 ----------------------------`, - ext: ".txt", - wantTitle: "No Tags Text", - wantTags: nil, - wantIdentifier: "20240101T120000", - wantFileType: FileTypeTxt, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseFrontMatter(tt.content, tt.ext) - if err != nil { - t.Fatalf("ParseFrontMatter() error = %v", err) - } - - if got.Title != tt.wantTitle { - t.Errorf("ParseFrontMatter().Title = %q, want %q", got.Title, tt.wantTitle) - } - - if !slices.Equal(got.Tags, tt.wantTags) { - t.Errorf("ParseFrontMatter().Tags = %v, want %v", got.Tags, tt.wantTags) - } - - if got.Identifier != tt.wantIdentifier { - t.Errorf("ParseFrontMatter().Identifier = %q, want %q", got.Identifier, tt.wantIdentifier) - } - - if got.FileType != tt.wantFileType { - t.Errorf("ParseFrontMatter().FileType = %q, want %q", got.FileType, tt.wantFileType) - } - }) - } -} - -// TestParseFrontMatterMissingFields validates handling of missing fields -func TestParseFrontMatterMissingFields(t *testing.T) { - content := `--- -date: 2024-01-01 ----` - - got, err := ParseFrontMatter(content, ".md") - if err != nil { - t.Fatalf("ParseFrontMatter() error = %v", err) - } - - // Should have empty values, not error - if got.Title != "" { - t.Errorf("ParseFrontMatter().Title = %q, want empty", got.Title) - } - if got.Identifier != "" { - t.Errorf("ParseFrontMatter().Identifier = %q, want empty", got.Identifier) - } - if got.Tags != nil { - t.Errorf("ParseFrontMatter().Tags = %v, want nil", got.Tags) - } -} - -// TestApply validates front matter updates -func TestApply(t *testing.T) { - tests := []struct { - name string - original string - fm *FrontMatter - wantContains []string - wantPreserve string - }{ - { - name: "update org front matter", - original: `#+title: Old Title -#+date: [2024-01-01 Mon 12:00] -#+filetags: :old: -#+identifier: 20240101T120000 - -* Original Content -This should be preserved`, - fm: &FrontMatter{ - Title: "New Title", - Tags: []string{"new", "updated"}, - Identifier: "20240101T120000", - FileType: FileTypeOrg, - }, - wantContains: []string{ - "#+title: New Title", - "#+filetags: :new:updated:", - "* Original Content", - "This should be preserved", - }, - wantPreserve: "* Original Content", - }, - { - name: "update markdown yaml front matter", - original: `--- -title: Old Title -tags: [old] -identifier: 20240101T120000 ---- - -# Original Heading -Content preserved`, - fm: &FrontMatter{ - Title: "New Title", - Tags: []string{"new"}, - Identifier: "20240101T120000", - FileType: FileTypeMdYaml, - }, - wantContains: []string{ - "---", - "title: New Title", - "tags: [new]", - "# Original Heading", - "Content preserved", - }, - wantPreserve: "# Original Heading", - }, - { - name: "update markdown toml front matter", - original: `+++ -title = Old Title -tags = [old] -identifier = 20240101T120000 -+++ - -Content here`, - fm: &FrontMatter{ - Title: "New Title", - Tags: []string{"updated"}, - Identifier: "20240101T120000", - FileType: FileTypeMdToml, - }, - wantContains: []string{ - "+++", - "title = New Title", - "tags = [updated]", - "Content here", - }, - wantPreserve: "Content here", - }, - { - name: "update txt front matter", - original: `title: Old Title -tags: old -identifier: 20240101T120000 ---------------------------- - -Text content`, - fm: &FrontMatter{ - Title: "New Title", - Tags: []string{"new"}, - Identifier: "20240101T120000", - FileType: FileTypeTxt, - }, - wantContains: []string{ - "title: New Title", - "tags: new", - "---------------------------", - "Text content", - }, - wantPreserve: "Text content", - }, - { - name: "add front matter when missing (org)", - original: `* Original Heading`, - fm: &FrontMatter{ - Title: "Added Title", - Tags: []string{"added"}, - Identifier: "20240101T120000", - FileType: FileTypeOrg, - }, - wantContains: []string{ - "#+title: Added Title", - "#+filetags: :added:", - "* Original Heading", - }, - wantPreserve: "* Original Heading", - }, - { - name: "add front matter when missing (md-yaml)", - original: `# Original Heading`, - fm: &FrontMatter{ - Title: "Added Title", - Tags: []string{"added"}, - Identifier: "20240101T120000", - FileType: FileTypeMdYaml, - }, - wantContains: []string{ - "---", - "title: Added Title", - "tags: [added]", - "# Original Heading", - }, - wantPreserve: "# Original Heading", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := Apply(tt.original, tt.fm) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - - for _, want := range tt.wantContains { - if !strings.Contains(got, want) { - t.Errorf("Apply() missing %q\nGot:\n%s", want, got) - } - } - - // Verify original content is preserved - if !strings.Contains(got, tt.wantPreserve) { - t.Errorf("Apply() didn't preserve %q\nGot:\n%s", tt.wantPreserve, got) - } - }) - } -} - -// TestApplyEmptyTags validates updating with empty tags -func TestApplyEmptyTags(t *testing.T) { - original := `--- -title: Test -tags: [old, tags] -identifier: 20240101T120000 ---- - -Content` - - fm := &FrontMatter{ - Title: "Test", - Tags: []string{}, - Identifier: "20240101T120000", - FileType: FileTypeMdYaml, - } - - got, err := Apply(original, fm) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - - // Should have empty tags field, not omit it - if !strings.Contains(got, "tags:") { - t.Errorf("Apply() should include tags field even when empty") - } - - // Content should be preserved - if !strings.Contains(got, "Content") { - t.Errorf("Apply() should preserve content") - } -} - -// TestApplyUnsupportedType validates error handling -func TestApplyUnsupportedType(t *testing.T) { - fm := &FrontMatter{ - Title: "Test", - Tags: []string{"test"}, - Identifier: "20240101T120000", - FileType: "unsupported", - } - - _, err := Apply("content", fm) - if err == nil { - t.Error("Apply() should error on unsupported file type") - } - - if !strings.Contains(err.Error(), "unsupported file type") { - t.Errorf("Apply() error = %v, want 'unsupported file type'", err) - } -} - -// TestGetExtension validates file extension mapping -func TestGetExtension(t *testing.T) { - tests := []struct { - fileType FileType - want string - }{ - {FileTypeOrg, ".org"}, - {FileTypeMdYaml, ".md"}, - {FileTypeMdToml, ".md"}, - {FileTypeTxt, ".txt"}, - } - - for _, tt := range tests { - t.Run(string(tt.fileType), func(t *testing.T) { - got := GetExtension(tt.fileType) - if got != tt.want { - t.Errorf("GetExtension(%q) = %q, want %q", tt.fileType, got, tt.want) - } - }) - } -} - -// TestFrontMatterRoundTrip validates generate -> parse -> update cycle -func TestFrontMatterRoundTrip(t *testing.T) { - fileTypes := []FileType{FileTypeOrg, FileTypeMdYaml, FileTypeMdToml, FileTypeTxt} - title := "Test Note" - tags := []string{"tag1", "tag2"} - identifier := "20240101T120000" - - for _, fileType := range fileTypes { - t.Run(string(fileType), func(t *testing.T) { - // Generate front matter - fm := NewFrontMatter(title, "", tags, fileType, identifier) - content := string(fm.Bytes()) - - // Parse it back - ext := GetExtension(fileType) - fm, err := ParseFrontMatter(content, ext) - if err != nil { - t.Fatalf("ParseFrontMatter() error = %v", err) - } - - // Verify parsed values - if fm.Title != title { - t.Errorf("Parsed title = %q, want %q", fm.Title, title) - } - if !slices.Equal(fm.Tags, tags) { - t.Errorf("Parsed tags = %v, want %v", fm.Tags, tags) - } - if fm.Identifier != identifier { - t.Errorf("Parsed identifier = %q, want %q", fm.Identifier, identifier) - } - if fm.FileType != fileType { - t.Errorf("Parsed fileType = %q, want %q", fm.FileType, fileType) - } - - // Update with new values - newTitle := "Updated Title" - newTags := []string{"new"} - fm.Title = newTitle - fm.Tags = newTags - - updated, err := Apply(content, fm) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - - // Parse again and verify - fm2, err := ParseFrontMatter(updated, ext) - if err != nil { - t.Fatalf("ParseFrontMatter() after update error = %v", err) - } - - if fm2.Title != newTitle { - t.Errorf("After update title = %q, want %q", fm2.Title, newTitle) - } - if !slices.Equal(fm2.Tags, newTags) { - t.Errorf("After update tags = %v, want %v", fm2.Tags, newTags) - } - }) - } -} diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 97bb7fb..7575faa 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -1,6 +1,7 @@ package metadata import ( + "denote/pkg/encoding/frontmatter" "fmt" "path/filepath" "regexp" @@ -123,7 +124,6 @@ func Sort(md Results, sortType SortBy, order SortOrder) { // ParseFilename extracts Denote metadata from a filename only (no file I/O). // Returns metadata with Path, Identifier, Signature, Title (from filename), and Tags. -// Caller should use ExtractTitleFromContent to get title from file content. func ParseFilename(path string) *Metadata { fname := filepath.Base(path) note := &Metadata{Path: path} @@ -149,39 +149,6 @@ func ParseFilename(path string) *Metadata { return note } -// ExtractTitleFromContent extracts the title from file content. -// ext should be the file extension (e.g., ".md", ".org", ".txt"). -// Returns empty string if no title found or unsupported extension. -func ExtractTitleFromContent(content string, ext string) string { - ext = strings.ToLower(ext) - if ext != ".org" && ext != ".md" && ext != ".txt" { - return "" - } - - // Try org-mode #+title: first, then fall back to first heading - if ext == ".org" { - if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(content); m != nil { - return strings.TrimSpace(m[1]) - } - // Fallback to first heading (lines starting with *) - if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(content); m != nil { - return strings.TrimSpace(m[1]) - } - } - - // Try markdown YAML front matter title: first, then fall back to # header - if ext == ".md" { - if m := regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`).FindStringSubmatch(content); m != nil { - return strings.TrimSpace(strings.Trim(m[1], `"`)) - } - if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(content); m != nil { - return strings.TrimSpace(m[1]) - } - } - - return "" -} - // GenerateIdentifier creates a new identifier timestamp. func GenerateIdentifier() string { return time.Now().Format("20060102T150405") @@ -229,10 +196,9 @@ func formatSignature(sig string) string { } // BuildFilename constructs a denote filename from metadata components. -func BuildFilename(identifier, signature, title string, keywords []string, ext string) string { - titleSlug := slugifyTitle(title) - signaturePart := formatSignature(signature) - keywordsPart := formatKeywords(keywords) - return fmt.Sprintf("%s%s--%s%s%s", identifier, signaturePart, titleSlug, keywordsPart, ext) +func BuildFilename(fm *frontmatter.FrontMatter, ext string) string { + titleSlug := slugifyTitle(fm.Title) + signaturePart := formatSignature(fm.Signature) + keywordsPart := formatKeywords(fm.Tags) + return fmt.Sprintf("%s%s--%s%s%s", fm.Identifier, signaturePart, titleSlug, keywordsPart, ext) } - diff --git a/pkg/metadata/metadata_test.go b/pkg/metadata/metadata_test.go index 8c31ccd..7366683 100644 --- a/pkg/metadata/metadata_test.go +++ b/pkg/metadata/metadata_test.go @@ -1,6 +1,7 @@ package metadata import ( + "denote/pkg/encoding/frontmatter" "regexp" "slices" "testing" @@ -215,6 +216,7 @@ func TestBuildFilename(t *testing.T) { keywords []string ext string want string + ftype frontmatter.FileType }{ { name: "complete filename with keywords", @@ -224,6 +226,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{"tag1", "tag2"}, ext: ".md", want: "20231225T120000--my-title__tag1_tag2.md", + ftype: frontmatter.FileTypeMdYaml, }, { name: "filename without keywords", @@ -233,6 +236,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{}, ext: ".md", want: "20231225T120000--my-title.md", + ftype: frontmatter.FileTypeMdYaml, }, { name: "filename with signature", @@ -242,6 +246,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{"tag1"}, ext: ".md", want: "20231225T120000==hello--my-title__tag1.md", + ftype: frontmatter.FileTypeMdYaml, }, { name: "filename with signature and no keywords", @@ -251,6 +256,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{}, ext: ".md", want: "20231225T120000==test--my-title.md", + ftype: frontmatter.FileTypeMdYaml, }, { name: "filename with multi-part signature", @@ -260,6 +266,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{"work"}, ext: ".md", want: "20231225T120000==a==b--my-title__work.md", + ftype: frontmatter.FileTypeMdYaml, }, { name: "filename with special chars in title", @@ -269,6 +276,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{"work"}, ext: ".org", want: "20231225T120000--special-title__work.org", + ftype: frontmatter.FileTypeOrg, }, { name: "org format", @@ -278,6 +286,7 @@ func TestBuildFilename(t *testing.T) { keywords: []string{"emacs"}, ext: ".org", want: "20240101T000000--org-note__emacs.org", + ftype: frontmatter.FileTypeOrg, }, { name: "txt format", @@ -287,12 +296,14 @@ func TestBuildFilename(t *testing.T) { keywords: []string{}, ext: ".txt", want: "20240101T000000--plain-text.txt", + ftype: frontmatter.FileTypeTxt, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := BuildFilename(tt.identifier, tt.signature, tt.title, tt.keywords, tt.ext) + fm := frontmatter.New(tt.title, tt.signature, tt.keywords, tt.identifier) + got := BuildFilename(fm, tt.ext) if got != tt.want { t.Errorf("BuildFilename() = %q, want %q", got, tt.want) } @@ -409,103 +420,6 @@ func TestParseFilename(t *testing.T) { } } -// TestExtractTitleFromContent validates title extraction from file content -func TestExtractTitleFromContent(t *testing.T) { - tests := []struct { - name string - content string - ext string - want string - }{ - { - name: "org mode with title directive", - content: `#+title: My Org Note -#+date: [2024-01-01] - -* First Heading`, - ext: ".org", - want: "My Org Note", - }, - { - name: "org mode fallback to heading", - content: `#+date: [2024-01-01] - -* Heading Title - -Some content`, - ext: ".org", - want: "Heading Title", - }, - { - name: "markdown yaml front matter", - content: `--- -title: Markdown Note -date: 2024-01-01 ---- - -# First Heading`, - ext: ".md", - want: "Markdown Note", - }, - { - name: "markdown yaml with quotes", - content: `--- -title: "Quoted Title" -date: 2024-01-01 ----`, - ext: ".md", - want: "Quoted Title", - }, - { - name: "markdown fallback to heading", - content: `--- -date: 2024-01-01 ---- - -# Heading Title - -Content here`, - ext: ".md", - want: "Heading Title", - }, - { - name: "markdown heading without front matter", - content: `# Simple Title - -Content`, - ext: ".md", - want: "Simple Title", - }, - { - name: "unsupported extension", - content: "Some content", - ext: ".pdf", - want: "", - }, - { - name: "empty content", - content: "", - ext: ".md", - want: "", - }, - { - name: "no title found", - content: "Just some text without structure", - ext: ".md", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ExtractTitleFromContent(tt.content, tt.ext) - if got != tt.want { - t.Errorf("ExtractTitleFromContent() = %q, want %q", got, tt.want) - } - }) - } -} - // TestSort validates sorting functionality func TestSort(t *testing.T) { notes := Results{ @@ -595,9 +509,9 @@ func TestSort(t *testing.T) { // TestResultsBytes validates serialization func TestResultsBytes(t *testing.T) { tests := []struct { - name string - input Results - want string + name string + input Results + want string }{ { name: "single note with tags", diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..491479b --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,69 @@ +package util + +import ( + "denote/pkg/encoding/frontmatter" + "fmt" + "regexp" + "strings" +) + +// Apply applies front matter to file content, replacing existing front matter if present. +// originalContent is the current file content, fm is the new front matter to apply. +func Apply(originalContent string, fm *frontmatter.FrontMatter, fileType frontmatter.FileType) (string, error) { + text := originalContent + newFrontMatter := string(frontmatter.Marshal(fm, fileType)) + + var newText string + switch fileType { + case frontmatter.FileTypeOrg: + // Find end of front matter (first blank line or non-#+ line) + lines := strings.Split(text, "\n") + endIdx := 0 + for i, line := range lines { + if i > 0 && (line == "" || !strings.HasPrefix(line, "#+")) { + endIdx = i + break + } + } + // Skip any blank lines after frontmatter since template includes one + for endIdx < len(lines) && lines[endIdx] == "" { + endIdx++ + } + if endIdx > 0 { + newText = newFrontMatter + strings.Join(lines[endIdx:], "\n") + } else { + newText = newFrontMatter + text + } + + case frontmatter.FileTypeMdYaml: + // Replace YAML front matter (match trailing blank lines to avoid duplication) + re := regexp.MustCompile(`(?s)^---\n.*?\n---\n\n*`) + if re.MatchString(text) { + newText = re.ReplaceAllString(text, newFrontMatter) + } else { + newText = newFrontMatter + text + } + + case frontmatter.FileTypeMdToml: + // Replace TOML front matter (match trailing blank lines to avoid duplication) + re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n\n*`) + if re.MatchString(text) { + newText = re.ReplaceAllString(text, newFrontMatter) + } else { + newText = newFrontMatter + text + } + + case frontmatter.FileTypeTxt: + // Replace text front matter (match trailing blank lines to avoid duplication) + re := regexp.MustCompile(`(?s)^title:.*?\n-+\n\n*`) + if re.MatchString(text) { + newText = re.ReplaceAllString(text, newFrontMatter) + } else { + newText = newFrontMatter + text + } + default: + return "", fmt.Errorf("unsupported file type: %s", fileType) + } + + return newText, nil +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go new file mode 100644 index 0000000..afb84dd --- /dev/null +++ b/pkg/util/util_test.go @@ -0,0 +1,215 @@ +package util + +import ( + "denote/pkg/encoding/frontmatter" + "strings" + "testing" +) + +func TestApply(t *testing.T) { + tests := []struct { + name string + original string + fm *frontmatter.FrontMatter + fileType frontmatter.FileType + wantContains []string + wantPreserve string + }{ + { + name: "update org front matter", + original: `#+title: Old Title +#+date: [2024-01-01 Mon 12:00] +#+filetags: :old: +#+identifier: 20240101T120000 + +* Original Content +This should be preserved`, + fm: &frontmatter.FrontMatter{ + Title: "New Title", + Tags: []string{"new", "updated"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeOrg, + wantContains: []string{ + "#+title: New Title", + "#+filetags: :new:updated:", + "* Original Content", + "This should be preserved", + }, + wantPreserve: "* Original Content", + }, + { + name: "update markdown yaml front matter", + original: `--- +title: Old Title +tags: [old] +identifier: 20240101T120000 +--- + +# Original Heading +Content preserved`, + fm: &frontmatter.FrontMatter{ + Title: "New Title", + Tags: []string{"new"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeMdYaml, + wantContains: []string{ + "---", + "title: New Title", + "tags: [new]", + "# Original Heading", + "Content preserved", + }, + wantPreserve: "# Original Heading", + }, + { + name: "update markdown toml front matter", + original: `+++ +title = Old Title +tags = [old] +identifier = 20240101T120000 ++++ + +Content here`, + fm: &frontmatter.FrontMatter{ + Title: "New Title", + Tags: []string{"updated"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeMdToml, + wantContains: []string{ + "+++", + "title = New Title", + "tags = [updated]", + "Content here", + }, + wantPreserve: "Content here", + }, + { + name: "update txt front matter", + original: `title: Old Title +tags: old +identifier: 20240101T120000 +--------------------------- + +Text content`, + fm: &frontmatter.FrontMatter{ + Title: "New Title", + Tags: []string{"new"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeTxt, + wantContains: []string{ + "title: New Title", + "tags: new", + "---------------------------", + "Text content", + }, + wantPreserve: "Text content", + }, + { + name: "add front matter when missing (org)", + original: `* Original Heading`, + fm: &frontmatter.FrontMatter{ + Title: "Added Title", + Tags: []string{"added"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeOrg, + wantContains: []string{ + "#+title: Added Title", + "#+filetags: :added:", + "* Original Heading", + }, + wantPreserve: "* Original Heading", + }, + { + name: "add front matter when missing (md-yaml)", + original: `# Original Heading`, + fm: &frontmatter.FrontMatter{ + Title: "Added Title", + Tags: []string{"added"}, + Identifier: "20240101T120000", + }, + fileType: frontmatter.FileTypeMdYaml, + wantContains: []string{ + "---", + "title: Added Title", + "tags: [added]", + "# Original Heading", + }, + wantPreserve: "# Original Heading", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Apply(tt.original, tt.fm, tt.fileType) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("Apply() missing %q\nGot:\n%s", want, got) + } + } + + // Verify original content is preserved + if !strings.Contains(got, tt.wantPreserve) { + t.Errorf("Apply() didn't preserve %q\nGot:\n%s", tt.wantPreserve, got) + } + }) + } +} + +// TestApplyEmptyTags validates updating with empty tags +func TestApplyEmptyTags(t *testing.T) { + original := `--- +title: Test +tags: [old, tags] +identifier: 20240101T120000 +--- + +Content` + + fm := &frontmatter.FrontMatter{ + Title: "Test", + Tags: []string{}, + Identifier: "20240101T120000", + } + + got, err := Apply(original, fm, frontmatter.FileTypeMdYaml) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + + // Should have empty tags field, not omit it + if !strings.Contains(got, "tags:") { + t.Errorf("Apply() should include tags field even when empty") + } + + // Content should be preserved + if !strings.Contains(got, "Content") { + t.Errorf("Apply() should preserve content") + } +} + +// TestApplyUnsupportedType validates error handling +func TestApplyUnsupportedType(t *testing.T) { + fm := &frontmatter.FrontMatter{ + Title: "Test", + Tags: []string{"test"}, + Identifier: "20240101T120000", + } + + _, err := Apply("content", fm, "unsupported") + if err == nil { + t.Error("Apply() should error on unsupported file type") + } + + if !strings.Contains(err.Error(), "unsupported file type") { + t.Errorf("Apply() error = %v, want 'unsupported file type'", err) + } +}