denote-acme/cmd/Drn/main.go

421 lines
12 KiB
Go

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
}