rename becomes its own binary

This commit is contained in:
Levi Neely 2025-11-26 18:16:05 +01:00
parent b080679ec9
commit 3e7e625051
14 changed files with 307 additions and 162 deletions

View File

@ -103,6 +103,21 @@ Middle-click `Put` to write all metadata changes. This will rename files and, wh
Reload all notes from disk, discarding any uncommitted changes in the 9P metadata. Middle-click `Get` to do this. This is useful when notes are modified outside of Acme or when you want to discard metadata changes.
### Drename
Update note metadata (title, tags, signature) from any note window:
```
Drename 'new title' tag1,tag2 ==newsig
```
This command works for all files in the denote directory:
- **Regular notes** (.md, .org, .txt): Updates frontmatter in window + renames file
- **Binary files** (PDFs, images): Just renames file
- **Encrypted files** (.gpg): Updates frontmatter in window + renames file
The window must be saved (Put/CryptPut) after Drename to persist changes to disk.
## File Format
By default notes are markdown files with YAML frontmatter:

262
cmd/Drename/main.go Normal file
View File

@ -0,0 +1,262 @@
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 {
// Create new frontmatter
fm := &metadata.FrontMatter{
Title: title,
Tags: tags,
Signature: signature,
}
// Update frontmatter in content
newContent, err := metadata.UpdateFrontMatter(string(body), fm)
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
}

View File

@ -2,7 +2,7 @@ package disk
import (
"bytes"
"denote/internal/metadata"
"denote/pkg/metadata"
"os/exec"
"path/filepath"
"strings"

View File

@ -1,15 +1,15 @@
package disk
import (
"denote/internal/metadata"
"denote/pkg/metadata"
p9client "denote/internal/p9/client"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"9fans.net/go/acme"
"9fans.net/go/plan9/client"
)
@ -100,61 +100,13 @@ func HandleUpdateEvent(f *client.Fsys, identifier, denoteDir string) error {
}
// HandleRenameEvent handles 'r' events from the 9P server.
// When path is updated in 9P, rename the file on the filesystem.
// Delegates to Drename binary to perform the actual rename.
func HandleRenameEvent(f *client.Fsys, identifier, denoteDir string) 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)
cmd := exec.Command("Drename", "--id="+identifier)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("rename failed: %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
}
@ -194,27 +146,3 @@ func HandleDeleteEvent(f *client.Fsys, identifier, denoteDir string) error {
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
}

View File

