Remove disk sync, use 9pfuse mount
- Remove internal/disk package (sync, callbacks, loader, backlinks) - Remove event handlers for disk operations - Simplify main.go to just 9P metadata operations - Convert Drn from Go to rc script (50 lines vs 170) - Plumbing now opens notes via ~/mnt/denote/n/<id>/body
This commit is contained in:
parent
264ac251ae
commit
20bc5324a6
420
cmd/Drn/main.go
420
cmd/Drn/main.go
|
|
@ -1,420 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
p9client "denote/internal/p9/client"
|
|
||||||
"denote/pkg/encoding/frontmatter"
|
|
||||||
"denote/pkg/util"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"9fans.net/go/acme"
|
|
||||||
"9fans.net/go/plan9/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Check if running in service mode (called by OnRename callback)
|
|
||||||
if len(os.Args) > 1 && strings.HasPrefix(os.Args[1], "--id=") {
|
|
||||||
identifier := strings.TrimPrefix(os.Args[1], "--id=")
|
|
||||||
if err := serviceMode(identifier); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Drn: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
args := os.Args[1:]
|
|
||||||
if len(args) == 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "usage: Drn [identifier] 'title' [==signature] [tags]\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if first argument is an identifier (interactive mode)
|
|
||||||
identifierPattern := regexp.MustCompile(`^\d{8}T\d{6}$`)
|
|
||||||
if identifierPattern.MatchString(args[0]) {
|
|
||||||
// Interactive mode: operate on file
|
|
||||||
if err := interactiveMode(args[0], args[1:]); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Drn: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window mode: operate on current window
|
|
||||||
if err := windowMode(args); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Drn: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRenameArgs parses 'title' [==signature] [tags] or [==signature] 'title' [tags]
|
|
||||||
func parseRenameArgs(args []string) (title, signature string, tags []string, err error) {
|
|
||||||
input := strings.Join(args, " ")
|
|
||||||
|
|
||||||
// Extract signature if present at the beginning
|
|
||||||
if strings.HasPrefix(input, "==") {
|
|
||||||
spaceIdx := strings.Index(input, " ")
|
|
||||||
if spaceIdx == -1 {
|
|
||||||
return "", "", nil, fmt.Errorf("title is required")
|
|
||||||
}
|
|
||||||
signature = input[2:spaceIdx] // Skip ==
|
|
||||||
input = strings.TrimSpace(input[spaceIdx+1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract title (must be single-quoted)
|
|
||||||
if !strings.HasPrefix(input, "'") {
|
|
||||||
return "", "", nil, fmt.Errorf("title must be single-quoted")
|
|
||||||
}
|
|
||||||
|
|
||||||
closeQuote := strings.Index(input[1:], "'")
|
|
||||||
if closeQuote == -1 {
|
|
||||||
return "", "", nil, fmt.Errorf("title must be single-quoted (missing closing quote)")
|
|
||||||
}
|
|
||||||
|
|
||||||
title = input[1 : closeQuote+1]
|
|
||||||
if title == "" {
|
|
||||||
return "", "", nil, fmt.Errorf("title cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract signature and tags (everything after closing quote)
|
|
||||||
remainder := strings.TrimSpace(input[closeQuote+2:])
|
|
||||||
|
|
||||||
if remainder != "" {
|
|
||||||
// Check if signature is present (starts with ==) and wasn't already parsed
|
|
||||||
if signature == "" && strings.HasPrefix(remainder, "==") {
|
|
||||||
// Find end of signature (space before tags)
|
|
||||||
spaceIdx := strings.Index(remainder, " ")
|
|
||||||
if spaceIdx == -1 {
|
|
||||||
// No tags, just signature
|
|
||||||
signature = remainder[2:] // Skip ==
|
|
||||||
} else {
|
|
||||||
signature = remainder[2:spaceIdx] // Skip ==
|
|
||||||
remainder = strings.TrimSpace(remainder[spaceIdx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract tags if present
|
|
||||||
if remainder != "" && !strings.HasPrefix(remainder, "==") {
|
|
||||||
// Validate tags
|
|
||||||
tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\p{Nd}]+$`)
|
|
||||||
if !tagPattern.MatchString(remainder) {
|
|
||||||
return "", "", nil, fmt.Errorf("tags must be comma-delimited lowercase unicode words (no spaces)")
|
|
||||||
}
|
|
||||||
tags = strings.Split(remainder, ",")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return title, signature, tags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceMode handles rename when called by OnRename callback
|
|
||||||
// This replicates the logic from HandleRenameEvent
|
|
||||||
func serviceMode(identifier string) error {
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Get current denote directory from 9P server
|
|
||||||
currentDir, err := p9client.ReadFile(f, "dir")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read current directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get new path from 9P server
|
|
||||||
newPath, err := p9client.ReadFile(f, "n/"+identifier+"/path")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newPath == "" {
|
|
||||||
// Path is not set yet. This can happen during note creation.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find old file on disk using directory from newPath
|
|
||||||
dir := filepath.Dir(newPath)
|
|
||||||
if dir == "." {
|
|
||||||
dir = currentDir
|
|
||||||
}
|
|
||||||
pattern := filepath.Join(dir, identifier+"*")
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
|
||||||
// File doesn't exist on disk. Nothing to rename.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
oldPath := matches[0]
|
|
||||||
|
|
||||||
// Rename if different
|
|
||||||
if oldPath != newPath {
|
|
||||||
newPath = filepath.Clean(newPath)
|
|
||||||
absNew, err := filepath.Abs(newPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve new path: %w", err)
|
|
||||||
}
|
|
||||||
absBase, err := filepath.Abs(currentDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve base directory: %w", err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(absNew, absBase) {
|
|
||||||
return fmt.Errorf("path traversal attempt: path outside base")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(oldPath, newPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to rename file from %s to %s: %w", oldPath, newPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update window tag for renamed file
|
|
||||||
if wins, err := acme.Windows(); err == nil {
|
|
||||||
for _, w := range wins {
|
|
||||||
if err := updateWindowName(w.ID, oldPath, newPath); err != nil {
|
|
||||||
log.Printf("failed to update window %d: %v", w.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// interactiveMode handles rename when called with explicit identifier
|
|
||||||
// Operates on the file directly, suitable for all file types
|
|
||||||
func interactiveMode(identifier string, args []string) error {
|
|
||||||
// Parse arguments
|
|
||||||
title, signature, tags, err := parseRenameArgs(args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentDir string
|
|
||||||
var filePath string
|
|
||||||
|
|
||||||
// Get current directory and find file
|
|
||||||
err = p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Get current denote directory from 9P server
|
|
||||||
dir, err := p9client.ReadFile(f, "dir")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read current directory: %w", err)
|
|
||||||
}
|
|
||||||
currentDir = dir
|
|
||||||
|
|
||||||
// Get path from metadata to find correct directory
|
|
||||||
path, err := p9client.ReadFile(f, "n/"+identifier+"/path")
|
|
||||||
if err != nil || path == "" {
|
|
||||||
path = currentDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use directory from metadata path
|
|
||||||
searchDir := filepath.Dir(path)
|
|
||||||
if searchDir == "." {
|
|
||||||
searchDir = currentDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find file by identifier
|
|
||||||
pattern := filepath.Join(searchDir, 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 with identifier: %s", identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return fmt.Errorf("multiple files match identifier %s", identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath = matches[0]
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
supportsFrontmatter := ext == ".org" || ext == ".md" || ext == ".txt"
|
|
||||||
|
|
||||||
// Update frontmatter in file if applicable
|
|
||||||
if supportsFrontmatter {
|
|
||||||
content, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse existing frontmatter
|
|
||||||
existing, fileType, err := frontmatter.Unmarshal(content, ext)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse frontmatter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update metadata
|
|
||||||
existing.Title = title
|
|
||||||
existing.Tags = tags
|
|
||||||
existing.Signature = signature
|
|
||||||
|
|
||||||
// Apply updated frontmatter
|
|
||||||
newContent, err := util.Apply(string(content), existing, fileType)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update frontmatter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update 9P metadata (triggers OnUpdate and OnRename callbacks for file rename)
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Write title
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/title", title); err != nil {
|
|
||||||
return fmt.Errorf("failed to write title: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write keywords
|
|
||||||
keywords := strings.Join(tags, ",")
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/keywords", keywords); err != nil {
|
|
||||||
return fmt.Errorf("failed to write keywords: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write signature
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/signature", signature); err != nil {
|
|
||||||
return fmt.Errorf("failed to write signature: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// windowMode handles rename when called by user from acme window
|
|
||||||
func windowMode(args []string) error {
|
|
||||||
// Get $winid
|
|
||||||
winidStr := os.Getenv("winid")
|
|
||||||
if winidStr == "" {
|
|
||||||
return fmt.Errorf("$winid not set - must be called from acme window")
|
|
||||||
}
|
|
||||||
winid, err := strconv.Atoi(winidStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid $winid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the window
|
|
||||||
win, err := acme.Open(winid, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open window: %w", err)
|
|
||||||
}
|
|
||||||
defer win.CloseFiles()
|
|
||||||
|
|
||||||
// Read window name from tag
|
|
||||||
tag, err := win.ReadAll("tag")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read window tag: %w", err)
|
|
||||||
}
|
|
||||||
windowName := strings.Fields(string(tag))[0]
|
|
||||||
|
|
||||||
// Extract identifier from window name
|
|
||||||
identifierPattern := regexp.MustCompile(`(\d{8}T\d{6})`)
|
|
||||||
matches := identifierPattern.FindStringSubmatch(windowName)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return fmt.Errorf("no denote identifier found in window name: %s", windowName)
|
|
||||||
}
|
|
||||||
identifier := matches[1]
|
|
||||||
|
|
||||||
// Parse arguments: [==signature] 'title' [tags] or 'title' [==signature] [tags]
|
|
||||||
title, signature, tags, err := parseRenameArgs(args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read window body to check for frontmatter
|
|
||||||
body, err := win.ReadAll("body")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read window body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect file type from window name
|
|
||||||
ext := strings.ToLower(filepath.Ext(windowName))
|
|
||||||
supportsFrontmatter := ext == ".org" || ext == ".md" || ext == ".txt"
|
|
||||||
|
|
||||||
// Update frontmatter if applicable
|
|
||||||
if supportsFrontmatter && len(body) > 0 {
|
|
||||||
// Parse existing frontmatter to preserve Identifier and FileType
|
|
||||||
existing, fileType, err := frontmatter.Unmarshal(body, ext)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse existing frontmatter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update only the fields that changed from rename
|
|
||||||
existing.Title = title
|
|
||||||
existing.Tags = tags
|
|
||||||
existing.Signature = signature
|
|
||||||
|
|
||||||
// Apply updated frontmatter to content
|
|
||||||
newContent, err := util.Apply(string(body), existing, fileType)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update frontmatter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write updated content back to window
|
|
||||||
if err := win.Addr("0,$"); err != nil {
|
|
||||||
return fmt.Errorf("failed to select window content: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := win.Write("data", []byte(newContent)); err != nil {
|
|
||||||
return fmt.Errorf("failed to write updated content: %w", err)
|
|
||||||
}
|
|
||||||
if err := win.Ctl("dirty"); err != nil {
|
|
||||||
return fmt.Errorf("failed to mark window dirty: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to 9P metadata (triggers OnUpdate and OnRename callbacks)
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Write title
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/title", title); err != nil {
|
|
||||||
return fmt.Errorf("failed to write title: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write keywords
|
|
||||||
keywords := strings.Join(tags, ",")
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/keywords", keywords); err != nil {
|
|
||||||
return fmt.Errorf("failed to write keywords: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write signature
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/signature", signature); err != nil {
|
|
||||||
return fmt.Errorf("failed to write signature: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateWindowName updates the window tag name from oldPath to newPath
|
|
||||||
func updateWindowName(id int, oldPath, newPath string) error {
|
|
||||||
win, err := acme.Open(id, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer win.CloseFiles()
|
|
||||||
|
|
||||||
tag, err := win.ReadAll("tag")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(string(tag), oldPath) {
|
|
||||||
return nil // Window doesn't have this path, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := win.Ctl("name " + newPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to rename window: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
package disk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"denote/pkg/metadata"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func FindBacklinks(targetID, denoteDir string, allNotes metadata.Results) metadata.Results {
|
|
||||||
searchPattern := "denote:" + targetID
|
|
||||||
|
|
||||||
duCmd := exec.Command("9", "du", "-a")
|
|
||||||
duCmd.Dir = denoteDir
|
|
||||||
|
|
||||||
awkCmd := exec.Command("9", "awk", "$2 ~ /\\.(md|org|txt)$/ {print $2}")
|
|
||||||
|
|
||||||
xargsCmd := exec.Command("9", "xargs", "9", "grep", "-l", searchPattern)
|
|
||||||
xargsCmd.Dir = denoteDir
|
|
||||||
|
|
||||||
duOut, err := duCmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
awkCmd.Stdin = duOut
|
|
||||||
|
|
||||||
awkOut, err := awkCmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
xargsCmd.Stdin = awkOut
|
|
||||||
|
|
||||||
var output bytes.Buffer
|
|
||||||
xargsCmd.Stdout = &output
|
|
||||||
|
|
||||||
if err := duCmd.Start(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := awkCmd.Start(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := xargsCmd.Start(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
duCmd.Wait()
|
|
||||||
awkCmd.Wait()
|
|
||||||
xargsCmd.Wait()
|
|
||||||
|
|
||||||
result := strings.TrimSpace(output.String())
|
|
||||||
if result == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var results metadata.Results
|
|
||||||
lines := strings.Split(result, "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimPrefix(line, "./")
|
|
||||||
m := metadata.ParseFilename(filepath.Join(denoteDir, line))
|
|
||||||
if m.Identifier == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, note := range allNotes {
|
|
||||||
if note.Identifier == m.Identifier {
|
|
||||||
results = append(results, note)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
package disk
|
|
||||||
|
|
||||||
import (
|
|
||||||
p9client "denote/internal/p9/client"
|
|
||||||
"denote/pkg/metadata"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"9fans.net/go/plan9/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleUpdateEvent handles 'u' events from the 9P server.
|
|
||||||
// When metadata is updated in 9P, sync it to the filesystem.
|
|
||||||
func HandleUpdateEvent(f *client.Fsys, identifier, denoteDir string) error {
|
|
||||||
fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "signature", "path")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
title := fields["title"]
|
|
||||||
keywords := fields["keywords"]
|
|
||||||
signature := fields["signature"]
|
|
||||||
path := fields["path"]
|
|
||||||
|
|
||||||
var tags []string
|
|
||||||
if keywords != "" {
|
|
||||||
for _, tag := range strings.Split(keywords, ",") {
|
|
||||||
tags = append(tags, strings.TrimSpace(tag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update if file exists (skip if not yet created via Put)
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine directory and extension
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if dir == "." {
|
|
||||||
dir = denoteDir
|
|
||||||
}
|
|
||||||
ext := GetFullExtension(path)
|
|
||||||
|
|
||||||
var fileType metadata.FileType
|
|
||||||
var fm *metadata.FrontMatter
|
|
||||||
|
|
||||||
// For files with frontmatter, parse and update it
|
|
||||||
if SupportsFrontMatter(path) {
|
|
||||||
// Parse existing frontmatter to get FileType and current metadata
|
|
||||||
existing, ftype, err := ExtractFrontMatter(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to extract frontmatter for %s: %v", identifier, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update fields from 9P metadata
|
|
||||||
existing.Title = title
|
|
||||||
existing.Tags = tags
|
|
||||||
existing.Signature = signature
|
|
||||||
|
|
||||||
fm = existing
|
|
||||||
fileType = ftype
|
|
||||||
|
|
||||||
// Preserve compound extensions (e.g., .md.gpg)
|
|
||||||
// Replace content extension but keep wrapper layers
|
|
||||||
contentExt := GetContentExtension(path)
|
|
||||||
typeExt := metadata.GetExtension(fileType)
|
|
||||||
if contentExt != ext {
|
|
||||||
// Compound extension: replace content part with type extension
|
|
||||||
ext = strings.Replace(ext, contentExt, typeExt, 1)
|
|
||||||
} else {
|
|
||||||
// Simple extension: use type extension
|
|
||||||
ext = typeExt
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For binary files, build metadata from 9P data
|
|
||||||
fm = &metadata.FrontMatter{
|
|
||||||
Identifier: identifier,
|
|
||||||
Title: title,
|
|
||||||
Tags: tags,
|
|
||||||
Signature: signature,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build new filename using updated metadata
|
|
||||||
filename := metadata.BuildFilename(fm, ext)
|
|
||||||
newPath := filepath.Join(dir, filename)
|
|
||||||
|
|
||||||
// If filename changed, update path in 9P (which will trigger rename)
|
|
||||||
if newPath != path {
|
|
||||||
newPath = filepath.Clean(newPath)
|
|
||||||
absNew, err := filepath.Abs(newPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve new path: %w", err)
|
|
||||||
}
|
|
||||||
absBase, err := filepath.Abs(denoteDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve base directory: %w", err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(absNew, absBase) {
|
|
||||||
return fmt.Errorf("path traversal attempt: path outside base")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/path", newPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to update path in 9p: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updated frontmatter to file content (only for supported file types)
|
|
||||||
if SupportsFrontMatter(path) && fm != nil {
|
|
||||||
if err := UpdateFrontMatter(path, fm, fileType); err != nil {
|
|
||||||
log.Printf("failed to update front matter for %s: %v", identifier, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleRenameEvent handles 'r' events from the 9P server.
|
|
||||||
// Delegates to Drn binary to perform the actual rename.
|
|
||||||
func HandleRenameEvent(f *client.Fsys, identifier, denoteDir string) error {
|
|
||||||
cmd := exec.Command("Drn", "--id="+identifier)
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("rename failed: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDeleteEvent handles 'd' events from the 9P server.
|
|
||||||
// When a note is deleted in 9P, delete the file from the filesystem.
|
|
||||||
func HandleDeleteEvent(f *client.Fsys, identifier, denoteDir string) error {
|
|
||||||
// Get path from metadata to find correct directory
|
|
||||||
path, err := p9client.ReadFile(f, "n/"+identifier+"/path")
|
|
||||||
if err != nil || path == "" {
|
|
||||||
// Fallback to denoteDir if path not set
|
|
||||||
path = denoteDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use directory from metadata path
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if dir == "." {
|
|
||||||
dir = denoteDir
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern := filepath.Join(dir, identifier+"*")
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
package disk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"denote/pkg/encoding/frontmatter"
|
|
||||||
"denote/pkg/metadata"
|
|
||||||
"denote/pkg/util"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetFullExtension returns the full extension, including compound extensions.
|
|
||||||
// For example: "file.md.gpg" returns ".md.gpg", "file.tar.gz" returns ".tar.gz"
|
|
||||||
func GetFullExtension(path string) string {
|
|
||||||
ext := filepath.Ext(path)
|
|
||||||
if ext == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's another extension before this one
|
|
||||||
pathWithoutExt := strings.TrimSuffix(path, ext)
|
|
||||||
innerExt := filepath.Ext(pathWithoutExt)
|
|
||||||
|
|
||||||
if innerExt != "" {
|
|
||||||
// Compound extension found, return both parts
|
|
||||||
return innerExt + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetContentExtension returns the content type extension, stripping encryption/compression layers.
|
|
||||||
// For example: "file.md.gpg" returns ".md", "file.tar.gz" returns ".tar", "file.md" returns ".md"
|
|
||||||
func GetContentExtension(path string) string {
|
|
||||||
fullExt := GetFullExtension(path)
|
|
||||||
|
|
||||||
// For compound extensions, take the first part
|
|
||||||
parts := strings.Split(fullExt, ".")
|
|
||||||
if len(parts) >= 3 { // ["", "md", "gpg"] for ".md.gpg"
|
|
||||||
return "." + parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullExt
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsFrontMatter returns true if the file extension supports frontmatter.
|
|
||||||
// Uses the actual extension, not the content extension, so encrypted files (.md.gpg)
|
|
||||||
// are correctly treated as binary files.
|
|
||||||
func SupportsFrontMatter(path string) bool {
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
|
||||||
return ext == ".org" || ext == ".md" || ext == ".txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractTitleFromContent extracts the title from file content.
|
|
||||||
// ext should be the file extension (e.g., ".md", ".org", ".txt").
|
|
||||||
// Returns empty string if no title found or unsupported extension.
|
|
||||||
func extractTitleFromContent(content string, ext string) string {
|
|
||||||
ext = strings.ToLower(ext)
|
|
||||||
if ext != ".org" && ext != ".md" && ext != ".txt" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try org-mode #+title: first, then fall back to first heading
|
|
||||||
if ext == ".org" {
|
|
||||||
if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(content); m != nil {
|
|
||||||
return strings.TrimSpace(m[1])
|
|
||||||
}
|
|
||||||
// Fallback to first heading (lines starting with *)
|
|
||||||
if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(content); m != nil {
|
|
||||||
return strings.TrimSpace(m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try markdown YAML front matter title: first, then fall back to # header
|
|
||||||
if ext == ".md" {
|
|
||||||
if m := regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`).FindStringSubmatch(content); m != nil {
|
|
||||||
return strings.TrimSpace(strings.Trim(m[1], `"`))
|
|
||||||
}
|
|
||||||
if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(content); m != nil {
|
|
||||||
return strings.TrimSpace(m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractMetadata extracts metadata from a file (combines filename and content parsing).
|
|
||||||
// This is the I/O wrapper around metadata's pure functions.
|
|
||||||
func ExtractMetadata(path string) (*metadata.Metadata, error) {
|
|
||||||
// Parse filename (no I/O)
|
|
||||||
note := metadata.ParseFilename(path)
|
|
||||||
|
|
||||||
// Check if we should try to read file content for title
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
|
||||||
// Don't read unsupported file types
|
|
||||||
if ext != ".org" && ext != ".md" && ext != ".txt" {
|
|
||||||
return note, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read file content
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
// If we can't read the file, just use filename metadata
|
|
||||||
return note, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract title from content
|
|
||||||
if title := extractTitleFromContent(string(content), ext); title != "" {
|
|
||||||
note.Title = title
|
|
||||||
}
|
|
||||||
|
|
||||||
return note, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractFrontMatter reads a file and parses its front matter.
|
|
||||||
// Returns the parsed FrontMatter and the detected FileType.
|
|
||||||
func ExtractFrontMatter(path string) (*metadata.FrontMatter, metadata.FileType, error) {
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return frontmatter.Unmarshal(content, ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateFrontMatter updates the front matter in a file.
|
|
||||||
func UpdateFrontMatter(path string, fm *metadata.FrontMatter, fileType metadata.FileType) error {
|
|
||||||
// Only update frontmatter for supported file types
|
|
||||||
if !SupportsFrontMatter(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read current content
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply front matter to content
|
|
||||||
newContent, err := util.Apply(string(content), fm, fileType)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply front matter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
return os.WriteFile(path, []byte(newContent), 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAll walks a directory and extracts metadata from all denote files.
|
|
||||||
func LoadAll(dir string) (metadata.Results, error) {
|
|
||||||
var notes metadata.Results
|
|
||||||
|
|
||||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
note, err := ExtractMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include files with valid identifiers
|
|
||||||
if note.Identifier != "" {
|
|
||||||
// Warn on invalid tags but continue loading
|
|
||||||
if invalid := metadata.ValidateTags(note.Tags); len(invalid) > 0 {
|
|
||||||
log.Printf("warning: %s:1 invalid tags %v (must be lowercase alphanumeric)", path, invalid)
|
|
||||||
}
|
|
||||||
notes = append(notes, note)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return notes, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
/*
|
|
||||||
The sync package synchronizes the underlying filesystem with the 9P virtual filesystem by loading
|
|
||||||
the denote metadata from the underlying filesystem, and refreshing the in-memory metadata. Files
|
|
||||||
with denote front matter use the front matter, and all other files use the metadata encoded in
|
|
||||||
the file name.
|
|
||||||
*/
|
|
||||||
package disk
|
|
||||||
|
|
||||||
import (
|
|
||||||
p9client "denote/internal/p9/client"
|
|
||||||
"denote/pkg/config"
|
|
||||||
"denote/pkg/metadata"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"9fans.net/go/acme"
|
|
||||||
"9fans.net/go/plan9/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// denoteFilePattern matches denote filenames: YYYYMMDDTHHMMSS--title__tags.ext
|
|
||||||
var denoteFilePattern = regexp.MustCompile(`^(\d{8}T\d{6})--`)
|
|
||||||
|
|
||||||
// WatchAcmeLog monitors Acme's log for Put events and syncs denote file
|
|
||||||
// front matter changes to the 9P filesystem metadata.
|
|
||||||
func WatchAcmeLog() {
|
|
||||||
for {
|
|
||||||
alog, err := acme.Log()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("sync: failed to open acme log: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
ev, err := alog.Read()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("sync: error reading acme log: %v", err)
|
|
||||||
alog.Close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only handle Put events
|
|
||||||
if ev.Op != "put" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a denote file
|
|
||||||
if !isDenoteFile(ev.Name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract identifier from filename
|
|
||||||
identifier := extractIdentifier(ev.Name)
|
|
||||||
if identifier == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if note exists in 9P
|
|
||||||
exists := noteExists(identifier)
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
// Existing note: sync front matter if supported (triggers 'u' event)
|
|
||||||
if SupportsFrontMatter(ev.Name) {
|
|
||||||
if err := syncFrontMatter(ev.Name, identifier); err != nil {
|
|
||||||
log.Printf("sync: failed to sync %s: %v", ev.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New note: register with 9P (triggers 'n' event)
|
|
||||||
if err := registerNewNote(ev.Name); err != nil {
|
|
||||||
log.Printf("sync: failed to register %s: %v", ev.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDenoteFile checks if the file is in the denote directory
|
|
||||||
func isDenoteFile(path string) bool {
|
|
||||||
// Try to read current dir from 9p, fall back to default
|
|
||||||
denoteDir := config.DefaultDenoteDir
|
|
||||||
|
|
||||||
err := p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
dir, readErr := p9client.ReadFile(f, "dir")
|
|
||||||
if readErr == nil && dir != "" {
|
|
||||||
denoteDir = strings.TrimSpace(dir)
|
|
||||||
}
|
|
||||||
return nil // Don't propagate error, just use fallback
|
|
||||||
})
|
|
||||||
_ = err // Ignore connection errors, use default
|
|
||||||
|
|
||||||
absPath, err := filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(absPath, denoteDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractIdentifier extracts the identifier from a denote filename
|
|
||||||
func extractIdentifier(path string) string {
|
|
||||||
filename := filepath.Base(path)
|
|
||||||
matches := denoteFilePattern.FindStringSubmatch(filename)
|
|
||||||
if len(matches) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteExists checks if a note exists in 9P
|
|
||||||
func noteExists(identifier string) bool {
|
|
||||||
err := p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
titlePath := "n/" + identifier + "/title"
|
|
||||||
fid, err := f.Open(titlePath, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fid.Close()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerNewNote writes to /new to register a new note (triggers 'n' event)
|
|
||||||
func registerNewNote(path string) error {
|
|
||||||
meta, err := ExtractMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format as 'title' tag1,tag2
|
|
||||||
var newInput string
|
|
||||||
if len(meta.Tags) > 0 {
|
|
||||||
newInput = fmt.Sprintf("'%s' %s", meta.Title, strings.Join(meta.Tags, ","))
|
|
||||||
} else {
|
|
||||||
newInput = fmt.Sprintf("'%s'", meta.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
return p9client.WriteFile(f, "new", newInput)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 := ExtractFrontMatter(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to 9P filesystem
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Write title (triggers 'u' event)
|
|
||||||
titlePath := "n/" + identifier + "/title"
|
|
||||||
if err := p9client.WriteFile(f, titlePath, fm.Title); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write keywords (triggers 'u' event)
|
|
||||||
keywords := strings.Join(fm.Tags, ",")
|
|
||||||
keywordsPath := "n/" + identifier + "/keywords"
|
|
||||||
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger rename event
|
|
||||||
ctlPath := "n/" + identifier + "/ctl"
|
|
||||||
if err := p9client.WriteFile(f, ctlPath, "r"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMetadataFunc is a function that updates metadata for a note
|
|
||||||
type UpdateMetadataFunc func(identifier, title, keywords, signature string) error
|
|
||||||
|
|
||||||
// GetAll reloads all notes from disk to 9P (discards uncommitted 9P changes)
|
|
||||||
func GetAll(updateFunc UpdateMetadataFunc) error {
|
|
||||||
return p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
// Read index to get all identifiers
|
|
||||||
indexFid, err := f.Open("index", 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer indexFid.Close()
|
|
||||||
|
|
||||||
var content []byte
|
|
||||||
buf := make([]byte, 8192)
|
|
||||||
for {
|
|
||||||
n, err := indexFid.Read(buf)
|
|
||||||
if err != nil && err.Error() != "EOF" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
content = append(content, buf[:n]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse identifiers from index
|
|
||||||
lines := strings.Split(string(content), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse identifier from line (format: "denote:ID | title | tags")
|
|
||||||
parts := strings.SplitN(line, "|", 2)
|
|
||||||
if len(parts) < 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier := strings.TrimSpace(strings.TrimPrefix(parts[0], "denote:"))
|
|
||||||
if identifier == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read path
|
|
||||||
pathFid, err := f.Open("n/"+identifier+"/path", 0)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("sync: failed to read path for %s: %v", identifier, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pathBuf := make([]byte, 8192)
|
|
||||||
n, _ := pathFid.Read(pathBuf)
|
|
||||||
pathFid.Close()
|
|
||||||
path := strings.TrimSpace(string(pathBuf[:n]))
|
|
||||||
|
|
||||||
// Check if file exists on filesystem
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
// Path in metadata doesn't exist - search for actual file on disk
|
|
||||||
// Use the directory from the metadata path (handles subdirs like journal/)
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
pattern := filepath.Join(dir, identifier+"*")
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("sync: failed to glob for %s: %v", identifier, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matches) > 0 {
|
|
||||||
// File exists (maybe encrypted or renamed), update metadata path
|
|
||||||
actualPath := matches[0]
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/path", actualPath); err != nil {
|
|
||||||
log.Printf("sync: failed to update path for %s: %v", identifier, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Continue to sync the file content
|
|
||||||
path = actualPath
|
|
||||||
} else {
|
|
||||||
// No file on disk - delete metadata (filesystem is source of truth)
|
|
||||||
if err := p9client.WriteFile(f, "n/"+identifier+"/ctl", "d"); err != nil {
|
|
||||||
log.Printf("sync: failed to delete metadata for %s: %v", identifier, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIMIZATION: Circumvent 9P write operation overhead by directly manipulating in-memory data.
|
|
||||||
var title, keywords, signature string
|
|
||||||
if SupportsFrontMatter(path) {
|
|
||||||
fm, _, err := ExtractFrontMatter(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("get: failed to extract front matter for %s: %v", identifier, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
title = fm.Title
|
|
||||||
keywords = strings.Join(fm.Tags, ",")
|
|
||||||
signature = fm.Signature
|
|
||||||
} else {
|
|
||||||
meta := metadata.ParseFilename(path)
|
|
||||||
title = meta.Title
|
|
||||||
keywords = strings.Join(meta.Tags, ",")
|
|
||||||
signature = meta.Signature
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update in-memory metadata directly
|
|
||||||
if err := updateFunc(identifier, title, keywords, signature); err != nil {
|
|
||||||
log.Printf("get: failed to update metadata for %s: %v", identifier, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDenoteFile reloads a text file with front matter from disk to 9P
|
|
||||||
func getDenoteFile(f *client.Fsys, path, identifier string) error {
|
|
||||||
// Extract front matter from file
|
|
||||||
fm, _, err := ExtractFrontMatter(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write title (triggers 'u' event)
|
|
||||||
titlePath := "n/" + identifier + "/title"
|
|
||||||
if err := p9client.WriteFile(f, titlePath, fm.Title); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write keywords (triggers 'u' event)
|
|
||||||
keywords := strings.Join(fm.Tags, ",")
|
|
||||||
keywordsPath := "n/" + identifier + "/keywords"
|
|
||||||
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write signature (triggers 'u' event)
|
|
||||||
signaturePath := "n/" + identifier + "/signature"
|
|
||||||
if err := p9client.WriteFile(f, signaturePath, fm.Signature); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNonDenoteFile reloads a binary file from disk to 9P (metadata from filename)
|
|
||||||
func getNonDenoteFile(f *client.Fsys, path, identifier string) error {
|
|
||||||
// Extract metadata from filename
|
|
||||||
meta := metadata.ParseFilename(path)
|
|
||||||
|
|
||||||
// Write title (triggers 'u' event)
|
|
||||||
titlePath := "n/" + identifier + "/title"
|
|
||||||
if err := p9client.WriteFile(f, titlePath, meta.Title); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write keywords (triggers 'u' event)
|
|
||||||
keywords := strings.Join(meta.Tags, ",")
|
|
||||||
keywordsPath := "n/" + identifier + "/keywords"
|
|
||||||
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write signature (triggers 'u' event)
|
|
||||||
signaturePath := "n/" + identifier + "/signature"
|
|
||||||
if err := p9client.WriteFile(f, signaturePath, meta.Signature); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
347
main.go
347
main.go
|
|
@ -1,10 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"denote/internal/disk"
|
|
||||||
p9client "denote/internal/p9/client"
|
p9client "denote/internal/p9/client"
|
||||||
"denote/pkg/config"
|
|
||||||
"denote/pkg/encoding/frontmatter"
|
|
||||||
"denote/pkg/encoding/results"
|
"denote/pkg/encoding/results"
|
||||||
"denote/pkg/metadata"
|
"denote/pkg/metadata"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -21,20 +18,7 @@ import (
|
||||||
"9fans.net/go/plan9/client"
|
"9fans.net/go/plan9/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const wname = "/Denote/"
|
||||||
wname = "/Denote/"
|
|
||||||
ftype = metadata.FileTypeMdYaml
|
|
||||||
)
|
|
||||||
|
|
||||||
// getDenoteDir returns the current denote directory.
|
|
||||||
func getDenoteDir() string {
|
|
||||||
if dir := os.Getenv("DENOTE_DIR"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
return config.DefaultDenoteDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for 9P operations
|
|
||||||
|
|
||||||
// readIndex reads and parses the index from 9P server.
|
// readIndex reads and parses the index from 9P server.
|
||||||
func readIndex(f *client.Fsys) (metadata.Results, error) {
|
func readIndex(f *client.Fsys) (metadata.Results, error) {
|
||||||
|
|
@ -42,22 +26,7 @@ func readIndex(f *client.Fsys) (metadata.Results, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read index: %w", err)
|
return nil, fmt.Errorf("failed to read index: %w", err)
|
||||||
}
|
}
|
||||||
|
return results.Unmarshal([]byte(indexContent))
|
||||||
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.
|
// setFilter sets or clears the filter on the 9P server.
|
||||||
|
|
@ -69,212 +38,30 @@ func setFilter(f *client.Fsys, filterQuery string) error {
|
||||||
return p9client.WriteFile(f, "ctl", cmd)
|
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 string) error {
|
|
||||||
denoteDir := getDenoteDir()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleServerEvents listens to the denotesrv event stream and handles events
|
|
||||||
func handleServerEvents() {
|
|
||||||
for {
|
|
||||||
err := p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
eventFid, err := f.Open("event", 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer eventFid.Close()
|
|
||||||
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := eventFid.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
line := strings.TrimSpace(string(buf[:n]))
|
|
||||||
parts := strings.SplitN(line, " ", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
eventType := parts[0]
|
|
||||||
identifier := parts[1]
|
|
||||||
|
|
||||||
switch eventType {
|
|
||||||
case "n": // new
|
|
||||||
handleNewEvent(f, identifier)
|
|
||||||
case "u": // update
|
|
||||||
disk.HandleUpdateEvent(f, identifier, getDenoteDir())
|
|
||||||
case "r": // rename
|
|
||||||
disk.HandleRenameEvent(f, identifier, getDenoteDir())
|
|
||||||
case "d": // delete
|
|
||||||
disk.HandleDeleteEvent(f, identifier, getDenoteDir())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("event stream error: %v, reconnecting...", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var err error
|
var err error
|
||||||
var w *acme.Win
|
var w *acme.Win
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
if len(args) == 1 {
|
if len(args) == 1 {
|
||||||
if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok {
|
if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok {
|
||||||
// Find note by identifier using 9P
|
// Plumb the identifier directly (plumbing rules handle the mount)
|
||||||
var notePath string
|
if err := exec.Command("plumb", "denote:"+identifier).Run(); err != nil {
|
||||||
err := p9client.With9P(func(f *client.Fsys) error {
|
log.Fatalf("failed to plumb identifier: %v", err)
|
||||||
// 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
|
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()
|
|
||||||
}
|
}
|
||||||
|
fmt.Println("Usage: Denote [denote:<identifier>]")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to denotesrv, auto-starting if needed
|
// Connect to denotesrv, auto-starting if needed
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
return nil // Just test connection
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
// Try to start denotesrv
|
|
||||||
cmd := exec.Command("denotesrv", "start")
|
cmd := exec.Command("denotesrv", "start")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
log.Fatalf("failed to start denotesrv: %v", err)
|
log.Fatalf("failed to start denotesrv: %v", err)
|
||||||
}
|
}
|
||||||
// Wait for server to be ready
|
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -287,52 +74,43 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start event handler for server events
|
// open window - look for existing /Denote/ window
|
||||||
go handleServerEvents()
|
|
||||||
|
|
||||||
// Start acme log watcher
|
|
||||||
go disk.WatchAcmeLog()
|
|
||||||
|
|
||||||
// open window - look for existing /Denote/ window across all processes
|
|
||||||
if wins, err := acme.Windows(); err == nil {
|
if wins, err := acme.Windows(); err == nil {
|
||||||
for _, winInfo := range wins {
|
for _, winInfo := range wins {
|
||||||
if winInfo.Name == wname {
|
if winInfo.Name == wname {
|
||||||
// Found existing window, open it
|
|
||||||
if w, err = acme.Open(winInfo.ID, nil); err == nil {
|
if w, err = acme.Open(winInfo.ID, nil); err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no existing window found, create new one
|
|
||||||
if w == nil {
|
if w == nil {
|
||||||
if w, err = acme.New(); err != nil {
|
if w, err = acme.New(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if err = w.Name(wname); err != nil {
|
if err = w.Name(wname); err != nil {
|
||||||
w.Del(true)
|
w.Del(true)
|
||||||
log.Fatal(fmt.Errorf("failed to set window name: %w", err))
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer w.CloseFiles()
|
defer w.CloseFiles()
|
||||||
|
|
||||||
if _, err = w.Write("tag", []byte("New Put Remove Get Path")); err != nil {
|
if _, err = w.Write("tag", []byte("New Put Remove Get")); err != nil {
|
||||||
w.Del(true)
|
w.Del(true)
|
||||||
log.Fatal(fmt.Errorf("failed to set tag: %w", err))
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get initial results (clear any filter, read index)
|
// get initial results
|
||||||
var rs metadata.Results
|
var rs metadata.Results
|
||||||
err = p9client.With9P(func(f *client.Fsys) error {
|
err = p9client.With9P(func(f *client.Fsys) error {
|
||||||
if err := setFilter(f, ""); err != nil {
|
if err := setFilter(f, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
rs, err = readIndex(f)
|
rs, err = readIndex(f)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc)
|
metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc)
|
||||||
refreshWindow(w, rs)
|
refreshWindow(w, rs)
|
||||||
|
|
@ -348,55 +126,41 @@ func main() {
|
||||||
case 'x', 'X':
|
case 'x', 'X':
|
||||||
switch string(e.Text) {
|
switch string(e.Text) {
|
||||||
case "New":
|
case "New":
|
||||||
// Read chorded argument
|
|
||||||
input := strings.TrimSpace(string(e.Arg))
|
input := strings.TrimSpace(string(e.Arg))
|
||||||
if input == "" {
|
if input == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to 9p (triggers callback which opens window)
|
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
return p9client.WriteFile(f, "new", input)
|
return p9client.WriteFile(f, "new", input)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("failed to create note: %v", err)
|
log.Printf("failed to create note: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh window and scroll to top to show new note
|
|
||||||
refreshWindowWithDefaults(w)
|
refreshWindowWithDefaults(w)
|
||||||
w.Addr("#0")
|
w.Addr("#0")
|
||||||
w.Ctl("dot=addr")
|
w.Ctl("dot=addr")
|
||||||
w.Ctl("show")
|
w.Ctl("show")
|
||||||
case "Remove":
|
case "Remove":
|
||||||
// Read chorded argument
|
|
||||||
input := strings.TrimSpace(string(e.Arg))
|
input := strings.TrimSpace(string(e.Arg))
|
||||||
if input == "" {
|
if input == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve current position for restoring after refresh
|
|
||||||
w.Ctl("addr=dot")
|
w.Ctl("addr=dot")
|
||||||
q0, q1, _ := w.ReadAddr()
|
q0, q1, _ := w.ReadAddr()
|
||||||
|
|
||||||
// Write to denote/n/<identifier>/ctl via 9P
|
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
return p9client.WriteFile(f, filepath.Join("n", input, "ctl"), "d")
|
return p9client.WriteFile(f, filepath.Join("n", input, "ctl"), "d")
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("failed to delete file: %v", err)
|
log.Printf("failed to delete file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh window and restore position
|
|
||||||
refreshWindowWithDefaults(w)
|
refreshWindowWithDefaults(w)
|
||||||
w.Addr("#%d,#%d", q0, q1)
|
w.Addr("#%d,#%d", q0, q1)
|
||||||
w.Ctl("dot=addr")
|
w.Ctl("dot=addr")
|
||||||
w.Ctl("show")
|
w.Ctl("show")
|
||||||
case "Look":
|
case "Look":
|
||||||
// Use e.Arg for the search arguments
|
|
||||||
performSearch(w, string(e.Arg))
|
performSearch(w, string(e.Arg))
|
||||||
w.Addr("#0")
|
w.Addr("#0")
|
||||||
w.Ctl("dot=addr")
|
w.Ctl("dot=addr")
|
||||||
w.Ctl("show")
|
w.Ctl("show")
|
||||||
case "Get":
|
case "Get":
|
||||||
// Refresh from server
|
|
||||||
refreshWindowWithDefaults(w)
|
refreshWindowWithDefaults(w)
|
||||||
w.Addr("#0")
|
w.Addr("#0")
|
||||||
w.Ctl("dot=addr")
|
w.Ctl("dot=addr")
|
||||||
|
|
@ -407,22 +171,16 @@ func main() {
|
||||||
log.Printf("failed to read window body: %v", err)
|
log.Printf("failed to read window body: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the window content with strict validation
|
|
||||||
updated, err := results.UnmarshalStrict(body)
|
updated, err := results.UnmarshalStrict(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to parse window: %v", err)
|
log.Printf("failed to parse window: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current results and apply changes
|
|
||||||
// Read unfiltered index
|
|
||||||
var current metadata.Results
|
var current metadata.Results
|
||||||
err = p9client.With9P(func(f *client.Fsys) error {
|
err = p9client.With9P(func(f *client.Fsys) error {
|
||||||
if err := setFilter(f, ""); err != nil {
|
if err := setFilter(f, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
current, err = readIndex(f)
|
current, err = readIndex(f)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
@ -430,41 +188,18 @@ func main() {
|
||||||
log.Printf("failed to get current results: %v", err)
|
log.Printf("failed to get current results: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and apply changes via individual file writes
|
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
if err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
return applyIndexChanges(f, current, updated)
|
return applyIndexChanges(f, current, updated)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("failed to apply changes: %v", err)
|
log.Printf("failed to apply changes: %v", err)
|
||||||
}
|
}
|
||||||
case "Path":
|
|
||||||
input := strings.TrimSpace(string(e.Arg))
|
|
||||||
if input == "" {
|
|
||||||
input = strings.TrimSpace(string(e.Text))
|
|
||||||
}
|
|
||||||
if input == "" || !isIdentifier(input) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p9client.With9P(func(f *client.Fsys) error {
|
|
||||||
path, err := p9client.ReadFile(f, "n/"+input+"/path")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read path: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Println(path)
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("path error: %v", err)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
w.WriteEvent(e)
|
w.WriteEvent(e)
|
||||||
}
|
}
|
||||||
case 'l', 'L':
|
case 'l', 'L':
|
||||||
// Check if text matches identifier pattern
|
|
||||||
text := string(e.Text)
|
text := string(e.Text)
|
||||||
if isIdentifier(text) {
|
if isIdentifier(text) {
|
||||||
// Plumb with denote: prefix
|
if err := exec.Command("plumb", "denote:"+text).Run(); err != nil {
|
||||||
if err := exec.Command("plumb", fmt.Sprintf("denote:%s", text)).Run(); err != nil {
|
|
||||||
log.Printf("failed to plumb identifier: %v", err)
|
log.Printf("failed to plumb identifier: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -478,7 +213,6 @@ func main() {
|
||||||
|
|
||||||
func performSearch(w *acme.Win, searchText string) {
|
func performSearch(w *acme.Win, searchText string) {
|
||||||
args := parseArgs(searchText)
|
args := parseArgs(searchText)
|
||||||
|
|
||||||
var filterArgs []string
|
var filterArgs []string
|
||||||
sortBy := metadata.SortById
|
sortBy := metadata.SortById
|
||||||
sortOrder := metadata.SortOrderDesc
|
sortOrder := metadata.SortOrderDesc
|
||||||
|
|
@ -486,22 +220,13 @@ func performSearch(w *acme.Win, searchText string) {
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok {
|
if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok {
|
||||||
parts := strings.Split(sortSpec, ",")
|
parts := strings.Split(sortSpec, ",")
|
||||||
|
|
||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "id", "date":
|
case "id", "date":
|
||||||
sortBy = metadata.SortById
|
sortBy = metadata.SortById
|
||||||
case "title":
|
case "title":
|
||||||
sortBy = metadata.SortByTitle
|
sortBy = metadata.SortByTitle
|
||||||
}
|
}
|
||||||
|
if len(parts) > 1 && parts[1] == "asc" {
|
||||||
if len(parts) > 1 {
|
|
||||||
switch parts[1] {
|
|
||||||
case "asc":
|
|
||||||
sortOrder = metadata.SortOrderAsc
|
|
||||||
case "desc":
|
|
||||||
sortOrder = metadata.SortOrderDesc
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sortOrder = metadata.SortOrderAsc
|
sortOrder = metadata.SortOrderAsc
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -509,10 +234,7 @@ func performSearch(w *acme.Win, searchText string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filter query string for ctl
|
|
||||||
filterQuery := strings.Join(filterArgs, " ")
|
filterQuery := strings.Join(filterArgs, " ")
|
||||||
|
|
||||||
// Set filter and read index
|
|
||||||
var rs metadata.Results
|
var rs metadata.Results
|
||||||
err := p9client.With9P(func(f *client.Fsys) error {
|
err := p9client.With9P(func(f *client.Fsys) error {
|
||||||
if err := setFilter(f, filterQuery); err != nil {
|
if err := setFilter(f, filterQuery); err != nil {
|
||||||
|
|
@ -527,7 +249,6 @@ func performSearch(w *acme.Win, searchText string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
metadata.Sort(rs, sortBy, sortOrder)
|
metadata.Sort(rs, sortBy, sortOrder)
|
||||||
|
|
||||||
refreshWindow(w, rs)
|
refreshWindow(w, rs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -555,7 +276,6 @@ func refreshWindowWithDefaults(w *acme.Win) {
|
||||||
refreshWindow(w, rs)
|
refreshWindow(w, rs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseArgs parses space-separated arguments, handling quoted strings
|
|
||||||
func parseArgs(s string) []string {
|
func parseArgs(s string) []string {
|
||||||
var args []string
|
var args []string
|
||||||
var current strings.Builder
|
var current strings.Builder
|
||||||
|
|
@ -568,19 +288,13 @@ func parseArgs(s string) []string {
|
||||||
escaped = false
|
escaped = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r {
|
switch r {
|
||||||
case '\\':
|
case '\\':
|
||||||
if inQuote {
|
if inQuote && i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
|
||||||
// Look ahead to see if next char is quote or backslash
|
|
||||||
if i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
|
|
||||||
escaped = true
|
escaped = true
|
||||||
} else {
|
} else {
|
||||||
current.WriteRune(r)
|
current.WriteRune(r)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
current.WriteRune(r)
|
|
||||||
}
|
|
||||||
case '"':
|
case '"':
|
||||||
inQuote = !inQuote
|
inQuote = !inQuote
|
||||||
case ' ', '\t', '\n':
|
case ' ', '\t', '\n':
|
||||||
|
|
@ -594,58 +308,37 @@ func parseArgs(s string) []string {
|
||||||
current.WriteRune(r)
|
current.WriteRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add final argument if any
|
|
||||||
if current.Len() > 0 {
|
if current.Len() > 0 {
|
||||||
args = append(args, current.String())
|
args = append(args, current.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from args
|
|
||||||
for i := range args {
|
for i := range args {
|
||||||
args[i] = strings.TrimFunc(args[i], unicode.IsSpace)
|
args[i] = strings.TrimFunc(args[i], unicode.IsSpace)
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error {
|
func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error {
|
||||||
// Validate entry count matches
|
|
||||||
if len(updated) != len(current) {
|
if len(updated) != len(current) {
|
||||||
return fmt.Errorf("entry count mismatch: got %d, expected %d", 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)
|
currentMap := make(map[string]*metadata.Metadata)
|
||||||
for _, m := range current {
|
for _, m := range current {
|
||||||
currentMap[m.Identifier] = m
|
currentMap[m.Identifier] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write title and keywords for changed entries
|
|
||||||
for _, upd := range updated {
|
for _, upd := range updated {
|
||||||
orig, exists := currentMap[upd.Identifier]
|
orig, exists := currentMap[upd.Identifier]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("identifier '%s' not found", upd.Identifier)
|
return fmt.Errorf("identifier '%s' not found", upd.Identifier)
|
||||||
}
|
}
|
||||||
|
if orig.Title != upd.Title || !slices.Equal(orig.Tags, upd.Tags) {
|
||||||
// Check if anything changed
|
if err := p9client.WriteFile(f, "n/"+upd.Identifier+"/title", upd.Title); err != nil {
|
||||||
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)
|
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 {
|
||||||
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 fmt.Errorf("failed to write keywords for %s: %w", upd.Identifier, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
mkfile
2
mkfile
|
|
@ -1,6 +1,6 @@
|
||||||
install:V:
|
install:V:
|
||||||
go build -o $HOME/bin/Denote .
|
go build -o $HOME/bin/Denote .
|
||||||
go build -o $HOME/bin/Drn cmd/Drn/main.go
|
cp scripts/Drn $HOME/bin/Drn
|
||||||
cp scripts/Djournal $HOME/bin/Djournal
|
cp scripts/Djournal $HOME/bin/Djournal
|
||||||
cp scripts/Dmerge $HOME/bin/Dmerge
|
cp scripts/Dmerge $HOME/bin/Dmerge
|
||||||
cp scripts/Dbkp $HOME/bin/Dbkp
|
cp scripts/Dbkp $HOME/bin/Dbkp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/local/plan9/bin/rc
|
||||||
|
# Drn - rename denote note metadata
|
||||||
|
# usage: Drn [identifier] 'title' [==signature] [tags]
|
||||||
|
|
||||||
|
rfork e
|
||||||
|
|
||||||
|
fn usage {
|
||||||
|
echo 'usage: Drn [identifier] ''title'' [==signature] [tags]' >[1=2]
|
||||||
|
exit usage
|
||||||
|
}
|
||||||
|
|
||||||
|
if(~ $#* 0) usage
|
||||||
|
|
||||||
|
# Check if first arg is identifier
|
||||||
|
id=()
|
||||||
|
if(~ $1 [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]) {
|
||||||
|
id=$1
|
||||||
|
shift
|
||||||
|
}
|
||||||
|
if not {
|
||||||
|
# Extract from window tag
|
||||||
|
if(~ $winid '') {
|
||||||
|
echo 'Drn: $winid not set' >[1=2]
|
||||||
|
exit 'no winid'
|
||||||
|
}
|
||||||
|
tag=`{9p read acme/$winid/tag}
|
||||||
|
id=`{echo $tag | grep -o '[0-9]\{8\}T[0-9]\{6\}'}
|
||||||
|
if(~ $#id 0) {
|
||||||
|
echo 'Drn: no identifier in window' >[1=2]
|
||||||
|
exit 'no id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(~ $#* 0) usage
|
||||||
|
|
||||||
|
# Parse: 'title' [==sig] [tags] or ==sig 'title' [tags]
|
||||||
|
sig=''
|
||||||
|
title=''
|
||||||
|
tags=''
|
||||||
|
|
||||||
|
args=$"*
|
||||||
|
|
||||||
|
# Check for leading ==signature
|
||||||
|
if(~ $1 ==*) {
|
||||||
|
sig=`{echo $1 | sed 's/^==//'}
|
||||||
|
shift
|
||||||
|
args=$"*
|
||||||
|
}
|
||||||
|
|
||||||
|
# Title is first arg (should be quoted in input)
|
||||||
|
title=$1
|
||||||
|
shift
|
||||||
|
|
||||||
|
# Remaining args
|
||||||
|
for(a in $*) {
|
||||||
|
if(~ $a ==*) {
|
||||||
|
sig=`{echo $a | sed 's/^==//'}
|
||||||
|
}
|
||||||
|
if not {
|
||||||
|
if(~ $tags '') {
|
||||||
|
tags=$a
|
||||||
|
}
|
||||||
|
if not {
|
||||||
|
tags=$tags^','$a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write to 9P
|
||||||
|
echo -n $title | 9p write denote/n/$id/title
|
||||||
|
echo -n $tags | 9p write denote/n/$id/keywords
|
||||||
|
echo -n $sig | 9p write denote/n/$id/signature
|
||||||
Loading…
Reference in New Issue