diff --git a/README.md b/README.md index 388ff6f..a867b50 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/Drename/main.go b/cmd/Drename/main.go new file mode 100644 index 0000000..e784a97 --- /dev/null +++ b/cmd/Drename/main.go @@ -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 +} diff --git a/internal/disk/backlinks.go b/internal/disk/backlinks.go index 5dafe16..bfbe813 100644 --- a/internal/disk/backlinks.go +++ b/internal/disk/backlinks.go @@ -2,7 +2,7 @@ package disk import ( "bytes" - "denote/internal/metadata" + "denote/pkg/metadata" "os/exec" "path/filepath" "strings" diff --git a/internal/disk/events.go b/internal/disk/callbacks.go similarity index 63% rename from internal/disk/events.go rename to internal/disk/callbacks.go index 0e3bbd4..11c9ad5 100644 --- a/internal/disk/events.go +++ b/internal/disk/callbacks.go @@ -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 -} diff --git a/internal/disk/loader.go b/internal/disk/loader.go index 138c5a7..7390965 100644 --- a/internal/disk/loader.go +++ b/internal/disk/loader.go @@ -1,7 +1,7 @@ package disk import ( - "denote/internal/metadata" + "denote/pkg/metadata" "fmt" "os" "path/filepath" diff --git a/internal/disk/sync.go b/internal/disk/sync.go index 18157e2..24c194d 100644 --- a/internal/disk/sync.go +++ b/internal/disk/sync.go @@ -7,7 +7,7 @@ the file name. package disk import ( - "denote/internal/metadata" + "denote/pkg/metadata" p9client "denote/internal/p9/client" "fmt" "log" diff --git a/internal/p9/server/server.go b/internal/p9/server/server.go index 57754b0..17cceef 100644 --- a/internal/p9/server/server.go +++ b/internal/p9/server/server.go @@ -32,7 +32,7 @@ package fs import ( "denote/internal/disk" - "denote/internal/metadata" + "denote/pkg/metadata" "fmt" "io" "net" diff --git a/main.go b/main.go index c666a78..2255304 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/mkfile b/mkfile index ea4fef9..2191e8b 100644 --- a/mkfile +++ b/mkfile @@ -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 \ No newline at end of file + rm -f $HOME/bin/Denote $HOME/bin/Drename $HOME/bin/Djournal $HOME/bin/Dmerge \ No newline at end of file diff --git a/internal/metadata/filter.go b/pkg/metadata/filter.go similarity index 100% rename from internal/metadata/filter.go rename to pkg/metadata/filter.go diff --git a/internal/metadata/frontmatter.go b/pkg/metadata/frontmatter.go similarity index 98% rename from internal/metadata/frontmatter.go rename to pkg/metadata/frontmatter.go index aeb59f8..852109f 100644 --- a/internal/metadata/frontmatter.go +++ b/pkg/metadata/frontmatter.go @@ -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", diff --git a/internal/metadata/frontmatter_test.go b/pkg/metadata/frontmatter_test.go similarity index 97% rename from internal/metadata/frontmatter_test.go rename to pkg/metadata/frontmatter_test.go index 3c5c6ea..306a18d 100644 --- a/internal/metadata/frontmatter_test.go +++ b/pkg/metadata/frontmatter_test.go @@ -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) } }) diff --git a/internal/metadata/metadata.go b/pkg/metadata/metadata.go similarity index 96% rename from internal/metadata/metadata.go rename to pkg/metadata/metadata.go index 6440808..031e822 100644 --- a/internal/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -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) diff --git a/internal/metadata/metadata_test.go b/pkg/metadata/metadata_test.go similarity index 94% rename from internal/metadata/metadata_test.go rename to pkg/metadata/metadata_test.go index 3b0d60c..da8e230 100644 --- a/internal/metadata/metadata_test.go +++ b/pkg/metadata/metadata_test.go @@ -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) {