267 lines
7.3 KiB
Go
267 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"denote/pkg/metadata"
|
|
p9client "denote/internal/p9/client"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"9fans.net/go/acme"
|
|
"9fans.net/go/plan9/client"
|
|
)
|
|
|
|
var denoteDir = os.Getenv("HOME") + "/doc"
|
|
|
|
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, "drename: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Window mode (called by user from acme window)
|
|
if err := windowMode(os.Args[1:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "drename: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// 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 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 (matches with or without signature)
|
|
pattern := filepath.Join(denoteDir, 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(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 := 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
|
|
})
|
|
}
|
|
|
|
// 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: 'Title' ==signature tag1,tag2,...
|
|
input := strings.Join(args, " ")
|
|
|
|
// Extract title (must be single-quoted)
|
|
if !strings.HasPrefix(input, "'") {
|
|
return fmt.Errorf("title must be single-quoted")
|
|
}
|
|
|
|
closeQuote := strings.Index(input[1:], "'")
|
|
if closeQuote == -1 {
|
|
return fmt.Errorf("title must be single-quoted (missing closing quote)")
|
|
}
|
|
|
|
title := input[1 : closeQuote+1]
|
|
if title == "" {
|
|
return fmt.Errorf("title cannot be empty")
|
|
}
|
|
|
|
// Extract signature and tags (everything after closing quote)
|
|
remainder := strings.TrimSpace(input[closeQuote+2:])
|
|
var signature string
|
|
var tags []string
|
|
|
|
if remainder != "" {
|
|
// Check if signature is present (starts with ==)
|
|
if 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 fmt.Errorf("tags must be comma-delimited lowercase unicode words (no spaces)")
|
|
}
|
|
tags = strings.Split(remainder, ",")
|
|
}
|
|
}
|
|
|
|
// 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, err := metadata.ParseFrontMatter(string(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 := metadata.Apply(string(body), existing)
|
|
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
|
|
}
|