From 603cd310026183cd097363eee8e1f523f2c7e0f6 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:10:08 +0100 Subject: [PATCH] architectural cleanup, fix frontmatter bugs --- internal/metadata/frontmatter.go | 30 +++--- internal/metadata/metadata.go | 43 +++------ internal/p9/client/client.go | 66 +------------ internal/p9/server/fs.go | 60 +++--------- internal/sync/loader.go | 115 ++++++++++++++++++++++ internal/sync/sync.go | 38 ++++---- main.go | 158 ++++++++++++++++++++++++------- 7 files changed, 297 insertions(+), 213 deletions(-) create mode 100644 internal/sync/loader.go diff --git a/internal/metadata/frontmatter.go b/internal/metadata/frontmatter.go index ccc299e..0459cf1 100644 --- a/internal/metadata/frontmatter.go +++ b/internal/metadata/frontmatter.go @@ -2,8 +2,6 @@ package metadata import ( "fmt" - "os" - "path/filepath" "regexp" "strings" "time" @@ -74,14 +72,11 @@ type FrontMatter struct { FileType string // org, md-yaml, md-toml, txt } -// Extract extracts front matter from a file -func Extract(path string) (*FrontMatter, error) { - ext := strings.ToLower(filepath.Ext(path)) - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - text := string(data) +// ParseFrontMatter 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) { + ext = strings.ToLower(ext) + text := content fm := &FrontMatter{} @@ -155,13 +150,10 @@ func Extract(path string) (*FrontMatter, error) { return fm, nil } -// Update updates front matter in a file -func Update(path string, fm *FrontMatter) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - text := string(data) +// UpdateFrontMatter updates front matter in file content and returns the new content. +// originalContent is the current file content, fm is the new front matter to apply. +func UpdateFrontMatter(originalContent string, fm *FrontMatter) (string, error) { + text := originalContent dateStr := time.Now().Format("2006-01-02 Mon 15:04") keywordsStr := FormatTags(fm.Tags, fm.FileType) @@ -219,10 +211,10 @@ func Update(path string, fm *FrontMatter) error { newText = newFrontMatter + text } default: - return fmt.Errorf("unsupported file type: %s", fm.FileType) + return "", fmt.Errorf("unsupported file type: %s", fm.FileType) } - return os.WriteFile(path, []byte(newText), 0644) + return newText, nil } // Generate generates front matter content for given parameters diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index c10d849..62f7c3b 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -2,7 +2,6 @@ package metadata import ( "fmt" - "os" "path/filepath" "regexp" "sort" @@ -59,10 +58,6 @@ func (es Results) FromString(data string) (Results, error) { return nil, fmt.Errorf("line %d: identifier cannot be empty", lineNum+1) } - if strings.Contains(title, "|") { - return nil, fmt.Errorf("line %d: title cannot contain '|'", lineNum+1) - } - var tags []string if tagsStr != "" { if !tagPattern.MatchString(tagsStr) { @@ -136,8 +131,10 @@ func Sort(md Results, sortType SortBy, order SortOrder) { } } -// ExtractMetadata extracts Denote metadata from a filename -func ExtractMetadata(path string) *Metadata { +// ParseFilename extracts Denote metadata from a filename only (no file I/O). +// Returns metadata with Path, Identifier, 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} @@ -146,17 +143,8 @@ func ExtractMetadata(path string) *Metadata { } // Extract title from filename - filenameTitle := "" if m := regexp.MustCompile(`--([^_\.]+)`).FindStringSubmatch(fname); m != nil { - filenameTitle = strings.ReplaceAll(m[1], "-", " ") - } - - // Try to get title from file content, fall back to filename - fileContentTitle := extractTitle(path) - if fileContentTitle != "" { - note.Title = fileContentTitle - } else { - note.Title = filenameTitle + note.Title = strings.ReplaceAll(m[1], "-", " ") } if m := regexp.MustCompile(`__(.+?)(?:\.|$)`).FindStringSubmatch(fname); m != nil { @@ -166,35 +154,32 @@ func ExtractMetadata(path string) *Metadata { return note } -func extractTitle(path string) string { - ext := strings.ToLower(filepath.Ext(strings.TrimSuffix(path, ".gpg"))) +// 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 "" } - data, err := os.ReadFile(path) - if err != nil { - return "" - } - text := string(data) - // Try org-mode #+title: first, then fall back to first heading if ext == ".org" { - if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(text); m != nil { + 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(text); m != nil { + 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(text); m != nil { + 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(text); m != nil { + if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(content); m != nil { return strings.TrimSpace(m[1]) } } diff --git a/internal/p9/client/client.go b/internal/p9/client/client.go index 8dc5e59..73826b0 100644 --- a/internal/p9/client/client.go +++ b/internal/p9/client/client.go @@ -1,8 +1,8 @@ -// Package client provides 9P client helpers for connecting to the denote server. +// Package client provides pure 9P client I/O operations for the denote server. +// This package contains NO business logic - only read/write primitives. package client import ( - "denote/internal/metadata" "fmt" "io" "strings" @@ -79,65 +79,3 @@ func ReadFields(f *client.Fsys, identifier string, fields ...string) (map[string return result, nil } -func ReadIndex() (metadata.Results, error) { - var results metadata.Results - - err := With9P(func(f *client.Fsys) error { - indexContent, err := ReadFile(f, "index") - if err != nil { - return fmt.Errorf("failed to read index: %w", err) - } - - results, err = results.FromString(indexContent) - if err != nil { - return err - } - - // Populate Path for each note - for _, note := range results { - fields, err := ReadFields(f, note.Identifier, "path") - if err != nil { - return fmt.Errorf("failed to read fields for %s: %w", note.Identifier, err) - } - note.Path = fields["path"] - } - - return nil - }) - - if err != nil { - return nil, err - } - - return results, nil -} - -func SetFilter(filterQuery string) error { - return With9P(func(f *client.Fsys) error { - var cmd string - if filterQuery == "" { - cmd = "filter" - } else { - cmd = "filter " + filterQuery - } - return WriteFile(f, "ctl", cmd) - }) -} - -func IdentifierToPath(identifier string) (string, error) { - if err := SetFilter(fmt.Sprintf("date:%s", identifier)); err != nil { - return "", fmt.Errorf("failed to set filter: %w", err) - } - defer SetFilter("") - - notes, err := ReadIndex() - if err != nil { - return "", fmt.Errorf("failed to read index: %w", err) - } - - if len(notes) == 0 { - return "", fmt.Errorf("no note found with identifier %s", identifier) - } - - return notes[0].Path, nil -} \ No newline at end of file diff --git a/internal/p9/server/fs.go b/internal/p9/server/fs.go index e0e5a29..7b8b00c 100644 --- a/internal/p9/server/fs.go +++ b/internal/p9/server/fs.go @@ -35,7 +35,6 @@ import ( "io" "net" "os" - "path/filepath" "regexp" "strings" "sync" @@ -77,7 +76,6 @@ const ( var fileNames = []string{"path", "title", "keywords", "ctl"} type server struct { - dir string notes metadata.Results mu sync.RWMutex eventChan chan string @@ -127,58 +125,15 @@ func emitEvent(id string, op rune) { // Getdir returns the denote directory -func getdir() string { - return fmt.Sprintf("%s/doc", os.Getenv("HOME")) -} - -// loadData returns metadata for notes in a directory matching given filters -func loadData(filters []*metadata.Filter) (metadata.Results, error) { - var notes metadata.Results - err := filepath.Walk(getdir(), func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - if note := metadata.ExtractMetadata(path); note.Identifier != "" { - match := true - for _, filt := range filters { - if !filt.IsMatch(note) { - match = false - break - } - } - if match { - notes = append(notes, note) - } - } - return nil - }) - if err != nil { - return nil, err - } - - return notes, nil -} - - - -// StartServer starts the 9P fileserver in the background -func StartServer() error { +// StartServer starts the 9P fileserver in the background with pre-loaded metadata. +// initialData should contain all notes to be served - typically loaded by sync.LoadAll(). +func StartServer(initialData metadata.Results) error { if srv != nil { return fmt.Errorf("server already running") } - // Load all notes - notes, err := loadData([]*metadata.Filter{}) - if err != nil { - return fmt.Errorf("failed to load notes: %w", err) - } - srv = &server{ - dir: getdir(), - notes: notes, + notes: initialData, eventChan: make(chan string, 100), eventSubs: make(map[uint64]chan string), } @@ -669,6 +624,13 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { break } } + // Also remove from filtered notes + for i, n := range s.filteredNotes { + if n.Identifier == noteID { + s.filteredNotes = append(s.filteredNotes[:i], s.filteredNotes[i+1:]...) + break + } + } s.mu.Unlock() } diff --git a/internal/sync/loader.go b/internal/sync/loader.go new file mode 100644 index 0000000..9f3fd56 --- /dev/null +++ b/internal/sync/loader.go @@ -0,0 +1,115 @@ +package sync + +import ( + "denote/internal/metadata" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SupportsFrontMatter returns true if the file extension supports frontmatter. +func SupportsFrontMatter(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".org" || ext == ".md" || ext == ".txt" +} + +// 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) { + // Parse filename (no I/O) + note := metadata.ParseFilename(path) + + // Check if we should try to read file content for title + ext := strings.ToLower(filepath.Ext(path)) + // Don't read encrypted files or unsupported types + if ext == ".gpg" || (ext != ".org" && ext != ".md" && ext != ".txt") { + return note, nil + } + + // Read file content + content, err := os.ReadFile(path) + if err != nil { + // If we can't read the file, just use filename metadata + return note, nil + } + + // Try to extract title from content + if title := metadata.ExtractTitleFromContent(string(content), ext); title != "" { + note.Title = title + } + + return note, nil +} + +// ExtractFrontMatter reads a file and parses its front matter. +func ExtractFrontMatter(path string) (*metadata.FrontMatter, error) { + ext := strings.ToLower(filepath.Ext(path)) + + // Don't try to parse front matter from encrypted files + if ext == ".gpg" { + return &metadata.FrontMatter{}, nil + } + + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return metadata.ParseFrontMatter(string(content), ext) +} + +// UpdateFrontMatter updates the front matter in a file. +func UpdateFrontMatter(path string, fm *metadata.FrontMatter) error { + // Only update frontmatter for supported file types + if !SupportsFrontMatter(path) { + return nil + } + + // Read current content + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + // Update front matter (pure function) + newContent, err := metadata.UpdateFrontMatter(string(content), fm) + if err != nil { + return fmt.Errorf("failed to update front matter: %w", err) + } + + // Write back to file + return os.WriteFile(path, []byte(newContent), 0644) +} + +// LoadAll walks a directory and extracts metadata from all denote files. +func LoadAll(dir string) (metadata.Results, error) { + var notes metadata.Results + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + note, err := ExtractMetadata(path) + if err != nil { + return err + } + + // Only include files with valid identifiers + if note.Identifier != "" { + notes = append(notes, note) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + return notes, nil +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 431181f..38e55d3 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -61,9 +61,11 @@ func WatchAcmeLog() { exists := noteExists(identifier) if exists { - // Existing note: sync front matter (triggers 'u' event) - if err := syncFrontMatter(ev.Name, identifier); err != nil { - log.Printf("sync: failed to sync %s: %v", ev.Name, err) + // Existing note: sync front matter if supported (triggers 'u' event) + if SupportsFrontMatter(ev.Name) { + if err := syncFrontMatter(ev.Name, identifier); err != nil { + log.Printf("sync: failed to sync %s: %v", ev.Name, err) + } } } else { // New note: register with 9P (triggers 'n' event) @@ -111,7 +113,10 @@ func noteExists(identifier string) bool { // registerNewNote writes to /new to register a new note (triggers 'n' event) func registerNewNote(path string) error { - meta := metadata.ExtractMetadata(path) + meta, err := ExtractMetadata(path) + if err != nil { + return err + } // Format as 'title' tag1,tag2 var newInput string @@ -129,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 := metadata.Extract(path) + fm, err := ExtractFrontMatter(path) if err != nil { return err } @@ -201,7 +206,7 @@ func SyncAll() error { continue } - // Read path and extension + // Read path pathFid, err := f.Open("n/"+identifier+"/path", 0) if err != nil { log.Printf("sync: failed to read path for %s: %v", identifier, err) @@ -212,18 +217,17 @@ func SyncAll() error { pathFid.Close() path := strings.TrimSpace(string(pathBuf[:n])) - extFid, err := f.Open("n/"+identifier+"/extension", 0) - if err != nil { - log.Printf("sync: failed to read extension for %s: %v", identifier, err) + // Check if file exists on filesystem + if _, err := os.Stat(path); os.IsNotExist(err) { + // File was deleted, emit 'd' event to clean up metadata + if err := p9client.WriteFile(f, "n/"+identifier+"/ctl", "d"); err != nil { + log.Printf("sync: failed to emit delete event for %s: %v", identifier, err) + } continue } - extBuf := make([]byte, 8192) - n, _ = extFid.Read(extBuf) - extFid.Close() - ext := strings.TrimSpace(string(extBuf[:n])) - // Route to appropriate sync function - if ext == ".org" || ext == ".md" || ext == ".txt" { + // Route to appropriate sync function based on file type + if SupportsFrontMatter(path) { if err := syncDenoteFile(f, path, identifier); err != nil { log.Printf("sync: failed to sync denote file %s: %v", identifier, err) } @@ -241,7 +245,7 @@ func SyncAll() error { // syncDenoteFile syncs a text file with front matter func syncDenoteFile(f *client.Fsys, path, identifier string) error { // Extract front matter from file - fm, err := metadata.Extract(path) + fm, err := ExtractFrontMatter(path) if err != nil { return err } @@ -271,7 +275,7 @@ func syncDenoteFile(f *client.Fsys, path, identifier string) error { // syncNonDenoteFile syncs a binary file (metadata from filename) func syncNonDenoteFile(f *client.Fsys, path, identifier string) error { // Extract metadata from filename - meta := metadata.ExtractMetadata(path) + meta := metadata.ParseFilename(path) // Write title (triggers 'u' event) titlePath := "n/" + identifier + "/title" diff --git a/main.go b/main.go index 64a64b1..98f0d7f 100644 --- a/main.go +++ b/main.go @@ -77,6 +77,42 @@ func renameNote(oldPath, title string, keywords []string, identifier string) (st return newPath, nil } +// Helper functions for 9P operations + +// readIndex reads and parses the index from 9P server. +func readIndex(f *client.Fsys) (metadata.Results, error) { + indexContent, err := p9client.ReadFile(f, "index") + if err != nil { + return nil, fmt.Errorf("failed to read index: %w", err) + } + + var results metadata.Results + results, err = results.FromString(indexContent) + if err != nil { + return nil, err + } + + // Populate Path for each note + for _, note := range results { + 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) + } + note.Path = path + } + + return results, nil +} + +// setFilter sets or clears the filter on the 9P server. +func setFilter(f *client.Fsys, filterQuery string) error { + cmd := "filter" + if filterQuery != "" { + cmd = "filter " + filterQuery + } + return p9client.WriteFile(f, "ctl", cmd) +} + // Event handling func eventListener() { @@ -121,6 +157,10 @@ func eventListener() { if err := handleNewEvent(f, identifier); err != nil { log.Printf("error handling new for %s: %v", identifier, err) } + case "d": + if err := handleDeleteEvent(identifier); err != nil { + log.Printf("error handling delete for %s: %v", identifier, err) + } } } }) @@ -133,7 +173,7 @@ func eventListener() { } func handleUpdateEvent(f *client.Fsys, identifier string) error { - fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "path", "extension") + fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "path") if err != nil { return err } @@ -141,7 +181,6 @@ func handleUpdateEvent(f *client.Fsys, identifier string) error { title := fields["title"] keywords := fields["keywords"] path := fields["path"] - ext := fields["extension"] var tags []string if keywords != "" { @@ -150,9 +189,8 @@ func handleUpdateEvent(f *client.Fsys, identifier string) error { } } - isDenoteFile := ext == ".org" || ext == ".md" || ext == ".txt" - - if isDenoteFile { + if sync.SupportsFrontMatter(path) { + ext := strings.ToLower(filepath.Ext(path)) var fileType string switch ext { case ".org": @@ -170,12 +208,13 @@ func handleUpdateEvent(f *client.Fsys, identifier string) error { FileType: fileType, } - if err := metadata.Update(path, fm); err != nil { + if err := sync.UpdateFrontMatter(path, fm); err != nil { log.Printf("failed to update front matter for %s: %v", identifier, err) } } // After updating content, check if a rename is needed + ext := strings.ToLower(filepath.Ext(strings.TrimSuffix(path, ".gpg"))) dir := filepath.Dir(path) if dir == "." { dir = denoteDir @@ -284,13 +323,42 @@ func main() { args := os.Args[1:] if len(args) == 1 { if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok { - path, err := p9client.IdentifierToPath(identifier) + // Find note by identifier using 9P + var notePath string + err := p9client.With9P(func(f *client.Fsys) error { + // Set filter to find the identifier + if err := p9client.WriteFile(f, "ctl", "filter date:"+identifier); err != nil { + return err + } + defer p9client.WriteFile(f, "ctl", "filter") // Clear filter + + // Read index + indexContent, err := p9client.ReadFile(f, "index") + if err != nil { + return err + } + + // Parse index + var results metadata.Results + results, err = results.FromString(indexContent) + if err != nil { + return err + } + + if len(results) == 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") + return err + }) if err != nil { fmt.Fprintf(os.Stderr, "error finding note: %v\n", err) os.Exit(1) } - fmt.Println(path) - if err := exec.Command("plumb", path).Run(); err != nil { + fmt.Println(notePath) + if err := exec.Command("plumb", notePath).Run(); err != nil { log.Printf("failed to plumb identifier: %v", err) } return @@ -303,8 +371,14 @@ func main() { } } - // 9p server startup - if err := p9server.StartServer(); err != nil { + // Load metadata from filesystem + notes, err := sync.LoadAll(denoteDir) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to load notes: %v\n", err) + } + + // 9p server startup with pre-loaded data + if err := p9server.StartServer(notes); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to start fileserver: %v\n", err) } @@ -332,10 +406,15 @@ func main() { } // get initial results (clear any filter, read index) - if err := p9client.SetFilter(""); err != nil { - panic(err) - } - rs, err := p9client.ReadIndex() + var rs metadata.Results + err = p9client.With9P(func(f *client.Fsys) error { + if err := setFilter(f, ""); err != nil { + return err + } + var err error + rs, err = readIndex(f) + return err + }) if err != nil { panic(err) } @@ -505,11 +584,15 @@ func main() { // Get current results and apply changes // Read unfiltered index - if err := p9client.SetFilter(""); err != nil { - log.Printf("failed to clear filter: %v", err) - break - } - current, err := p9client.ReadIndex() + var current metadata.Results + err = p9client.With9P(func(f *client.Fsys) error { + if err := setFilter(f, ""); err != nil { + return err + } + var err error + current, err = readIndex(f) + return err + }) if err != nil { log.Printf("failed to get current results: %v", err) break @@ -577,14 +660,16 @@ func performSearch(w *acme.Win, searchText string) { // Build filter query string for ctl filterQuery := strings.Join(filterArgs, " ") - // Set filter on server - if err := p9client.SetFilter(filterQuery); err != nil { - log.Printf("failed to set filter: %v", err) - return - } - - // Read filtered index - rs, err := p9client.ReadIndex() + // Set filter and read index + var rs metadata.Results + err := p9client.With9P(func(f *client.Fsys) error { + if err := setFilter(f, filterQuery); err != nil { + return err + } + var err error + rs, err = readIndex(f) + return err + }) if err != nil { log.Printf("search error: %v", err) return @@ -603,12 +688,15 @@ func refreshWindow(w *acme.Win, rs metadata.Results) { } func refreshWindowWithDefaults(w *acme.Win) { - // Clear filter and read index - if err := p9client.SetFilter(""); err != nil { - log.Printf("error clearing filter: %v", err) - return - } - rs, err := p9client.ReadIndex() + var rs metadata.Results + err := p9client.With9P(func(f *client.Fsys) error { + if err := setFilter(f, ""); err != nil { + return err + } + var err error + rs, err = readIndex(f) + return err + }) if err != nil { log.Printf("error refreshing: %v", err) return @@ -728,7 +816,7 @@ func watchNoteWindow(win *acme.Win, path string) { win.WriteEvent(e) encryptedPath := path + ".gpg" - meta := metadata.ExtractMetadata(encryptedPath) + meta := metadata.ParseFilename(encryptedPath) var newInput string if len(meta.Tags) > 0 {