refactor: denote->main.go, filter via ctl file

This commit is contained in:
Levi Neely 2025-11-22 14:44:11 +01:00
parent d8b7af94ba
commit 5365c19720
5 changed files with 829 additions and 672 deletions

View File

@ -1,639 +0,0 @@
package denote
import (
"denote/internal/fs"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
)
// GetDir returns the denote directory path
func GetDir() string {
return fmt.Sprintf("%s/doc", os.Getenv("HOME"))
}
// readFile reads entire content from a 9P file
func readFile(f *client.Fsys, path string) (string, error) {
fid, err := f.Open(path, plan9.OREAD)
if err != nil {
return "", err
}
defer fid.Close()
var content []byte
buf := make([]byte, 8192)
for {
n, err := fid.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
if n == 0 {
break
}
content = append(content, buf[:n]...)
}
return strings.TrimSpace(string(content)), nil
}
// read9PFields reads multiple 9P fields for a note
func read9PFields(f *client.Fsys, identifier string, fields ...string) (map[string]string, error) {
result := make(map[string]string)
for _, field := range fields {
val, err := readFile(f, "n/"+identifier+"/"+field)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", field, err)
}
result[field] = val
}
return result, nil
}
// Search returns metadata for notes by reading from the 9P index
func Search(filters []*fs.Filter) (fs.Results, error) {
var results fs.Results
err := fs.With9P(func(f *client.Fsys) error {
// Read index to get identifiers
indexContent, err := readFile(f, "index")
if err != nil {
return fmt.Errorf("failed to read index: %w", err)
}
lines := strings.Split(indexContent, "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 1 {
continue
}
identifier := strings.TrimSpace(parts[0])
meta := &fs.Metadata{
Identifier: identifier,
}
// Read remaining fields from note directory
if path, err := readFile(f, "n/"+identifier+"/path"); err == nil {
meta.Path = path
}
if ext, err := readFile(f, "n/"+identifier+"/extension"); err == nil {
meta.Extension = ext
}
if keywords, err := readFile(f, "n/"+identifier+"/keywords"); err == nil && keywords != "" {
meta.Tags = strings.Split(keywords, ",")
for i := range meta.Tags {
meta.Tags[i] = strings.TrimSpace(meta.Tags[i])
}
}
// Read title - 9P server already returns file content title if available,
// otherwise filename-based title
if title, err := readFile(f, "n/"+identifier+"/title"); err == nil {
meta.Title = title
}
// Apply filters
match := true
for _, filt := range filters {
if !filt.IsMatch(meta) {
match = false
break
}
}
if match {
results = append(results, meta)
}
}
return nil
})
if err != nil {
return nil, err
}
return results, nil
}
// Templates for denote frontmatter (org, md, txt)
var templates = map[string]string{
"org": `#+title: %s
#+date: %s
#+filetags: %s
#+identifier: %s
`,
"md-yaml": `---
title: %s
date: %s
tags: %s
identifier: %s
---
`,
"md-toml": `+++
title = %s
date = %s
tags = %s
identifier = %s
+++
`,
"txt": `title: %s
date: %s
tags: %s
identifier: %s
---------------------------
`,
}
// contains the list of file extension
//
// for which Denote should add front matter.
var fileExtensions = map[string]string{
"org": ".org",
"md-yaml": ".md",
"md-toml": ".md",
"txt": ".txt",
}
func formatTags(tags []string, fileType string) string {
if len(tags) == 0 {
return ""
}
switch fileType {
case "org":
return ":" + strings.Join(tags, ":") + ":"
case "md-yaml", "md-toml":
return "[" + strings.Join(tags, ", ") + "]"
default:
return strings.Join(tags, " ")
}
}
// FrontMatter represents parsed front matter from a note
type FrontMatter struct {
Title string
Tags []string
Identifier string
FileType string // org, md-yaml, md-toml, txt
}
// ExtractFrontMatter extracts front matter from a file
func ExtractFrontMatter(path string) (*FrontMatter, error) {
ext := strings.ToLower(filepath.Ext(path))
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
text := string(data)
fm := &FrontMatter{}
switch ext {
case ".org":
fm.FileType = "org"
if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^#\+filetags:\s*:(.+):$`).FindStringSubmatch(text); m != nil {
fm.Tags = strings.Split(m[1], ":")
}
if m := regexp.MustCompile(`(?m)^#\+identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
case ".md":
// Try YAML first
yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`)
if m := yamlRe.FindStringSubmatch(text); m != nil {
fm.FileType = "md-yaml"
yamlContent := m[1]
if m := regexp.MustCompile(`(?m)^title:\s*["']?(.+?)["']?$`).FindStringSubmatch(yamlContent); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags:\s*\[(.+?)\]$`).FindStringSubmatch(yamlContent); m != nil {
tags := strings.Split(m[1], ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
fm.Tags = tags
}
if m := regexp.MustCompile(`(?m)^identifier:\s*["']?(.+?)["']?$`).FindStringSubmatch(yamlContent); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
} else {
// Try TOML
tomlRe := regexp.MustCompile(`(?ms)^\+\+\+\n(.*?)\n\+\+\+`)
if m := tomlRe.FindStringSubmatch(text); m != nil {
fm.FileType = "md-toml"
tomlContent := m[1]
if m := regexp.MustCompile(`(?m)^title\s*=\s*["']?(.+?)["']?$`).FindStringSubmatch(tomlContent); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags\s*=\s*\[(.+?)\]$`).FindStringSubmatch(tomlContent); m != nil {
tags := strings.Split(m[1], ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
fm.Tags = tags
}
if m := regexp.MustCompile(`(?m)^identifier\s*=\s*["']?(.+?)["']?$`).FindStringSubmatch(tomlContent); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
}
}
case ".txt":
fm.FileType = "txt"
if m := regexp.MustCompile(`(?m)^title:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Tags = strings.Fields(m[1])
}
if m := regexp.MustCompile(`(?m)^identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
}
return fm, nil
}
// updateFrontMatter updates front matter in a file
func updateFrontMatter(path string, fm *FrontMatter) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
text := string(data)
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
keywordsStr := formatTags(fm.Tags, fm.FileType)
// For org-mode, wrap date in brackets for timestamp
if fm.FileType == "org" {
dateStr = "[" + dateStr + "]"
}
template := templates[fm.FileType]
newFrontMatter := fmt.Sprintf(template, fm.Title, dateStr, keywordsStr, fm.Identifier)
var newText string
switch fm.FileType {
case "org":
// 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
}
}
if endIdx > 0 {
newText = newFrontMatter + strings.Join(lines[endIdx:], "\n")
} else {
newText = newFrontMatter + text
}
case "md-yaml":
// Replace YAML front matter
re := regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
if re.MatchString(text) {
newText = re.ReplaceAllString(text, newFrontMatter)
} else {
newText = newFrontMatter + text
}
case "md-toml":
// Replace TOML front matter
re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n*`)
if re.MatchString(text) {
newText = re.ReplaceAllString(text, newFrontMatter)
} else {
newText = newFrontMatter + text
}
case "txt":
// Replace text front matter
re := regexp.MustCompile(`(?s)^title:.*?\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 os.WriteFile(path, []byte(newText), 0644)
}
// rename renames a note file according to denote convention
func rename(oldPath, title string, keywords []string, identifier string) (string, error) {
if identifier == "" {
identifier = time.Now().Format("20060102T150405")
}
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
var keywordsPart string
if len(keywords) > 0 {
keywordsPart = "__" + strings.Join(keywords, "_")
}
ext := filepath.Ext(oldPath)
dir := filepath.Dir(oldPath)
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
newPath := filepath.Join(dir, filename)
if err := os.Rename(oldPath, newPath); err != nil {
return "", err
}
return newPath, nil
}
// GenerateNoteContent generates filename and frontmatter content without writing to disk
func GenerateNoteContent(dir, title string, keywords []string, fileType, identifier string) (path, content string) {
// Use provided identifier or generate new one
if identifier == "" {
identifier = time.Now().Format("20060102T150405")
}
// Format file name
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
var keywordsPart string
if len(keywords) > 0 {
keywordsPart = "__" + strings.Join(keywords, "_")
}
ext := fileExtensions[fileType]
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
path = filepath.Join(dir, filename)
// Generate front matter
template := templates[fileType]
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
keywordsStr := formatTags(keywords, fileType)
content = fmt.Sprintf(template, title, dateStr, keywordsStr, identifier)
return path, content
}
func NewNote(dir, title string, keywords []string, fileType, identifier string) (string, error) {
path, content := GenerateNoteContent(dir, title, keywords, fileType, identifier)
// Write file
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return "", err
}
return path, nil
}
// Open opens a file using plumb
func Open(filePath string) error {
cmd := exec.Command("plumb", filePath)
return cmd.Run()
}
// IdentifierToPath converts a denote identifier to a file path
func IdentifierToPath(identifier string) (string, error) {
filter, err := fs.NewFilter(fmt.Sprintf("date:%s", identifier))
if err != nil {
return "", fmt.Errorf("invalid identifier: %w", err)
}
notes, err := Search([]*fs.Filter{filter})
if err != nil {
return "", fmt.Errorf("failed to search for note: %w", err)
}
if len(notes) == 0 {
return "", fmt.Errorf("no note found with identifier %s", identifier)
}
return notes[0].Path, nil
}
func EventListener() {
for {
err := fs.With9P(func(f *client.Fsys) error {
// Open event file (blocking reads)
fid, err := f.Open("event", plan9.OREAD)
if err != nil {
return fmt.Errorf("failed to open event file: %w", err)
}
defer fid.Close()
// Read events in loop
buf := make([]byte, 8192)
for {
n, err := fid.Read(buf)
if err != nil {
return fmt.Errorf("failed to read event: %w", err)
}
if n == 0 {
continue
}
event := strings.TrimSpace(string(buf[:n]))
// Parse event: "identifier action"
parts := strings.Fields(event)
if len(parts) != 2 {
log.Printf("invalid event format: %s", event)
continue
}
identifier := parts[0]
action := parts[1]
// Handle events
switch action {
case "u":
if err := handleUpdateEvent(f, identifier); err != nil {
log.Printf("error handling update for %s: %v", identifier, err)
}
case "r":
if err := handleRenameEvent(f, identifier); err != nil {
log.Printf("error handling rename for %s: %v", identifier, err)
}
case "n":
if err := handleNewEvent(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)
}
}
}
})
if err != nil {
log.Printf("event consumer error: %v", err)
time.Sleep(time.Second)
}
}
}
func handleUpdateEvent(f *client.Fsys, identifier string) error {
// Read current metadata via 9P
fields, err := read9PFields(f, identifier, "title", "keywords", "path", "extension")
if err != nil {
return err
}
title := fields["title"]
keywords := fields["keywords"]
path := fields["path"]
ext := fields["extension"]
// Parse tags from keywords
var tags []string
if keywords != "" {
for _, tag := range strings.Split(keywords, ",") {
tags = append(tags, strings.TrimSpace(tag))
}
}
// Check if this is a denote file (has front matter)
isDenoteFile := ext == ".org" || ext == ".md" || ext == ".txt"
if isDenoteFile {
// Determine file type from extension
var fileType string
switch ext {
case ".org":
fileType = "org"
case ".md":
// Need to detect YAML vs TOML - default to YAML
fileType = "md-yaml"
case ".txt":
fileType = "txt"
}
// Update front matter
fm := &FrontMatter{
Title: title,
Tags: tags,
Identifier: identifier,
FileType: fileType,
}
if err := updateFrontMatter(path, fm); err != nil {
log.Printf("failed to update front matter for %s: %v", identifier, err)
}
}
// For non-denote files (PDFs, etc.), metadata is already updated by 9P write
return nil
}
func handleRenameEvent(f *client.Fsys, identifier string) error {
// Read current metadata via 9P
fields, err := read9PFields(f, identifier, "title", "keywords", "path")
if err != nil {
return err
}
title := fields["title"]
keywords := fields["keywords"]
path := fields["path"]
// Parse tags from keywords
var tags []string
if keywords != "" {
for _, tag := range strings.Split(keywords, ",") {
tags = append(tags, strings.TrimSpace(tag))
}
}
// Rename file
newPath, err := rename(path, title, tags, identifier)
if err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
// Update path in in-memory metadata if it changed
if newPath != path {
if err := fs.UpdateNotePath(identifier, newPath); err != nil {
return fmt.Errorf("failed to update path: %w", err)
}
}
return nil
}
func handleNewEvent(identifier string) error {
// Retrieve metadata from in-memory state
metadata, err := fs.GetNote(identifier)
if err != nil {
return fmt.Errorf("failed to get metadata: %w", err)
}
// Get denote directory
denoteDir := GetDir()
// Create new note file using existing NewNote function
path, err := NewNote(denoteDir, metadata.Title, metadata.Tags, "md-yaml", identifier)
if err != nil {
return fmt.Errorf("failed to create note file: %w", err)
}
// Update the path in the metadata
if err := fs.UpdateNotePath(identifier, path); err != nil {
return fmt.Errorf("failed to update path in metadata: %w", err)
}
log.Printf("created new note: %s at %s", identifier, path)
return nil
}
func handleDeleteEvent(identifier string) error {
// Get denote directory
denoteDir := GetDir()
// Find file matching pattern: <identifier>--*
pattern := filepath.Join(denoteDir, identifier+"--*")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("failed to find file: %w", err)
}
if len(matches) == 0 {
return fmt.Errorf("no file found matching identifier: %s", identifier)
}
if len(matches) > 1 {
log.Printf("warning: multiple files match identifier %s, deleting first: %s", identifier, matches[0])
}
// Delete the file
if err := os.Remove(matches[0]); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
log.Printf("deleted note: %s", matches[0])
return nil
}

