diff --git a/internal/tmpl/tmpl.go b/internal/metadata/frontmatter.go similarity index 99% rename from internal/tmpl/tmpl.go rename to internal/metadata/frontmatter.go index 3f4e20e..ccc299e 100644 --- a/internal/tmpl/tmpl.go +++ b/internal/metadata/frontmatter.go @@ -1,4 +1,4 @@ -package tmpl +package metadata import ( "fmt" diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..fbc6a18 --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,205 @@ +package metadata + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Metadata is the metadata encoded into Denote-style +// file names. +type Metadata struct { + Path string + Identifier string + Title string + Tags []string + Extension string +} + +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 unicode letters and digits, no spaces + tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\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) + } + + if strings.Contains(title, "|") { + return nil, fmt.Errorf("line %d: title cannot contain '|'", 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 +} + +func SlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +type SortBy string + +const ( + SortById SortBy = "id" + SortByDate SortBy = "date" + SortByTitle SortBy = "title" +) + +type SortOrder int + +const ( + SortOrderAsc SortOrder = iota + SortOrderDesc +) + +// Sort organizes a list of notes by sortType and order using metadata. +func Sort(md Results, sortType SortBy, order SortOrder) { + switch sortType { + case SortById, SortByDate: + sort.Slice(md, func(i, j int) bool { + if order == SortOrderAsc { + return md[i].Identifier < md[j].Identifier // Reverse chronological by default + } else { + return md[i].Identifier > md[j].Identifier // Reverse chronological by default + } + }) + case SortByTitle: + sort.Slice(md, func(i, j int) bool { + if order == SortOrderAsc { + return strings.ToLower(md[i].Title) < strings.ToLower(md[j].Title) + } else { + return strings.ToLower(md[i].Title) > strings.ToLower(md[j].Title) + } + }) + default: + sort.Slice(md, func(i, j int) bool { + return md[i].Identifier > md[j].Identifier // Reverse chronological by default + }) + } +} + +// ExtractMetadata extracts Denote metadata from a filename +func ExtractMetadata(path string) *Metadata { + fname := filepath.Base(path) + note := &Metadata{Path: path} + + if m := regexp.MustCompile(`^(\d{8}T\d{6})`).FindStringSubmatch(fname); m != nil { + note.Identifier = m[1] + } + + // 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 + } + + if m := regexp.MustCompile(`__(.+?)(?:\.|$)`).FindStringSubmatch(fname); m != nil { + note.Tags = strings.Split(m[1], "_") + } + note.Extension = filepath.Ext(fname) + + return note +} + +func extractTitle(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + 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 { + return strings.TrimSpace(m[1]) + } + // Fallback to first heading (lines starting with *) + if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(text); 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 { + return strings.TrimSpace(strings.Trim(m[1], `"`)) + } + if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(text); m != nil { + return strings.TrimSpace(m[1]) + } + } + + return "" +} diff --git a/internal/p9/server/fs.go b/internal/p9/server/fs.go index 98b6942..0dc07ba 100644 --- a/internal/p9/server/fs.go +++ b/internal/p9/server/fs.go @@ -31,6 +31,7 @@ Notes: package fs import ( + "denote/internal/metadata" "fmt" "io" "net" @@ -38,7 +39,6 @@ import ( "path/filepath" "regexp" "slices" - "sort" "strings" "sync" "time" @@ -47,92 +47,6 @@ import ( "9fans.net/go/plan9/client" ) -// Metadata is the metadata encoded into Denote-style -// file names. -type Metadata struct { - Path string - Identifier string - Title string - Tags []string - Extension string -} -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 unicode letters and digits, no spaces - tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\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) - } - - if strings.Contains(title, "|") { - return nil, fmt.Errorf("line %d: title cannot contain '|'", 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 -} - -func SlicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - // Filter matches a given field in a Result to a regular expression type Filter struct { field FilterField @@ -207,7 +121,7 @@ func NewFilter(arg string) (*Filter, error) { } // IsMatch checks if a note matches this filter -func (f *Filter) IsMatch(n *Metadata) bool { +func (f *Filter) IsMatch(n *metadata.Metadata) bool { result := false switch f.field { case FilterDate: @@ -237,46 +151,6 @@ func (f *Filter) IsMatch(n *Metadata) bool { return result } -type SortBy string - -const ( - SortById SortBy = "id" - SortByDate SortBy = "date" - SortByTitle SortBy = "title" -) - -type SortOrder int - -const ( - SortOrderAsc SortOrder = iota - SortOrderDesc -) - -// Sort organizes a list of notes by sortType and order using metadata. -func Sort(md Results, sortType SortBy, order SortOrder) { - switch sortType { - case SortById, SortByDate: - sort.Slice(md, func(i, j int) bool { - if order == SortOrderAsc { - return md[i].Identifier < md[j].Identifier // Reverse chronological by default - } else { - return md[i].Identifier > md[j].Identifier // Reverse chronological by default - } - }) - case SortByTitle: - sort.Slice(md, func(i, j int) bool { - if order == SortOrderAsc { - return strings.ToLower(md[i].Title) < strings.ToLower(md[j].Title) - } else { - return strings.ToLower(md[i].Title) > strings.ToLower(md[j].Title) - } - }) - default: - sort.Slice(md, func(i, j int) bool { - return md[i].Identifier > md[j].Identifier // Reverse chronological by default - }) - } -} // File types in our filesystem const ( @@ -312,7 +186,7 @@ var fileNames = []string{"path", "title", "keywords", "extension", "ctl"} type server struct { dir string - notes Results + notes metadata.Results mu sync.RWMutex eventChan chan string eventSubs map[uint64]chan string @@ -320,7 +194,7 @@ type server struct { nextSubID uint64 nextSubIDMu sync.Mutex filterQuery string - filteredNotes []*Metadata + filteredNotes []*metadata.Metadata } type connState struct { @@ -339,7 +213,7 @@ type fid struct { var srv *server // findNote finds a note by identifier in the in-memory collection -func findNote(identifier string) (*Metadata, error) { +func findNote(identifier string) (*metadata.Metadata, error) { srv.mu.RLock() defer srv.mu.RUnlock() for _, n := range srv.notes { @@ -359,72 +233,6 @@ func emitEvent(id string, op rune) { } } -// ExtractMetadata extracts Denote metadata from a filename -func ExtractMetadata(path string) *Metadata { - fname := filepath.Base(path) - note := &Metadata{Path: path} - - if m := regexp.MustCompile(`^(\d{8}T\d{6})`).FindStringSubmatch(fname); m != nil { - note.Identifier = m[1] - } - - // 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 - } - - if m := regexp.MustCompile(`__(.+?)(?:\.|$)`).FindStringSubmatch(fname); m != nil { - note.Tags = strings.Split(m[1], "_") - } - note.Extension = filepath.Ext(fname) - - return note -} - -func extractTitle(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - 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 { - return strings.TrimSpace(m[1]) - } - // Fallback to first heading (lines starting with *) - if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(text); 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 { - return strings.TrimSpace(strings.Trim(m[1], `"`)) - } - if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(text); m != nil { - return strings.TrimSpace(m[1]) - } - } - - return "" -} // Getdir returns the denote directory func getdir() string { @@ -432,8 +240,8 @@ func getdir() string { } // loadData returns metadata for notes in a directory matching given filters -func loadData(filters []*Filter) (Results, error) { - var notes Results +func loadData(filters []*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 @@ -441,7 +249,7 @@ func loadData(filters []*Filter) (Results, error) { if info.IsDir() { return nil } - if note := ExtractMetadata(path); note.Identifier != "" { + if note := metadata.ExtractMetadata(path); note.Identifier != "" { match := true for _, filt := range filters { if !filt.IsMatch(note) { @@ -462,7 +270,7 @@ func loadData(filters []*Filter) (Results, error) { return notes, nil } -func AddNote(note *Metadata) error { +func AddNote(note *metadata.Metadata) error { if srv == nil { return fmt.Errorf("server not running") } @@ -474,7 +282,7 @@ func AddNote(note *Metadata) error { return nil } -func GetNote(identifier string) (*Metadata, error) { +func GetNote(identifier string) (*metadata.Metadata, error) { if srv == nil { return nil, fmt.Errorf("server not running") } @@ -933,7 +741,7 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // Create metadata entry with temporary path // (actual file will be created by denote package event handler) - metadata := &Metadata{ + meta := &metadata.Metadata{ Identifier: identifier, Title: title, Tags: tags, @@ -943,7 +751,7 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // Add to in-memory state s.mu.Lock() - s.notes = append(s.notes, metadata) + s.notes = append(s.notes, meta) s.mu.Unlock() // Emit event notification @@ -1249,7 +1057,7 @@ func (s *server) getIndexContent() string { } // Return filtered notes - var filtered Results + var filtered metadata.Results for _, note := range s.filteredNotes { filtered = append(filtered, note) } @@ -1334,7 +1142,7 @@ func (s *server) handleFilterCommand(fc *plan9.Fcall, command string) *plan9.Fca } // Apply filters to notes - var filtered []*Metadata + var filtered []*metadata.Metadata for _, note := range s.notes { match := true for _, filt := range filters { diff --git a/internal/sync/sync.go b/internal/sync/sync.go index f668164..431181f 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -7,9 +7,8 @@ the file name. package sync import ( + "denote/internal/metadata" p9client "denote/internal/p9/client" - p9server "denote/internal/p9/server" - "denote/internal/tmpl" "fmt" "log" "os" @@ -112,7 +111,7 @@ func noteExists(identifier string) bool { // registerNewNote writes to /new to register a new note (triggers 'n' event) func registerNewNote(path string) error { - meta := p9server.ExtractMetadata(path) + meta := metadata.ExtractMetadata(path) // Format as 'title' tag1,tag2 var newInput string @@ -130,7 +129,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 := tmpl.Extract(path) + fm, err := metadata.Extract(path) if err != nil { return err } @@ -242,7 +241,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 := tmpl.Extract(path) + fm, err := metadata.Extract(path) if err != nil { return err } @@ -272,7 +271,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 := p9server.ExtractMetadata(path) + meta := metadata.ExtractMetadata(path) // Write title (triggers 'u' event) titlePath := "n/" + identifier + "/title" diff --git a/main.go b/main.go index 785c8eb..a8bccff 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,10 @@ package main import ( + "denote/internal/metadata" p9client "denote/internal/p9/client" p9server "denote/internal/p9/server" "denote/internal/sync" - "denote/internal/tmpl" "fmt" "io" "log" @@ -54,10 +54,10 @@ func buildDenoteFilename(identifier, title string, keywords []string, ext string func generateNotePath(dir, title string, keywords []string, fileType string) (string, string) { identifier := generateIdentifier() - ext := tmpl.FileExtensions[fileType] + ext := metadata.FileExtensions[fileType] filename := buildDenoteFilename(identifier, title, keywords, ext) path := filepath.Join(dir, filename) - content := tmpl.Generate(title, keywords, fileType, identifier) + content := metadata.Generate(title, keywords, fileType, identifier) return path, content } @@ -114,8 +114,8 @@ func read9PFields(f *client.Fsys, identifier string, fields ...string) (map[stri return result, nil } -func readIndex() (p9server.Results, error) { - var results p9server.Results +func readIndex() (metadata.Results, error) { + var results metadata.Results err := p9client.With9P(func(f *client.Fsys) error { indexContent, err := read9PFile(f, "index") @@ -268,14 +268,14 @@ func handleUpdateEvent(f *client.Fsys, identifier string) error { fileType = "txt" } - fm := &tmpl.FrontMatter{ + fm := &metadata.FrontMatter{ Title: title, Tags: tags, Identifier: identifier, FileType: fileType, } - if err := tmpl.Update(path, fm); err != nil { + if err := metadata.Update(path, fm); err != nil { log.Printf("failed to update front matter for %s: %v", identifier, err) } } @@ -418,7 +418,7 @@ func main() { if err != nil { panic(err) } - p9server.Sort(rs, p9server.SortById, p9server.SortOrderDesc) + metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc) refreshWindow(w, rs) // listen for 'n' events to refresh window @@ -575,7 +575,7 @@ func main() { } // Parse the window content - var emptyResults p9server.Results + var emptyResults metadata.Results updated, err := emptyResults.FromString(string(body)) if err != nil { log.Printf("failed to parse window: %v", err) @@ -624,8 +624,8 @@ func performSearch(w *acme.Win, searchText string) { args := parseArgs(searchText) var filterArgs []string - sortBy := p9server.SortById - sortOrder := p9server.SortOrderDesc + sortBy := metadata.SortById + sortOrder := metadata.SortOrderDesc for _, arg := range args { if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok { @@ -633,20 +633,20 @@ func performSearch(w *acme.Win, searchText string) { switch parts[0] { case "id", "date": - sortBy = p9server.SortById + sortBy = metadata.SortById case "title": - sortBy = p9server.SortByTitle + sortBy = metadata.SortByTitle } if len(parts) > 1 { switch parts[1] { case "asc": - sortOrder = p9server.SortOrderAsc + sortOrder = metadata.SortOrderAsc case "desc": - sortOrder = p9server.SortOrderDesc + sortOrder = metadata.SortOrderDesc } } else { - sortOrder = p9server.SortOrderAsc + sortOrder = metadata.SortOrderAsc } } else { filterArgs = append(filterArgs, arg) @@ -668,12 +668,12 @@ func performSearch(w *acme.Win, searchText string) { log.Printf("search error: %v", err) return } - p9server.Sort(rs, sortBy, sortOrder) + metadata.Sort(rs, sortBy, sortOrder) refreshWindow(w, rs) } -func refreshWindow(w *acme.Win, rs p9server.Results) { +func refreshWindow(w *acme.Win, rs metadata.Results) { w.Addr(",") w.Write("data", rs.Bytes()) w.Addr("#0") @@ -692,7 +692,7 @@ func refreshWindowWithDefaults(w *acme.Win) { log.Printf("error refreshing: %v", err) return } - p9server.Sort(rs, p9server.SortById, p9server.SortOrderDesc) + metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc) refreshWindow(w, rs) } @@ -749,14 +749,14 @@ func parseArgs(s string) []string { return args } -func applyIndexChanges(f *client.Fsys, current, updated p9server.Results) error { +func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error { // Validate entry count matches if len(updated) != len(current) { return fmt.Errorf("entry count mismatch: got %d, expected %d", len(updated), len(current)) } // Build map of current state - currentMap := make(map[string]*p9server.Metadata) + currentMap := make(map[string]*metadata.Metadata) for _, m := range current { currentMap[m.Identifier] = m } @@ -770,7 +770,7 @@ func applyIndexChanges(f *client.Fsys, current, updated p9server.Results) error // Check if anything changed titleChanged := orig.Title != upd.Title - tagsChanged := !p9server.SlicesEqual(orig.Tags, upd.Tags) + tagsChanged := !metadata.SlicesEqual(orig.Tags, upd.Tags) if titleChanged || tagsChanged { // Write both title and keywords @@ -807,7 +807,7 @@ func watchNoteWindow(win *acme.Win, path string) { win.WriteEvent(e) encryptedPath := path + ".gpg" - meta := p9server.ExtractMetadata(encryptedPath) + meta := metadata.ExtractMetadata(encryptedPath) var newInput string if len(meta.Tags) > 0 {