denote-acme/main.go

357 lines
7.9 KiB
Go

package main
import (
"denote/internal/denote"
"denote/internal/fs"
"denote/internal/sync"
"denote/internal/ui"
"fmt"
"log"
"os"
"os/exec"
"regexp"
"strings"
"unicode"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
)
const (
wname = "/Denote/"
)
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)
if err != nil {
fmt.Fprintf(os.Stderr, "error finding note: %v\n", err)
os.Exit(1)
}
fmt.Println(path)
denote.Open(path)
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 denote:<identifier> - opens denote file by identifier")
fmt.Println()
}
}
// 9p server startup
if err := fs.StartServer(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to start fileserver: %v\n", err)
}
// start event consumer
go denote.EventListener()
// start acme log watcher
go sync.WatchAcmeLog()
// open window
if w, err = ui.WindowOpen(wname); err != nil {
log.Fatal(err)
}
defer w.CloseFiles()
if err = ui.TagSet(w, "New Reset Sync Put"); err != nil {
log.Fatal(err)
}
// get initial results
rs, err := denote.Search(fs.Filters{})
if err != nil {
panic(err)
}
fs.Sort(rs, fs.SortById, fs.SortOrderDesc)
refreshWindow(w, rs)
// listen for 'n' events to refresh window
go func() {
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]))
parts := strings.Fields(event)
if len(parts) >= 2 && parts[1] == "n" {
// Refresh window with new results
rs, err := denote.Search(fs.Filters{})
if err != nil {
log.Printf("error refreshing after new file: %v", err)
continue
}
fs.Sort(rs, fs.SortById, fs.SortOrderDesc)
refreshWindow(w, rs)
}
}
})
if err != nil {
log.Printf("window event listener error: %v", err)
}
}
}()
ui.WindowDirty(w, false)
ui.DotToAddr(w, "#0")
// 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 == "" {
log.Printf("New: no input provided")
break
}
// Write to denote/new via 9P
if err := fs.With9P(func(f *client.Fsys) error {
return fs.WriteFile(f, "new", input)
}); err != nil {
log.Printf("failed to create new file: %v", err)
}
case "Reset":
rs, err := denote.Search(fs.Filters{})
if err != nil {
panic(err)
}
fs.Sort(rs, fs.SortById, fs.SortOrderDesc)
refreshWindow(w, rs)
case "Look":
// Use e.Arg for the search arguments
performSearch(w, string(e.Arg))
case "Sync":
if err := sync.SyncAll(); err != nil {
log.Printf("sync error: %v", err)
}
case "Put":
body, err := w.ReadAll("body")
if err != nil {
log.Printf("failed to read window body: %v", err)
break
}
// Parse the window content
var emptyResults fs.Results
updated, err := emptyResults.FromString(string(body))
if err != nil {
log.Printf("failed to parse window: %v", err)
break
}
// Get current results and apply changes
current, err := denote.Search(fs.Filters{})
if err != nil {
log.Printf("failed to get current results: %v", err)
break
}
// Validate and apply changes via individual file writes
if err := fs.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
cmd := fmt.Sprintf("plumb 'denote:%s'", text)
if err := exec.Command("rc", "-c", cmd).Run(); err != nil {
log.Printf("failed to plumb identifier: %v", err)
}
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
func performSearch(w *ui.Window, searchText string) {
args := parseArgs(searchText)
var filterArgs []string
sortBy := fs.SortById
sortOrder := fs.SortOrderDesc
for _, arg := range args {
if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok {
parts := strings.Split(sortSpec, ",")
switch parts[0] {
case "id", "date":
sortBy = fs.SortById
case "title":
sortBy = fs.SortByTitle
}
if len(parts) > 1 {
switch parts[1] {
case "asc":
sortOrder = fs.SortOrderAsc
case "desc":
sortOrder = fs.SortOrderDesc
}
} else {
sortOrder = fs.SortOrderAsc
}
} else {
filterArgs = append(filterArgs, arg)
}
}
filters, err := fs.Filters{}.Parse(filterArgs)
if err != nil {
log.Printf("filter parse error: %v", err)
return
}
rs, err := denote.Search(filters)
if err != nil {
log.Printf("search error: %v", err)
return
}
fs.Sort(rs, sortBy, sortOrder)
refreshWindow(w, rs)
}
func refreshWindow(w *ui.Window, rs fs.Results) {
ui.BodyWrite(w, ",", rs.Bytes())
ui.DotToAddr(w, "#0")
}
// 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 fs.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]*fs.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 := !fs.SlicesEqual(orig.Tags, upd.Tags)
if titleChanged || tagsChanged {
// Write both title and keywords
titlePath := upd.Identifier + "/title"
if err := fs.WriteFile(f, titlePath, upd.Title); err != nil {
return fmt.Errorf("failed to write title for %s: %w", upd.Identifier, err)
}
keywordsPath := upd.Identifier + "/keywords"
keywords := strings.Join(upd.Tags, ",")
if err := fs.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)
}