refactor: denote->main.go, filter via ctl file
This commit is contained in:
parent
d8b7af94ba
commit
5365c19720
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
384
main.go
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue