continue refactoring

This commit is contained in:
Levi Neely 2025-11-22 15:26:57 +01:00
parent ca31e9e1d4
commit 6aaf6cfa91
5 changed files with 248 additions and 236 deletions

View File

@ -1,4 +1,4 @@
package tmpl
package metadata
import (
"fmt"

View File

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

View File

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

View File

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

46
main.go
View File

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