View File

@ -4,8 +4,9 @@ denote metadata for all files in the denote directory. The filesystem is organiz
follows:
denote/ (directory) Root filesystem
ctl (write-only) Control file (commands: filter <query>)
event (read-only) Global event bus (listen & react to rename, update, and delete events)
index (read-only) Metadata index, supports search functionality
index (read-only) Metadata index (respects active filter)
new (write-only) Create new note (write "'title' tag1,tag2")
n/ (directory) Notes directory
<identifier>/ (directory) Unique denote file identifier
@ -16,7 +17,13 @@ follows:
title (read-write) Denote document title
Notes:
- Messages written to the ctl file may generate events that are broadcast over the global event bus.
- Messages written to denote/ctl affect global state (e.g., filtering)
- Filter command syntax: filter [field:]pattern where field is date|title|tag
- Multiple filters can be specified: filter tag:journal !tag:draft
- Titles with spaces must be quoted: filter title:"My Title"
- Writing 'filter' with no arguments clears the active filter
- Index respects the current filter - returns only matching notes
- Messages written to denote/n/<identifier>/ctl may generate events that are broadcast over the global event bus
- Writing to title or keywords triggers an update ('u') event and a rename ('r') event
- Write 'd' to denote/n/<identifier>/ctl to delete a denote file
- Write "'document title' tag1,tag2,(...)" to denote/new to create a new denote file
@ -170,7 +177,21 @@ func NewFilter(arg string) (*Filter, error) {
return nil, fmt.Errorf("invalid filter syntax: %s", arg)
}
pattern := m[2]
fieldStr := m[1]
value := m[2]
// Strip surrounding quotes (both single and double)
value = strings.Trim(value, `"'`)
// Validate: if field is title and value has spaces, original should have been quoted
if fieldStr == "title" && strings.Contains(value, " ") {
// Check if original value was quoted
if !strings.HasPrefix(m[2], `"`) && !strings.HasPrefix(m[2], `'`) {
return nil, fmt.Errorf("title with spaces must be quoted: %s", arg)
}
}
pattern := value
if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
pattern = strings.TrimPrefix(strings.TrimSuffix(pattern, "/"), "/")
} else {
@ -182,7 +203,7 @@ func NewFilter(arg string) (*Filter, error) {
return nil, fmt.Errorf("invalid regex: %v", err)
}
return &Filter{field: FilterField(m[1]), re: re, negate: negate}, nil
return &Filter{field: FilterField(fieldStr), re: re, negate: negate}, nil
}
// IsMatch checks if a note matches this filter
@ -275,6 +296,7 @@ const (
qidEvent = 999998
qidNew = 999997
qidNDir = 999996
qidCtl = 999995
)
// File types within a note directory
@ -289,14 +311,16 @@ const (
var fileNames = []string{"path", "title", "keywords", "extension", "ctl"}
type server struct {
dir string
notes Results
mu sync.RWMutex
eventChan chan string
eventSubs map[uint64]chan string
eventMu sync.RWMutex
nextSubID uint64
nextSubIDMu sync.Mutex
dir string
notes Results
mu sync.RWMutex
eventChan chan string
eventSubs map[uint64]chan string
eventMu sync.RWMutex
nextSubID uint64
nextSubIDMu sync.Mutex
filterQuery string
filteredNotes []*Metadata
}
type connState struct {
@ -715,6 +739,14 @@ func (s *server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
qids = append(qids, qid)
path = "/new"
found = true
} else if "ctl" == name {
qid := plan9.Qid{
Type: QTFile,
Path: uint64(qidCtl),
}
qids = append(qids, qid)
path = "/ctl"
found = true
} else if "n" == name {
qid := plan9.Qid{
Type: QTDir,
@ -892,6 +924,11 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errorFcall(fc, "file not open for writing")
}
// Handle writes to /ctl
if f.path == "/ctl" {
return s.handleCtlCommand(fc)
}
// Handle writes to /new
if f.path == "/new" {
input := strings.TrimSpace(string(fc.Data))
@ -1102,6 +1139,19 @@ func (s *server) readDir(path string, offset int64, count uint32) []byte {
Muid: "denote",
Length: 0,
})
// add ctl node
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{
Type: QTFile,
Path: uint64(qidCtl),
},
Mode: 0200,
Name: "ctl",
Uid: "denote",
Gid: "denote",
Muid: "denote",
Length: 0,
})
// add n directory
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{
@ -1226,7 +1276,17 @@ func (s *server) pathToNoteIndex(path string) int {
}
func (s *server) getIndexContent() string {
return string(s.notes.Bytes())
if s.filterQuery == "" {
// No filter: return all notes
return string(s.notes.Bytes())
}
// Return filtered notes
var filtered Results
for _, note := range s.filteredNotes {
filtered = append(filtered, note)
}
return string(filtered.Bytes())
}
func (s *server) getFileContent(path string) string {
@ -1267,3 +1327,146 @@ func errorFcall(fc *plan9.Fcall, msg string) *plan9.Fcall {
Ename: msg,
}
}
// handleCtlCommand processes commands written to /ctl
func (s *server) handleCtlCommand(fc *plan9.Fcall) *plan9.Fcall {
command := strings.TrimSpace(string(fc.Data))
// Parse command - must start with a known command word
if strings.HasPrefix(command, "filter") {
return s.handleFilterCommand(fc, command)
}
return errorFcall(fc, fmt.Sprintf("unknown ctl command: %s", command))
}
// handleFilterCommand processes the 'filter' ctl command
func (s *server) handleFilterCommand(fc *plan9.Fcall, command string) *plan9.Fcall {
// Extract filter query after "filter" keyword
query := strings.TrimSpace(strings.TrimPrefix(command, "filter"))
s.mu.Lock()
defer s.mu.Unlock()
if query == "" {
// Clear filter: filter with no arguments
s.filterQuery = ""
s.filteredNotes = nil
return &plan9.Fcall{
Type: plan9.Rwrite,
Tag: fc.Tag,
Count: uint32(len(fc.Data)),
}
}
// Parse filter query into Filter objects
filters, err := parseFilterQuery(query)
if err != nil {
return errorFcall(fc, fmt.Sprintf("invalid filter: %v", err))
}
// Apply filters to notes
var filtered []*Metadata
for _, note := range s.notes {
match := true
for _, filt := range filters {
if !filt.IsMatch(note) {
match = false
break
}
}
if match {
filtered = append(filtered, note)
}
}
// Store filter state
s.filterQuery = query
s.filteredNotes = filtered
return &plan9.Fcall{
Type: plan9.Rwrite,
Tag: fc.Tag,
Count: uint32(len(fc.Data)),
}
}
// parseFilterQuery parses a filter command query into Filter objects
// Handles quoted strings for titles: title:"my title" or title:'my title'
// Multiple filters space-separated: tag:journal !tag:draft
func parseFilterQuery(query string) ([]*Filter, error) {
var filters []*Filter
// Split by spaces while respecting quotes
parts := splitRespectingQuotes(query)
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Use existing NewFilter function
filt, err := NewFilter(part)
if err != nil {
return nil, fmt.Errorf("invalid filter '%s': %w", part, err)
}
filters = append(filters, filt)
}
if len(filters) == 0 {
return nil, fmt.Errorf("no valid filters provided")
}
return filters, nil
}
// splitRespectingQuotes splits a string on spaces but preserves quoted strings
// Handles both single and double quotes
func splitRespectingQuotes(s string) []string {
var result []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(s); i++ {
ch := s[i]
switch ch {
case '"', '\'':
if !inQuote {
// Start quote
inQuote = true
quoteChar = ch
current.WriteByte(ch)
} else if ch == quoteChar {
// End quote (matching)
inQuote = false
quoteChar = 0
current.WriteByte(ch)
} else {
// Different quote char inside quotes
current.WriteByte(ch)
}
case ' ':
if inQuote {
current.WriteByte(ch)
} else {
if current.Len() > 0 {
result = append(result, current.String())
current.Reset()
}
}
default:
current.WriteByte(ch)
}
}
// Add final token
if current.Len() > 0 {
result = append(result, current.String())
}
return result
}

View File

@ -7,10 +7,11 @@ the file name.
package sync
import (
"denote/internal/denote"
"denote/internal/fs"
"denote/internal/tmpl"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
@ -76,7 +77,7 @@ func WatchAcmeLog() {
// isDenoteFile checks if the file is in the denote directory
func isDenoteFile(path string) bool {
denoteDir := denote.GetDir()
denoteDir := os.Getenv("HOME") + "/doc"
absPath, err := filepath.Abs(path)
if err != nil {
return false
@ -128,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 := denote.ExtractFrontMatter(path)
fm, err := tmpl.Extract(path)
if err != nil {
return err
}
@ -240,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 := denote.ExtractFrontMatter(path)
fm, err := tmpl.Extract(path)
if err != nil {
return err
}

240
internal/tmpl/tmpl.go Normal file
View File

@ -0,0 +1,240 @@
package tmpl
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// Templates for denote frontmatter (org, md, txt)
var templates = map[string]string{
"org": `#+title: %s
#+date: %s
#+filetags: %s
#+identifier: %s
`,
"md-yaml": `---
title: %s
date: %s
tags: %s
identifier: %s
---
`,
"md-toml": `+++
title = %s
date = %s
tags = %s
identifier = %s
+++
`,
"txt": `title: %s
date: %s
tags: %s
identifier: %s
---------------------------
`,
}
// FileExtensions contains the list of file extensions
// for which Denote should add front matter.
var FileExtensions = map[string]string{
"org": ".org",
"md-yaml": ".md",
"md-toml": ".md",
"txt": ".txt",
}
// FormatTags formats tags according to file type
func FormatTags(tags []string, fileType string) string {
if len(tags) == 0 {
return ""
}
switch fileType {
case "org":
return ":" + strings.Join(tags, ":") + ":"
case "md-yaml", "md-toml":
return "[" + strings.Join(tags, ", ") + "]"
default:
return strings.Join(tags, " ")
}
}
// FrontMatter represents parsed front matter from a note
type FrontMatter struct {
Title string
Tags []string
Identifier string
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)
fm := &FrontMatter{}
switch ext {
case ".org":
fm.FileType = "org"
if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^#\+filetags:\s*:(.+):$`).FindStringSubmatch(text); m != nil {
fm.Tags = strings.Split(m[1], ":")
}
if m := regexp.MustCompile(`(?m)^#\+identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
case ".md":
// Try YAML first
yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`)
if m := yamlRe.FindStringSubmatch(text); m != nil {
fm.FileType = "md-yaml"
yamlContent := m[1]
if m := regexp.MustCompile(`(?m)^title:\s*["']?(.+?)["']?$`).FindStringSubmatch(yamlContent); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags:\s*\[(.+?)\]$`).FindStringSubmatch(yamlContent); m != nil {
tags := strings.Split(m[1], ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
fm.Tags = tags
}
if m := regexp.MustCompile(`(?m)^identifier:\s*["']?(.+?)["']?$`).FindStringSubmatch(yamlContent); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
} else {
// Try TOML
tomlRe := regexp.MustCompile(`(?ms)^\+\+\+\n(.*?)\n\+\+\+`)
if m := tomlRe.FindStringSubmatch(text); m != nil {
fm.FileType = "md-toml"
tomlContent := m[1]
if m := regexp.MustCompile(`(?m)^title\s*=\s*["']?(.+?)["']?$`).FindStringSubmatch(tomlContent); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags\s*=\s*\[(.+?)\]$`).FindStringSubmatch(tomlContent); m != nil {
tags := strings.Split(m[1], ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
fm.Tags = tags
}
if m := regexp.MustCompile(`(?m)^identifier\s*=\s*["']?(.+?)["']?$`).FindStringSubmatch(tomlContent); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
}
}
case ".txt":
fm.FileType = "txt"
if m := regexp.MustCompile(`(?m)^title:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^tags:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Tags = strings.Fields(m[1])
}
if m := regexp.MustCompile(`(?m)^identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil {
fm.Identifier = strings.TrimSpace(m[1])
}
}
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)
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
keywordsStr := FormatTags(fm.Tags, fm.FileType)
// For org-mode, wrap date in brackets for timestamp
if fm.FileType == "org" {
dateStr = "[" + dateStr + "]"
}
template := templates[fm.FileType]
newFrontMatter := fmt.Sprintf(template, fm.Title, dateStr, keywordsStr, fm.Identifier)
var newText string
switch fm.FileType {
case "org":
// 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
}
}
if endIdx > 0 {
newText = newFrontMatter + strings.Join(lines[endIdx:], "\n")
} else {
newText = newFrontMatter + text
}
case "md-yaml":
// Replace YAML front matter
re := regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
if re.MatchString(text) {
newText = re.ReplaceAllString(text, newFrontMatter)
} else {
newText = newFrontMatter + text
}
case "md-toml":
// Replace TOML front matter
re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n*`)
if re.MatchString(text) {
newText = re.ReplaceAllString(text, newFrontMatter)
} else {
newText = newFrontMatter + text
}
case "txt":
// Replace text front matter
re := regexp.MustCompile(`(?s)^title:.*?\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 os.WriteFile(path, []byte(newText), 0644)
}
// Generate generates front matter content for given parameters
func Generate(title string, tags []string, fileType, identifier string) string {
template := templates[fileType]
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
// For org-mode, wrap date in brackets for timestamp
if fileType == "org" {
dateStr = "[" + dateStr + "]"
}
keywordsStr := FormatTags(tags, fileType)
return fmt.Sprintf(template, title, dateStr, keywordsStr, identifier)
}

384
main.go
View File

@ -1,17 +1,19 @@
package main
import (
"denote/internal/denote"
"denote/internal/fs"
"denote/internal/sync"
"denote/internal/tmpl"
"denote/internal/ui"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"9fans.net/go/acme"
@ -21,21 +23,355 @@ import (
const (
wname = "/Denote/"
ftype = "md-yaml"
)
var denoteDir = os.Getenv("HOME") + "/doc"
// Helper functions for denote filename generation
func generateIdentifier() string {
return time.Now().Format("20060102T150405")
}
func slugifyTitle(title string) string {
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
return regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
}
func formatKeywords(keywords []string) string {
if len(keywords) == 0 {
return ""
}
return "__" + strings.Join(keywords, "_")
}
func buildDenoteFilename(identifier, title string, keywords []string, ext string) string {
titleSlug := slugifyTitle(title)
keywordsPart := formatKeywords(keywords)
return fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
}
func generateNotePath(dir, title string, keywords []string, fileType string) (string, string) {
identifier := generateIdentifier()
ext := tmpl.FileExtensions[fileType]
filename := buildDenoteFilename(identifier, title, keywords, ext)
path := filepath.Join(dir, filename)
content := tmpl.Generate(title, keywords, fileType, identifier)
return path, content
}
func renameNote(oldPath, title string, keywords []string, identifier string) (string, error) {
if identifier == "" {
identifier = generateIdentifier()
}
ext := filepath.Ext(oldPath)
dir := filepath.Dir(oldPath)
filename := buildDenoteFilename(identifier, title, keywords, ext)
newPath := filepath.Join(dir, filename)
if err := os.Rename(oldPath, newPath); err != nil {
return "", err
}
return newPath, nil
}
// 9P client helpers
func read9PFile(f *client.Fsys, path string) (string, error) {
fid, err := f.Open(path, plan9.OREAD)
if err != nil {
return "", err
}
defer fid.Close()
var content []byte
buf := make([]byte, 8192)
for {
n, err := fid.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
if n == 0 {
break
}
content = append(content, buf[:n]...)
}
return strings.TrimSpace(string(content)), nil
}
func read9PFields(f *client.Fsys, identifier string, fields ...string) (map[string]string, error) {
result := make(map[string]string)
for _, field := range fields {
val, err := read9PFile(f, "n/"+identifier+"/"+field)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", field, err)
}
result[field] = val
}
return result, nil
}
func readIndex() (fs.Results, error) {
var results fs.Results
err := fs.With9P(func(f *client.Fsys) error {
indexContent, err := read9PFile(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 and Extension for each note
for _, note := range results {
fields, err := read9PFields(f, note.Identifier, "path", "extension")
if err != nil {
return fmt.Errorf("failed to read fields for %s: %w", note.Identifier, err)
}
note.Path = fields["path"]
note.Extension = fields["extension"]
}
return nil
})
if err != nil {
return nil, err
}
return results, nil
}
func setFilter(filterQuery string) error {
return fs.With9P(func(f *client.Fsys) error {
var cmd string
if filterQuery == "" {
cmd = "filter"
} else {
cmd = "filter " + filterQuery
}
return fs.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
}
// Event handling
func eventListener() {
for {
err := fs.With9P(func(f *client.Fsys) error {
fid, err := f.Open("event", plan9.OREAD)
if err != nil {
return fmt.Errorf("failed to open event file: %w", err)
}
defer fid.Close()
buf := make([]byte, 8192)
for {
n, err := fid.Read(buf)
if err != nil {
return fmt.Errorf("failed to read event: %w", err)
}
if n == 0 {
continue
}
event := strings.TrimSpace(string(buf[:n]))
parts := strings.Fields(event)
if len(parts) != 2 {
log.Printf("invalid event format: %s", event)
continue
}
identifier := parts[0]
action := parts[1]
switch action {
case "u":
if err := handleUpdateEvent(f, identifier); err != nil {
log.Printf("error handling update for %s: %v", identifier, err)
}
case "r":
if err := handleRenameEvent(f, identifier); err != nil {
log.Printf("error handling rename for %s: %v", identifier, err)
}
case "n":
if err := handleNewEvent(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)
}
}
}
})
if err != nil {
log.Printf("event consumer error: %v", err)
time.Sleep(time.Second)
}
}
}
func handleUpdateEvent(f *client.Fsys, identifier string) error {
fields, err := read9PFields(f, identifier, "title", "keywords", "path", "extension")
if err != nil {
return err
}
title := fields["title"]
keywords := fields["keywords"]
path := fields["path"]
ext := fields["extension"]
var tags []string
if keywords != "" {
for _, tag := range strings.Split(keywords, ",") {
tags = append(tags, strings.TrimSpace(tag))
}
}
isDenoteFile := ext == ".org" || ext == ".md" || ext == ".txt"
if isDenoteFile {
var fileType string
switch ext {
case ".org":
fileType = "org"
case ".md":
fileType = "md-yaml"
case ".txt":
fileType = "txt"
}
fm := &tmpl.FrontMatter{
Title: title,
Tags: tags,
Identifier: identifier,
FileType: fileType,
}
if err := tmpl.Update(path, fm); err != nil {
log.Printf("failed to update front matter for %s: %v", identifier, err)
}
}
return nil
}
func handleRenameEvent(f *client.Fsys, identifier string) error {
fields, err := read9PFields(f, identifier, "title", "keywords", "path")
if err != nil {
return err
}
title := fields["title"]
keywords := fields["keywords"]
path := fields["path"]
var tags []string
if keywords != "" {
for _, tag := range strings.Split(keywords, ",") {
tags = append(tags, strings.TrimSpace(tag))
}
}
newPath, err := renameNote(path, title, tags, identifier)
if err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
if newPath != path {
if err := fs.UpdateNotePath(identifier, newPath); err != nil {
return fmt.Errorf("failed to update path: %w", err)
}
}
return nil
}
func handleNewEvent(identifier string) error {
metadata, err := fs.GetNote(identifier)
if err != nil {
return fmt.Errorf("failed to get metadata: %w", err)
}
path, content := generateNotePath(denoteDir, metadata.Title, metadata.Tags, "md-yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create note file: %w", err)
}
if err := fs.UpdateNotePath(identifier, path); err != nil {
return fmt.Errorf("failed to update path in metadata: %w", err)
}
log.Printf("created new note: %s at %s", identifier, path)
return nil
}
func handleDeleteEvent(identifier string) error {
pattern := filepath.Join(denoteDir, identifier+"--*")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("failed to find file: %w", err)
}
if len(matches) == 0 {
return fmt.Errorf("no file found matching identifier: %s", identifier)
}
if len(matches) > 1 {
log.Printf("warning: multiple files match identifier %s, deleting first: %s", identifier, matches[0])
}
if err := os.Remove(matches[0]); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
log.Printf("deleted note: %s", matches[0])
return nil
}
func main() {
var err error
var w *ui.Window
args := os.Args[1:]
if len(args) == 1 {
if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok {
path, err := denote.IdentifierToPath(identifier)
path, err := identifierToPath(identifier)
if err != nil {
fmt.Fprintf(os.Stderr, "error finding note: %v\n", err)
os.Exit(1)
}
fmt.Println(path)
denote.Open(path)
if err := exec.Command("plumb", path).Run(); err != nil {
log.Printf("failed to plumb identifier: %v", err)
}
return
} else {
fmt.Println("Usage:")
@ -52,7 +388,7 @@ func main() {
}
// start event consumer
go denote.EventListener()
go eventListener()
// start acme log watcher
go sync.WatchAcmeLog()
@ -67,8 +403,11 @@ func main() {
log.Fatal(err)
}
// get initial results
rs, err := denote.Search(fs.Filters{})
// get initial results (clear any filter, read index)
if err := setFilter(""); err != nil {
panic(err)
}
rs, err := readIndex()
if err != nil {
panic(err)
}
@ -160,8 +499,8 @@ func main() {
tags = strings.Split(remainder, ",")
}
// Generate filename and content using denote package
fullPath, content := denote.GenerateNoteContent(denote.GetDir(), title, tags, "md-yaml", "")
// Generate filename and content
fullPath, content := generateNotePath(denoteDir, title, tags, "md-yaml")
// Create new Acme window
newWin, err := ui.WindowOpen(fullPath)
@ -221,7 +560,12 @@ func main() {
}
// Get current results and apply changes
current, err := denote.Search(fs.Filters{})
// Read unfiltered index
if err := setFilter(""); err != nil {
log.Printf("failed to clear filter: %v", err)
break
}
current, err := readIndex()
if err != nil {
log.Printf("failed to get current results: %v", err)
break
@ -241,8 +585,7 @@ func main() {
text := string(e.Text)
if isIdentifier(text) {
// Plumb with denote: prefix
cmd := fmt.Sprintf("plumb 'denote:%s'", text)
if err := exec.Command("rc", "-c", cmd).Run(); err != nil {
if err := exec.Command("plumb", fmt.Sprintf("denote:%s", text)).Run(); err != nil {
log.Printf("failed to plumb identifier: %v", err)
}
} else {
@ -287,13 +630,17 @@ func performSearch(w *ui.Window, searchText string) {
}
}
filters, err := fs.Filters{}.Parse(filterArgs)
if err != nil {
log.Printf("filter parse error: %v", err)
// Build filter query string for ctl
filterQuery := strings.Join(filterArgs, " ")
// Set filter on server
if err := setFilter(filterQuery); err != nil {
log.Printf("failed to set filter: %v", err)
return
}
rs, err := denote.Search(filters)
// Read filtered index
rs, err := readIndex()
if err != nil {
log.Printf("search error: %v", err)
return
@ -309,7 +656,12 @@ func refreshWindow(w *ui.Window, rs fs.Results) {
}
func refreshWindowWithDefaults(w *ui.Window) {
rs, err := denote.Search(fs.Filters{})
// Clear filter and read index
if err := setFilter(""); err != nil {
log.Printf("error clearing filter: %v", err)
return
}
rs, err := readIndex()
if err != nil {
log.Printf("error refreshing: %v", err)
return