350 lines
8.1 KiB
Go
350 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
p9client "denote/internal/p9/client"
|
|
"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/"
|
|
|
|
// 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)
|
|
}
|
|
return results.Unmarshal([]byte(indexContent))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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 {
|
|
// Plumb the identifier directly (plumbing rules handle the mount)
|
|
if err := exec.Command("plumb", "denote:"+identifier).Run(); err != nil {
|
|
log.Fatalf("failed to plumb identifier: %v", err)
|
|
}
|
|
return
|
|
}
|
|
fmt.Println("Usage: Denote [denote:<identifier>]")
|
|
return
|
|
}
|
|
|
|
// Connect to denotesrv, auto-starting if needed
|
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
return nil
|
|
}); err != nil {
|
|
cmd := exec.Command("denotesrv", "start")
|
|
if err := cmd.Run(); err != nil {
|
|
log.Fatalf("failed to start denotesrv: %v", err)
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
return nil
|
|
}); err == nil {
|
|
break
|
|
}
|
|
if i == 9 {
|
|
log.Fatal("denotesrv failed to start")
|
|
}
|
|
}
|
|
}
|
|
|
|
// open window - look for existing /Denote/ window
|
|
if wins, err := acme.Windows(); err == nil {
|
|
for _, winInfo := range wins {
|
|
if winInfo.Name == wname {
|
|
if w, err = acme.Open(winInfo.ID, nil); err == nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if w == nil {
|
|
if w, err = acme.New(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if err = w.Name(wname); err != nil {
|
|
w.Del(true)
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
defer w.CloseFiles()
|
|
|
|
if _, err = w.Write("tag", []byte("New Put Remove Get")); err != nil {
|
|
w.Del(true)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// get initial results
|
|
var rs metadata.Results
|
|
err = p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, ""); err != nil {
|
|
return err
|
|
}
|
|
rs, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Fatal(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":
|
|
input := strings.TrimSpace(string(e.Arg))
|
|
if input == "" {
|
|
break
|
|
}
|
|
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)
|
|
}
|
|
refreshWindowWithDefaults(w)
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Remove":
|
|
input := strings.TrimSpace(string(e.Arg))
|
|
if input == "" {
|
|
break
|
|
}
|
|
w.Ctl("addr=dot")
|
|
q0, q1, _ := w.ReadAddr()
|
|
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)
|
|
}
|
|
refreshWindowWithDefaults(w)
|
|
w.Addr("#%d,#%d", q0, q1)
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Look":
|
|
performSearch(w, string(e.Arg))
|
|
w.Addr("#0")
|
|
w.Ctl("dot=addr")
|
|
w.Ctl("show")
|
|
case "Get":
|
|
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
|
|
}
|
|
updated, err := results.UnmarshalStrict(body)
|
|
if err != nil {
|
|
log.Printf("failed to parse window: %v", err)
|
|
break
|
|
}
|
|
var current metadata.Results
|
|
err = p9client.With9P(func(f *client.Fsys) error {
|
|
if err := setFilter(f, ""); err != nil {
|
|
return err
|
|
}
|
|
current, err = readIndex(f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("failed to get current results: %v", err)
|
|
break
|
|
}
|
|
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':
|
|
text := string(e.Text)
|
|
if isIdentifier(text) {
|
|
if err := exec.Command("plumb", "denote:"+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 && parts[1] == "asc" {
|
|
sortOrder = metadata.SortOrderAsc
|
|
}
|
|
} else {
|
|
filterArgs = append(filterArgs, arg)
|
|
}
|
|
}
|
|
|
|
filterQuery := strings.Join(filterArgs, " ")
|
|
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)
|
|
}
|
|
|
|
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 && i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
|
|
escaped = true
|
|
} 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)
|
|
}
|
|
}
|
|
if current.Len() > 0 {
|
|
args = append(args, current.String())
|
|
}
|
|
for i := range args {
|
|
args[i] = strings.TrimFunc(args[i], unicode.IsSpace)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error {
|
|
if len(updated) != len(current) {
|
|
return fmt.Errorf("entry count mismatch: got %d, expected %d", len(updated), len(current))
|
|
}
|
|
currentMap := make(map[string]*metadata.Metadata)
|
|
for _, m := range current {
|
|
currentMap[m.Identifier] = m
|
|
}
|
|
for _, upd := range updated {
|
|
orig, exists := currentMap[upd.Identifier]
|
|
if !exists {
|
|
return fmt.Errorf("identifier '%s' not found", upd.Identifier)
|
|
}
|
|
if orig.Title != upd.Title || !slices.Equal(orig.Tags, upd.Tags) {
|
|
if err := p9client.WriteFile(f, "n/"+upd.Identifier+"/title", upd.Title); err != nil {
|
|
return fmt.Errorf("failed to write title for %s: %w", upd.Identifier, err)
|
|
}
|
|
if err := p9client.WriteFile(f, "n/"+upd.Identifier+"/keywords", strings.Join(upd.Tags, ",")); 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)
|
|
}
|