@ -1,7 +1,7 @@
package disk
import (
"denote/internal/metadata"
"denote/pkg/metadata"
"fmt"
"os"
"path/filepath"

View File

@ -7,7 +7,7 @@ the file name.
package disk
import (
"denote/internal/metadata"
"denote/pkg/metadata"
p9client "denote/internal/p9/client"
"fmt"
"log"

View File

@ -32,7 +32,7 @@ package fs
import (
"denote/internal/disk"
"denote/internal/metadata"
"denote/pkg/metadata"
"fmt"
"io"
"net"

View File

@ -2,7 +2,7 @@ package main
import (
"denote/internal/disk"
"denote/internal/metadata"
"denote/pkg/metadata"
p9client "denote/internal/p9/client"
p9server "denote/internal/p9/server"
"fmt"
@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"unicode"
@ -552,7 +553,7 @@ func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error
// Check if anything changed
titleChanged := orig.Title != upd.Title
tagsChanged := !metadata.SlicesEqual(orig.Tags, upd.Tags)
tagsChanged := !slices.Equal(orig.Tags, upd.Tags)
if titleChanged || tagsChanged {
// Write both title and keywords

3
mkfile
View File

@ -1,7 +1,8 @@
install:V:
go build -o $HOME/bin/Denote .
go build -o $HOME/bin/Drename cmd/Drename/main.go
cp scripts/Djournal $HOME/bin/Djournal
cp scripts/Dmerge $HOME/bin/Dmerge
clean:V:
rm -f $HOME/bin/Denote $HOME/bin/Djournal $HOME/bin/Dmerge
rm -f $HOME/bin/Denote $HOME/bin/Drename $HOME/bin/Djournal $HOME/bin/Dmerge

View File

@ -44,9 +44,9 @@ signature: %s
`,
}
// FileExtensions contains the list of file extensions
// fileExtensions contains the list of file extensions
// for which Denote should add front matter.
var FileExtensions = map[string]string{
var fileExtensions = map[string]string{
"org": ".org",
"md-yaml": ".md",
"md-toml": ".md",

View File

@ -1,6 +1,7 @@
package metadata
import (
"slices"
"strings"
"testing"
)
@ -346,7 +347,7 @@ identifier: 20240101T120000
t.Errorf("ParseFrontMatter().Title = %q, want %q", got.Title, tt.wantTitle)
}
if !SlicesEqual(got.Tags, tt.wantTags) {
if !slices.Equal(got.Tags, tt.wantTags) {
t.Errorf("ParseFrontMatter().Tags = %v, want %v", got.Tags, tt.wantTags)
}
@ -608,12 +609,12 @@ func TestFileExtensions(t *testing.T) {
for _, tt := range tests {
t.Run(tt.fileType, func(t *testing.T) {
got, ok := FileExtensions[tt.fileType]
got, ok := fileExtensions[tt.fileType]
if !ok {
t.Errorf("FileExtensions[%q] not found", tt.fileType)
t.Errorf("fileExtensions[%q] not found", tt.fileType)
}
if got != tt.want {
t.Errorf("FileExtensions[%q] = %q, want %q", tt.fileType, got, tt.want)
t.Errorf("fileExtensions[%q] = %q, want %q", tt.fileType, got, tt.want)
}
})
}
@ -632,7 +633,7 @@ func TestFrontMatterRoundTrip(t *testing.T) {
content := Generate(title, "", tags, fileType, identifier)
// Parse it back
ext := FileExtensions[fileType]
ext := fileExtensions[fileType]
fm, err := ParseFrontMatter(content, ext)
if err != nil {
t.Fatalf("ParseFrontMatter() error = %v", err)
@ -642,7 +643,7 @@ func TestFrontMatterRoundTrip(t *testing.T) {
if fm.Title != title {
t.Errorf("Parsed title = %q, want %q", fm.Title, title)
}
if !SlicesEqual(fm.Tags, tags) {
if !slices.Equal(fm.Tags, tags) {
t.Errorf("Parsed tags = %v, want %v", fm.Tags, tags)
}
if fm.Identifier != identifier {
@ -672,7 +673,7 @@ func TestFrontMatterRoundTrip(t *testing.T) {
if fm2.Title != newTitle {
t.Errorf("After update title = %q, want %q", fm2.Title, newTitle)
}
if !SlicesEqual(fm2.Tags, newTags) {
if !slices.Equal(fm2.Tags, newTags) {
t.Errorf("After update tags = %v, want %v", fm2.Tags, newTags)
}
})

View File

@ -4,7 +4,6 @@ import (
"fmt"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"time"
@ -81,12 +80,6 @@ func (es Results) FromString(data string) (Results, error) {
return results, nil
}
// SlicesEqual compares two string slices for equality.
// Deprecated: Use slices.Equal from standard library instead.
func SlicesEqual(a, b []string) bool {
return slices.Equal(a, b)
}
type SortBy string
const (
@ -247,7 +240,7 @@ func BuildFilename(identifier, signature, title string, keywords []string, ext s
// Returns (path, content).
func GenerateNote(dir, title, signature string, keywords []string, fileType string) (string, string) {
identifier := GenerateIdentifier()
ext := FileExtensions[fileType]
ext := fileExtensions[fileType]
filename := BuildFilename(identifier, signature, title, keywords, ext)
path := filepath.Join(dir, filename)
content := Generate(title, signature, keywords, fileType, identifier)

View File

@ -2,6 +2,7 @@ package metadata
import (
"regexp"
"slices"
"strings"
"testing"
"time"
@ -396,7 +397,7 @@ func TestParseFilename(t *testing.T) {
tt.path, got.Title, tt.wantTitle)
}
if !SlicesEqual(got.Tags, tt.wantTags) {
if !slices.Equal(got.Tags, tt.wantTags) {
t.Errorf("ParseFilename(%q).Tags = %v, want %v",
tt.path, got.Tags, tt.wantTags)
}
@ -764,7 +765,7 @@ func TestResultsFromString(t *testing.T) {
if got[i].Title != tt.want[i].Title {
t.Errorf("Result[%d].Title = %q, want %q", i, got[i].Title, tt.want[i].Title)
}
if !SlicesEqual(got[i].Tags, tt.want[i].Tags) {
if !slices.Equal(got[i].Tags, tt.want[i].Tags) {
t.Errorf("Result[%d].Tags = %v, want %v", i, got[i].Tags, tt.want[i].Tags)
}
}
@ -772,63 +773,6 @@ func TestResultsFromString(t *testing.T) {
}
}
// TestSlicesEqual validates slice comparison
func TestSlicesEqual(t *testing.T) {
tests := []struct {
name string
a []string
b []string
want bool
}{
{
name: "equal slices",
a: []string{"a", "b", "c"},
b: []string{"a", "b", "c"},
want: true,
},
{
name: "different length",
a: []string{"a", "b"},
b: []string{"a", "b", "c"},
want: false,
},
{
name: "different content",
a: []string{"a", "b", "c"},
b: []string{"a", "x", "c"},
want: false,
},
{
name: "both nil",
a: nil,
b: nil,
want: true,
},
{
name: "both empty",
a: []string{},
b: []string{},
want: true,
},
{
// slices.Equal treats nil and empty slices as equal (both have len 0)
name: "nil vs empty",
a: nil,
b: []string{},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SlicesEqual(tt.a, tt.b)
if got != tt.want {
t.Errorf("SlicesEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
// TestGenerateNote validates note generation (integration test)
func TestGenerateNote(t *testing.T) {
t.Run("without signature", func(t *testing.T) {