585 lines
15 KiB
Go
585 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"denote/internal/disk"
|
|
p9client "denote/internal/p9/client"
|
|
p9server "denote/internal/p9/server"
|
|
"denote/pkg/encoding/frontmatter"
|
|
"denote/pkg/encoding/results"
|
|
"denote/pkg/metadata"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"9fans.net/go/acme"
|
|
"9fans.net/go/plan9/client"
|
|
)
|
|
|
|
const (
|
|
wname = "/Denote/"
|
|
ftype = metadata.FileTypeMdYaml
|
|
)
|
|
|
|
var denoteDir = os.Getenv("HOME") + "/doc"
|
|
|
|
// Helper functions for 9P operations
|
|
|
|
// readIndex reads and parses the index from 9P server.
|
|
func readIndex(f *client.Fsys) (metadata.Results, error) {
|
|
indexContent, err := p9client.ReadFile(f, "index")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read index: %w", err)
|
|
}
|
|
|
|
rs, err := results.Unmarshal([]byte(indexContent))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Populate Path for each note
|
|
for _, note := range rs {
|
|
path, err := p9client.ReadFile(f, "n/"+note.Identifier+"/path")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read path for %s: %w", note.Identifier, err)
|
|
}
|
|
note.Path = path
|
|
}
|
|
|
|
return rs, nil
|
|
}
|
|
|
|
// setFilter sets or clears the filter on the 9P server.
|
|
func setFilter(f *client.Fsys, filterQuery string) error {
|
|
cmd := "filter"
|
|
if filterQuery != "" {
|
|
cmd = "filter " + filterQuery
|
|
}
|
|
return p9client.WriteFile(f, "ctl", cmd)
|
|
}
|
|
|
|
// Event handling
|
|
|
|
// handleNewEvent is called when a new note is created.
|
|
// When a new note is created in 9P, open an acme window (file created on Put).
|
|
func handleNewEvent(f *client.Fsys, identifier, denoteDir string) error {
|
|
fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "signature")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get metadata: %w", err)
|
|
}
|
|
title := fields["title"]
|
|
signature := fields["signature"]
|
|
var tags []string
|
|
if keywords, ok := fields["keywords"]; ok && keywords != "" {
|
|
tags = strings.Split(keywords, ",")
|
|
for i := range tags {
|
|
tags[i] = strings.TrimSpace(tags[i])
|
|
}
|
|
}
|
|
|
|
// Parse subdirectory from title if using '/:' separator
|
|
targetDir := denoteDir
|
|
if idx := strings.Index(title, "/:"); idx != -1 {
|
|
subdir := title[:idx+1] // Include trailing slash
|
|
title = title[idx+2:] // Skip past '/:'
|
|
targetDir = filepath.Join(denoteDir, subdir)
|
|
|
|
targetDir = filepath.Clean(targetDir)
|
|
absTarget, err := filepath.Abs(targetDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve target directory: %w", err)
|
|
}
|
|
absBase, err := filepath.Abs(denoteDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve base directory: %w", err)
|
|
}
|
|
if !strings.HasPrefix(absTarget, absBase) {
|
|
return fmt.Errorf("path traversal attempt: directory outside base")
|
|
}
|
|
|
|
// Create subdirectory if it doesn't exist
|
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
|
|
}
|
|
|
|
// Update metadata with cleaned title
|
|
if err := p9client.WriteFile(f, "n/"+identifier+"/title", title); err != nil {
|
|
return fmt.Errorf("failed to update title in metadata: %w", err)
|
|
}
|
|
}
|
|
|
|
newIdentifier := metadata.GenerateIdentifier()
|
|
fm := metadata.NewFrontMatter(title, signature, tags, newIdentifier)
|
|
ext := metadata.GetExtension(ftype)
|
|
filename := metadata.BuildFilename(fm, ext)
|
|
path := filepath.Join(targetDir, filename)
|
|
content := string(frontmatter.Marshal(fm, ftype))
|
|
|
|
if err := p9client.WriteFile(f, "n/"+identifier+"/path", path); err != nil {
|
|
return fmt.Errorf("failed to update path in metadata: %w", err)
|
|
}
|
|
|
|
// Check if file already exists (manual creation case)
|
|
if _, err := os.Stat(path); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Open acme window with initial content (file will be created on Put)
|
|
var win *acme.Win
|
|
if win = acme.Show(path); win == nil {
|
|
if win, err = acme.New(); err != nil {
|
|
return fmt.Errorf("failed to create window: %w", err)
|
|
}
|
|
if err = win.Name(path); err != nil {
|
|
win.Del(true)
|
|
return fmt.Errorf("failed to set window name: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := win.Addr("0"); err != nil {
|
|
win.Del(true)
|
|
return fmt.Errorf("failed to seek: %w", err)
|
|
}
|
|
if _, err := win.Write("data", []byte(content)); err != nil {
|
|
win.Del(true)
|
|
return fmt.Errorf("failed to write content: %w", err)
|
|
}
|
|
|
|
win.Ctl("dirty")
|
|
win.Addr("$")
|
|
win.Ctl("dot=addr")
|
|
win.Ctl("show")
|
|
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
var err error
|
|
var w *acme.Win
|
|
args := os.Args[1:]
|
|
if len(args) == 1 {
|
|
if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok {
|
|
// Find note by identifier using 9P
|
|
var notePath string
|
|
err := p9client.With9P(func(f *client.Fsys) error {
|
|
// Set filter to find the identifier
|
|
if err := p9client.WriteFile(f, "ctl", "filter date:"+identifier); err != nil {
|
|
return err
|
|
}
|
|
defer p9client.WriteFile(f, "ctl", "filter") // Clear filter
|
|
|
|
// Read index
|
|
indexContent, err := p9client.ReadFile(f, "index")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse index
|
|
rs, err := results.Unmarshal([]byte(indexContent))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(rs) == 0 {
|
|
return fmt.Errorf("no note found with identifier %s", identifier)
|
|
}
|
|
|
|
// Read path for first result
|
|
notePath, err = p9client.ReadFile(f, "n/"+rs[0].Identifier+"/path")
|
|
return err
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error finding note: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(notePath)
|
|
if err := exec.Command("plumb", notePath).Run(); err != nil {
|
|
log.Printf("failed to plumb identifier: %v", err)
|
|
}
|
|
return
|
|
} else {
|
|
fmt.Println("Usage:")
|
|
fmt.Println("Denote is designed to be used in Acme, however it does accept one argument:")
|
|
fmt.Println()
|
|
fmt.Println("\tDenote <identifier> - opens denote file by identifier")
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
// Load metadata from filesystem
|
|
notes, err := disk.LoadAll(denoteDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: failed to load notes: %v\n", err)
|
|
}
|
|
|
|
// Setup callbacks for note operations
|
|
callbacks := p9server.Callbacks{
|
|
OnNew: func(identifier string) error {
|
|
return p9client.With9P(func(f *client.Fsys) error {
|
|
return handleNewEvent(f, identifier, denoteDir)
|
|
})
|
|
},
|
|
OnUpdate: func(identifier string) error {
|
|
return p9client.With9P(func(f *client.Fsys) error {
|
|
return disk.HandleUpdateEvent(f, identifier, denoteDir)
|
|
})
|
|
},
|
|
OnRename: func(identifier string) error {
|
|
return p9client.With9P(func(f *client.Fsys) error {
|
|
return disk.HandleRenameEvent(f, identifier, denoteDir)
|
|
})
|
|
},
|
|
OnDelete: func(identifier string) error {
|
|
return p9client.With9P(func(f *client.Fsys) error {
|
|
return disk.HandleDeleteEvent(f, identifier, denoteDir)
|
|
})
|
|
},
|
|
}
|
|
|
|
// 9p server startup with pre-loaded data and callbacks
|
|
if err := p9server.StartServer(notes, denoteDir, callbacks); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: failed to start fileserver: %v\n", err)
|
|
}
|
|
|
|
// start acme log watcher
|
|
go disk.WatchAcmeLog()
|
|
|
|
// open window
|
|
if w = acme.Show(wname); w == nil {
|
|
if w, err = acme.New(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if err = w.Name(wname); err != nil {
|
|
w.Del(true)
|
|
log.Fatal(fmt.Errorf("failed to set window name: %w", err))
|
|
}
|
|
}
|
|
defer w.CloseFiles()
|
|
|
|
if _, err = w.Write("tag", []byte("New Put Remove Get")); err != nil {
|
|
w.Del(true)
|
|
log.Fatal(fmt.Errorf("failed to set tag: %w", err))
|
|
}
|
|
|
|
// get initial results (clear any filter, read index)
|
|
var rs metadata.Results
|
|
err = p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, ""); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
rs, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc)
|
|
refreshWindow(w, rs)
|
|
|
|
w.Ctl("clean")
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
|
|
// event loop
|
|
for e := range w.EventChan() {
|
|
switch e.C2 {
|
|
case 'x', 'X':
|
|
switch string(e.Text) {
|
|
case "New":
|
|
// Read chorded argument
|
|
input := strings.TrimSpace(string(e.Arg))
|
|
if input == "" {
|
|
break
|
|
}
|
|
|
|
// Write to 9p (triggers callback which opens window)
|
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
return p9client.WriteFile(f, "new", input)
|
|
}); err != nil {
|
|
log.Printf("failed to create note: %v", err)
|
|
}
|
|
|
|
// Refresh window and scroll to top to show new note
|
|
refreshWindowWithDefaults(w)
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Remove":
|
|
// Read chorded argument
|
|
input := strings.TrimSpace(string(e.Arg))
|
|
if input == "" {
|
|
break
|
|
}
|
|
|
|
// Preserve current position for restoring after refresh
|
|
w.Ctl("addr=dot")
|
|
q0, q1, _ := w.ReadAddr()
|
|
|
|
// Write to denote/n/<identifier>/ctl via 9P
|
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
return p9client.WriteFile(f, filepath.Join("n", input, "ctl"), "d")
|
|
}); err != nil {
|
|
log.Printf("failed to delete file: %v", err)
|
|
}
|
|
|
|
// Refresh window and restore position
|
|
refreshWindowWithDefaults(w)
|
|
w.Addr("#%d,#%d", q0, q1)
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Look":
|
|
// Use e.Arg for the search arguments
|
|
performSearch(w, string(e.Arg))
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Get":
|
|
if err := disk.GetAll(p9server.UpdateMetadataFromDisk); err != nil {
|
|
log.Printf("get error: %v", err)
|
|
}
|
|
refreshWindowWithDefaults(w)
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Put":
|
|
body, err := w.ReadAll("body")
|
|
if err != nil {
|
|
log.Printf("failed to read window body: %v", err)
|
|
break
|
|
}
|
|
|
|
// Parse the window content
|
|
updated, err := results.Unmarshal(body)
|
|
if err != nil {
|
|
log.Printf("failed to parse window: %v", err)
|
|
break
|
|
}
|
|
|
|
// Get current results and apply changes
|
|
// Read unfiltered index
|
|
var current metadata.Results
|
|
err = p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, ""); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
current, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("failed to get current results: %v", err)
|
|
break
|
|
}
|
|
|
|
// Validate and apply changes via individual file writes
|
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
return applyIndexChanges(f, current, updated)
|
|
}); err != nil {
|
|
log.Printf("failed to apply changes: %v", err)
|
|
}
|
|
default:
|
|
w.WriteEvent(e)
|
|
}
|
|
case 'l', 'L':
|
|
// Check if text matches identifier pattern
|
|
text := string(e.Text)
|
|
if isIdentifier(text) {
|
|
// Plumb with denote: prefix
|
|
if err := exec.Command("plumb", fmt.Sprintf("denote:%s", text)).Run(); err != nil {
|
|
log.Printf("failed to plumb identifier: %v", err)
|
|
}
|
|
} else {
|
|
w.WriteEvent(e)
|
|
}
|
|
default:
|
|
w.WriteEvent(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func performSearch(w *acme.Win, searchText string) {
|
|
args := parseArgs(searchText)
|
|
|
|
var filterArgs []string
|
|
sortBy := metadata.SortById
|
|
sortOrder := metadata.SortOrderDesc
|
|
|
|
for _, arg := range args {
|
|
if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok {
|
|
parts := strings.Split(sortSpec, ",")
|
|
|
|
switch parts[0] {
|
|
case "id", "date":
|
|
sortBy = metadata.SortById
|
|
case "title":
|
|
sortBy = metadata.SortByTitle
|
|
}
|
|
|
|
if len(parts) > 1 {
|
|
switch parts[1] {
|
|
case "asc":
|
|
sortOrder = metadata.SortOrderAsc
|
|
case "desc":
|
|
sortOrder = metadata.SortOrderDesc
|
|
}
|
|
} else {
|
|
sortOrder = metadata.SortOrderAsc
|
|
}
|
|
} else {
|
|
filterArgs = append(filterArgs, arg)
|
|
}
|
|
}
|
|
|
|
// Build filter query string for ctl
|
|
filterQuery := strings.Join(filterArgs, " ")
|
|
|
|
// Set filter and read index
|
|
var rs metadata.Results
|
|
err := p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, filterQuery); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
rs, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("search error: %v", err)
|
|
return
|
|
}
|
|
metadata.Sort(rs, sortBy, sortOrder)
|
|
|
|
refreshWindow(w, rs)
|
|
}
|
|
|
|
func refreshWindow(w *acme.Win, rs metadata.Results) {
|
|
w.Addr(",")
|
|
w.Write("data", results.Marshal(rs))
|
|
w.Ctl("show")
|
|
}
|
|
|
|
func refreshWindowWithDefaults(w *acme.Win) {
|
|
var rs metadata.Results
|
|
err := p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, ""); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
rs, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("error refreshing: %v", err)
|
|
return
|
|
}
|
|
metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc)
|
|
refreshWindow(w, rs)
|
|
}
|
|
|
|
// parseArgs parses space-separated arguments, handling quoted strings
|
|
func parseArgs(s string) []string {
|
|
var args []string
|
|
var current strings.Builder
|
|
inQuote := false
|
|
escaped := false
|
|
|
|
for i, r := range s {
|
|
if escaped {
|
|
current.WriteRune(r)
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
switch r {
|
|
case '\\':
|
|
if inQuote {
|
|
// Look ahead to see if next char is quote or backslash
|
|
if i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
|
|
escaped = true
|
|
} else {
|
|
current.WriteRune(r)
|
|
}
|
|
} else {
|
|
current.WriteRune(r)
|
|
}
|
|
case '"':
|
|
inQuote = !inQuote
|
|
case ' ', '\t', '\n':
|
|
if inQuote {
|
|
current.WriteRune(r)
|
|
} else if current.Len() > 0 {
|
|
args = append(args, current.String())
|
|
current.Reset()
|
|
}
|
|
default:
|
|
current.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
// Add final argument if any
|
|
if current.Len() > 0 {
|
|
args = append(args, current.String())
|
|
}
|
|
|
|
// Trim whitespace from args
|
|
for i := range args {
|
|
args[i] = strings.TrimFunc(args[i], unicode.IsSpace)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error {
|
|
// Validate entry count matches
|
|
if len(updated) != len(current) {
|
|
return fmt.Errorf("entry count mismatch: got %d, expected %d", len(updated), len(current))
|
|
}
|
|
|
|
// Build map of current state
|
|
currentMap := make(map[string]*metadata.Metadata)
|
|
for _, m := range current {
|
|
currentMap[m.Identifier] = m
|
|
}
|
|
|
|
// Write title and keywords for changed entries
|
|
for _, upd := range updated {
|
|
orig, exists := currentMap[upd.Identifier]
|
|
if !exists {
|
|
return fmt.Errorf("identifier '%s' not found", upd.Identifier)
|
|
}
|
|
|
|
// Check if anything changed
|
|
titleChanged := orig.Title != upd.Title
|
|
tagsChanged := !slices.Equal(orig.Tags, upd.Tags)
|
|
|
|
if titleChanged || tagsChanged {
|
|
// Write both title and keywords
|
|
titlePath := "n/" + upd.Identifier + "/title"
|
|
if err := p9client.WriteFile(f, titlePath, upd.Title); err != nil {
|
|
return fmt.Errorf("failed to write title for %s: %w", upd.Identifier, err)
|
|
}
|
|
|
|
keywordsPath := "n/" + upd.Identifier + "/keywords"
|
|
keywords := strings.Join(upd.Tags, ",")
|
|
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
|
|
return fmt.Errorf("failed to write keywords for %s: %w", upd.Identifier, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var identifierPattern = regexp.MustCompile(`^\d{8}T\d{6}$`)
|
|
|
|
func isIdentifier(s string) bool {
|
|
return identifierPattern.MatchString(s)
|
|
}
|