architectural cleanup, fix frontmatter bugs

This commit is contained in:
Levi Neely 2025-11-23 19:10:08 +01:00
parent c4187f54f9
commit 603cd31002
7 changed files with 297 additions and 213 deletions

View File

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

View File

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

View File

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

View File

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

115
internal/sync/loader.go Normal file
View File

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

View File

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

158
main.go
View File

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