421 lines
12 KiB
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
|
|
}
|