Remove disk sync, use 9pfuse mount

- Remove internal/disk package (sync, callbacks, loader, backlinks)
- Remove event handlers for disk operations
- Simplify main.go to just 9P metadata operations
- Convert Drn from Go to rc script (50 lines vs 170)
- Plumbing now opens notes via ~/mnt/denote/n/<id>/body
This commit is contained in:
Levi Neely 2026-03-20 18:00:23 +01:00
parent 264ac251ae
commit 20bc5324a6
8 changed files with 94 additions and 1528 deletions

View File

@ -1,420 +0,0 @@
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
}

View File

@ -1,74 +0,0 @@
package disk
import (
"bytes"
"denote/pkg/metadata"
"os/exec"
"path/filepath"
"strings"
)
func FindBacklinks(targetID, denoteDir string, allNotes metadata.Results) metadata.Results {
searchPattern := "denote:" + targetID
duCmd := exec.Command("9", "du", "-a")
duCmd.Dir = denoteDir
awkCmd := exec.Command("9", "awk", "$2 ~ /\\.(md|org|txt)$/ {print $2}")
xargsCmd := exec.Command("9", "xargs", "9", "grep", "-l", searchPattern)
xargsCmd.Dir = denoteDir
duOut, err := duCmd.StdoutPipe()
if err != nil {
return nil
}
awkCmd.Stdin = duOut
awkOut, err := awkCmd.StdoutPipe()
if err != nil {
return nil
}
xargsCmd.Stdin = awkOut
var output bytes.Buffer
xargsCmd.Stdout = &output
if err := duCmd.Start(); err != nil {
return nil
}
if err := awkCmd.Start(); err != nil {
return nil
}
if err := xargsCmd.Start(); err != nil {
return nil
}
duCmd.Wait()
awkCmd.Wait()
xargsCmd.Wait()
result := strings.TrimSpace(output.String())
if result == "" {
return nil
}
var results metadata.Results
lines := strings.Split(result, "\n")
for _, line := range lines {
line = strings.TrimPrefix(line, "./")
m := metadata.ParseFilename(filepath.Join(denoteDir, line))
if m.Identifier == "" {
continue
}
for _, note := range allNotes {
if note.Identifier == m.Identifier {
results = append(results, note)
break
}
}
}
return results
}

View File

@ -1,169 +0,0 @@
package disk
import (
p9client "denote/internal/p9/client"
"denote/pkg/metadata"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"9fans.net/go/plan9/client"
)
// HandleUpdateEvent handles 'u' events from the 9P server.
// When metadata is updated in 9P, sync it to the filesystem.
func HandleUpdateEvent(f *client.Fsys, identifier, denoteDir string) error {
fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "signature", "path")
if err != nil {
return err
}
title := fields["title"]
keywords := fields["keywords"]
signature := fields["signature"]
path := fields["path"]
var tags []string
if keywords != "" {
for _, tag := range strings.Split(keywords, ",") {
tags = append(tags, strings.TrimSpace(tag))
}
}
// Only update if file exists (skip if not yet created via Put)
if _, err := os.Stat(path); err != nil {
return nil
}
// Determine directory and extension
dir := filepath.Dir(path)
if dir == "." {
dir = denoteDir
}
ext := GetFullExtension(path)
var fileType metadata.FileType
var fm *metadata.FrontMatter
// For files with frontmatter, parse and update it
if SupportsFrontMatter(path) {
// Parse existing frontmatter to get FileType and current metadata
existing, ftype, err := ExtractFrontMatter(path)
if err != nil {
log.Printf("failed to extract frontmatter for %s: %v", identifier, err)
return nil
}
// Update fields from 9P metadata
existing.Title = title
existing.Tags = tags
existing.Signature = signature
fm = existing
fileType = ftype
// Preserve compound extensions (e.g., .md.gpg)
// Replace content extension but keep wrapper layers
contentExt := GetContentExtension(path)
typeExt := metadata.GetExtension(fileType)
if contentExt != ext {
// Compound extension: replace content part with type extension
ext = strings.Replace(ext, contentExt, typeExt, 1)
} else {
// Simple extension: use type extension
ext = typeExt
}
} else {
// For binary files, build metadata from 9P data
fm = &metadata.FrontMatter{
Identifier: identifier,
Title: title,
Tags: tags,
Signature: signature,
}
}
// Build new filename using updated metadata
filename := metadata.BuildFilename(fm, ext)
newPath := filepath.Join(dir, filename)
// If filename changed, update path in 9P (which will trigger rename)
if newPath != path {
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 := p9client.WriteFile(f, "n/"+identifier+"/path", newPath); err != nil {
return fmt.Errorf("failed to update path in 9p: %w", err)
}
}
// Apply updated frontmatter to file content (only for supported file types)
if SupportsFrontMatter(path) && fm != nil {
if err := UpdateFrontMatter(path, fm, fileType); err != nil {
log.Printf("failed to update front matter for %s: %v", identifier, err)
}
}
return nil
}
// HandleRenameEvent handles 'r' events from the 9P server.
// Delegates to Drn binary to perform the actual rename.
func HandleRenameEvent(f *client.Fsys, identifier, denoteDir string) error {
cmd := exec.Command("Drn", "--id="+identifier)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("rename failed: %w", err)
}
return nil
}
// HandleDeleteEvent handles 'd' events from the 9P server.
// When a note is deleted in 9P, delete the file from the filesystem.
func HandleDeleteEvent(f *client.Fsys, identifier, denoteDir string) error {
// Get path from metadata to find correct directory
path, err := p9client.ReadFile(f, "n/"+identifier+"/path")
if err != nil || path == "" {
// Fallback to denoteDir if path not set
path = denoteDir
}
// Use directory from metadata path
dir := filepath.Dir(path)
if dir == "." {
dir = denoteDir
}
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 {
return nil
}
if len(matches) > 1 {
log.Printf("warning: multiple files match identifier %s, deleting first: %s", identifier, matches[0])
}
if err := os.Remove(matches[0]); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}

View File

@ -1,188 +0,0 @@
package disk
import (
"denote/pkg/encoding/frontmatter"
"denote/pkg/metadata"
"denote/pkg/util"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
// GetFullExtension returns the full extension, including compound extensions.
// For example: "file.md.gpg" returns ".md.gpg", "file.tar.gz" returns ".tar.gz"
func GetFullExtension(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return ""
}
// Check if there's another extension before this one
pathWithoutExt := strings.TrimSuffix(path, ext)
innerExt := filepath.Ext(pathWithoutExt)
if innerExt != "" {
// Compound extension found, return both parts
return innerExt + ext
}
return ext
}
// GetContentExtension returns the content type extension, stripping encryption/compression layers.
// For example: "file.md.gpg" returns ".md", "file.tar.gz" returns ".tar", "file.md" returns ".md"
func GetContentExtension(path string) string {
fullExt := GetFullExtension(path)
// For compound extensions, take the first part
parts := strings.Split(fullExt, ".")
if len(parts) >= 3 { // ["", "md", "gpg"] for ".md.gpg"
return "." + parts[1]
}
return fullExt
}
// SupportsFrontMatter returns true if the file extension supports frontmatter.
// Uses the actual extension, not the content extension, so encrypted files (.md.gpg)
// are correctly treated as binary files.
func SupportsFrontMatter(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return ext == ".org" || ext == ".md" || ext == ".txt"
}
// extractTitleFromContent extracts the title from file content.
// ext should be the file extension (e.g., ".md", ".org", ".txt").
// Returns empty string if no title found or unsupported extension.
func extractTitleFromContent(content string, ext string) string {
ext = strings.ToLower(ext)
if ext != ".org" && ext != ".md" && ext != ".txt" {
return ""
}
// Try org-mode #+title: first, then fall back to first heading
if ext == ".org" {
if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(content); m != nil {
return strings.TrimSpace(m[1])
}
// Fallback to first heading (lines starting with *)
if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(content); m != nil {
return strings.TrimSpace(m[1])
}
}
// Try markdown YAML front matter title: first, then fall back to # header
if ext == ".md" {
if m := regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`).FindStringSubmatch(content); m != nil {
return strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(content); m != nil {
return strings.TrimSpace(m[1])
}
}
return ""
}
// ExtractMetadata extracts metadata from a file (combines filename and content parsing).
// This is the I/O wrapper around metadata's pure functions.
func ExtractMetadata(path string) (*metadata.Metadata, error) {
// Parse filename (no I/O)
note := metadata.ParseFilename(path)
// Check if we should try to read file content for title
ext := strings.ToLower(filepath.Ext(path))
// Don't read unsupported file types
if ext != ".org" && ext != ".md" && ext != ".txt" {
return note, nil
}
// Read file content
content, err := os.ReadFile(path)
if err != nil {
// If we can't read the file, just use filename metadata
return note, nil
}
// Try to extract title from content
if title := extractTitleFromContent(string(content), ext); title != "" {
note.Title = title
}
return note, nil
}
// ExtractFrontMatter reads a file and parses its front matter.
// Returns the parsed FrontMatter and the detected FileType.
func ExtractFrontMatter(path string) (*metadata.FrontMatter, metadata.FileType, error) {
ext := strings.ToLower(filepath.Ext(path))
content, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
return frontmatter.Unmarshal(content, ext)
}
// UpdateFrontMatter updates the front matter in a file.
func UpdateFrontMatter(path string, fm *metadata.FrontMatter, fileType metadata.FileType) error {
// Only update frontmatter for supported file types
if !SupportsFrontMatter(path) {
return nil
}
// Read current content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Apply front matter to content
newContent, err := util.Apply(string(content), fm, fileType)
if err != nil {
return fmt.Errorf("failed to apply front matter: %w", err)
}
// Write back to file
return os.WriteFile(path, []byte(newContent), 0644)
}
// LoadAll walks a directory and extracts metadata from all denote files.
func LoadAll(dir string) (metadata.Results, error) {
var notes metadata.Results
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
note, err := ExtractMetadata(path)
if err != nil {
return err
}
// Only include files with valid identifiers
if note.Identifier != "" {
// Warn on invalid tags but continue loading
if invalid := metadata.ValidateTags(note.Tags); len(invalid) > 0 {
log.Printf("warning: %s:1 invalid tags %v (must be lowercase alphanumeric)", path, invalid)
}
notes = append(notes, note)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return notes, nil
}

View File

@ -1,348 +0,0 @@
/*
The sync package synchronizes the underlying filesystem with the 9P virtual filesystem by loading
the denote metadata from the underlying filesystem, and refreshing the in-memory metadata. Files
with denote front matter use the front matter, and all other files use the metadata encoded in
the file name.
*/
package disk
import (
p9client "denote/internal/p9/client"
"denote/pkg/config"
"denote/pkg/metadata"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"9fans.net/go/acme"
"9fans.net/go/plan9/client"
)
// denoteFilePattern matches denote filenames: YYYYMMDDTHHMMSS--title__tags.ext
var denoteFilePattern = regexp.MustCompile(`^(\d{8}T\d{6})--`)
// WatchAcmeLog monitors Acme's log for Put events and syncs denote file
// front matter changes to the 9P filesystem metadata.
func WatchAcmeLog() {
for {
alog, err := acme.Log()
if err != nil {
log.Printf("sync: failed to open acme log: %v", err)
return
}
for {
ev, err := alog.Read()
if err != nil {
log.Printf("sync: error reading acme log: %v", err)
alog.Close()
break
}
// Only handle Put events
if ev.Op != "put" {
continue
}
// Check if this is a denote file
if !isDenoteFile(ev.Name) {
continue
}
// Extract identifier from filename
identifier := extractIdentifier(ev.Name)
if identifier == "" {
continue
}
// Check if note exists in 9P
exists := noteExists(identifier)
if exists {
// Existing note: sync front matter if supported (triggers 'u' event)
if SupportsFrontMatter(ev.Name) {
if err := syncFrontMatter(ev.Name, identifier); err != nil {
log.Printf("sync: failed to sync %s: %v", ev.Name, err)
}
}
} else {
// New note: register with 9P (triggers 'n' event)
if err := registerNewNote(ev.Name); err != nil {
log.Printf("sync: failed to register %s: %v", ev.Name, err)
}
}
}
}
}
// isDenoteFile checks if the file is in the denote directory
func isDenoteFile(path string) bool {
// Try to read current dir from 9p, fall back to default
denoteDir := config.DefaultDenoteDir
err := p9client.With9P(func(f *client.Fsys) error {
dir, readErr := p9client.ReadFile(f, "dir")
if readErr == nil && dir != "" {
denoteDir = strings.TrimSpace(dir)
}
return nil // Don't propagate error, just use fallback
})
_ = err // Ignore connection errors, use default
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
return strings.HasPrefix(absPath, denoteDir)
}
// extractIdentifier extracts the identifier from a denote filename
func extractIdentifier(path string) string {
filename := filepath.Base(path)
matches := denoteFilePattern.FindStringSubmatch(filename)
if len(matches) < 2 {
return ""
}
return matches[1]
}
// noteExists checks if a note exists in 9P
func noteExists(identifier string) bool {
err := p9client.With9P(func(f *client.Fsys) error {
titlePath := "n/" + identifier + "/title"
fid, err := f.Open(titlePath, 0)
if err != nil {
return err
}
fid.Close()
return nil
})
return err == nil
}
// registerNewNote writes to /new to register a new note (triggers 'n' event)
func registerNewNote(path string) error {
meta, err := ExtractMetadata(path)
if err != nil {
return err
}
// Format as 'title' tag1,tag2
var newInput string
if len(meta.Tags) > 0 {
newInput = fmt.Sprintf("'%s' %s", meta.Title, strings.Join(meta.Tags, ","))
} else {
newInput = fmt.Sprintf("'%s'", meta.Title)
}
return p9client.With9P(func(f *client.Fsys) error {
return p9client.WriteFile(f, "new", newInput)
})
}
// syncFrontMatter reads the file's front matter and writes it to 9P metadata
func syncFrontMatter(path, identifier string) error {
// Extract front matter from file
fm, _, err := ExtractFrontMatter(path)
if err != nil {
return err
}
// Write to 9P filesystem
return p9client.With9P(func(f *client.Fsys) error {
// Write title (triggers 'u' event)
titlePath := "n/" + identifier + "/title"
if err := p9client.WriteFile(f, titlePath, fm.Title); err != nil {
return err
}
// Write keywords (triggers 'u' event)
keywords := strings.Join(fm.Tags, ",")
keywordsPath := "n/" + identifier + "/keywords"
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
return err
}
// Trigger rename event
ctlPath := "n/" + identifier + "/ctl"
if err := p9client.WriteFile(f, ctlPath, "r"); err != nil {
return err
}
return nil
})
}
// UpdateMetadataFunc is a function that updates metadata for a note
type UpdateMetadataFunc func(identifier, title, keywords, signature string) error
// GetAll reloads all notes from disk to 9P (discards uncommitted 9P changes)
func GetAll(updateFunc UpdateMetadataFunc) error {
return p9client.With9P(func(f *client.Fsys) error {
// Read index to get all identifiers
indexFid, err := f.Open("index", 0)
if err != nil {
return err
}
defer indexFid.Close()
var content []byte
buf := make([]byte, 8192)
for {
n, err := indexFid.Read(buf)
if err != nil && err.Error() != "EOF" {
return err
}
if n == 0 {
break
}
content = append(content, buf[:n]...)
}
// Parse identifiers from index
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Parse identifier from line (format: "denote:ID | title | tags")
parts := strings.SplitN(line, "|", 2)
if len(parts) < 1 {
continue
}
identifier := strings.TrimSpace(strings.TrimPrefix(parts[0], "denote:"))
if identifier == "" {
continue
}
// Read path
pathFid, err := f.Open("n/"+identifier+"/path", 0)
if err != nil {
log.Printf("sync: failed to read path for %s: %v", identifier, err)
continue
}
pathBuf := make([]byte, 8192)
n, _ := pathFid.Read(pathBuf)
pathFid.Close()
path := strings.TrimSpace(string(pathBuf[:n]))
// Check if file exists on filesystem
if _, err := os.Stat(path); os.IsNotExist(err) {
// Path in metadata doesn't exist - search for actual file on disk
// Use the directory from the metadata path (handles subdirs like journal/)
dir := filepath.Dir(path)
pattern := filepath.Join(dir, identifier+"*")
matches, err := filepath.Glob(pattern)
if err != nil {
log.Printf("sync: failed to glob for %s: %v", identifier, err)
continue
}
if len(matches) > 0 {
// File exists (maybe encrypted or renamed), update metadata path
actualPath := matches[0]
if err := p9client.WriteFile(f, "n/"+identifier+"/path", actualPath); err != nil {
log.Printf("sync: failed to update path for %s: %v", identifier, err)
continue
}
// Continue to sync the file content
path = actualPath
} else {
// No file on disk - delete metadata (filesystem is source of truth)
if err := p9client.WriteFile(f, "n/"+identifier+"/ctl", "d"); err != nil {
log.Printf("sync: failed to delete metadata for %s: %v", identifier, err)
}
continue
}
}
// OPTIMIZATION: Circumvent 9P write operation overhead by directly manipulating in-memory data.
var title, keywords, signature string
if SupportsFrontMatter(path) {
fm, _, err := ExtractFrontMatter(path)
if err != nil {
log.Printf("get: failed to extract front matter for %s: %v", identifier, err)
continue
}
title = fm.Title
keywords = strings.Join(fm.Tags, ",")
signature = fm.Signature
} else {
meta := metadata.ParseFilename(path)
title = meta.Title
keywords = strings.Join(meta.Tags, ",")
signature = meta.Signature
}
// Update in-memory metadata directly
if err := updateFunc(identifier, title, keywords, signature); err != nil {
log.Printf("get: failed to update metadata for %s: %v", identifier, err)
}
}
return nil
})
}
// getDenoteFile reloads a text file with front matter from disk to 9P
func getDenoteFile(f *client.Fsys, path, identifier string) error {
// Extract front matter from file
fm, _, err := ExtractFrontMatter(path)
if err != nil {
return err
}
// Write title (triggers 'u' event)
titlePath := "n/" + identifier + "/title"
if err := p9client.WriteFile(f, titlePath, fm.Title); err != nil {
return err
}
// Write keywords (triggers 'u' event)
keywords := strings.Join(fm.Tags, ",")
keywordsPath := "n/" + identifier + "/keywords"
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
return err
}
// Write signature (triggers 'u' event)
signaturePath := "n/" + identifier + "/signature"
if err := p9client.WriteFile(f, signaturePath, fm.Signature); err != nil {
return err
}
return nil
}
// getNonDenoteFile reloads a binary file from disk to 9P (metadata from filename)
func getNonDenoteFile(f *client.Fsys, path, identifier string) error {
// Extract metadata from filename
meta := metadata.ParseFilename(path)
// Write title (triggers 'u' event)
titlePath := "n/" + identifier + "/title"
if err := p9client.WriteFile(f, titlePath, meta.Title); err != nil {
return err
}
// Write keywords (triggers 'u' event)
keywords := strings.Join(meta.Tags, ",")
keywordsPath := "n/" + identifier + "/keywords"
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
return err
}
// Write signature (triggers 'u' event)
signaturePath := "n/" + identifier + "/signature"
if err := p9client.WriteFile(f, signaturePath, meta.Signature); err != nil {
return err
}
return nil
}

347
main.go
View File

@ -1,10 +1,7 @@
package main
import (
"denote/internal/disk"
p9client "denote/internal/p9/client"
"denote/pkg/config"
"denote/pkg/encoding/frontmatter"
"denote/pkg/encoding/results"
"denote/pkg/metadata"
"fmt"
@ -21,20 +18,7 @@ import (
"9fans.net/go/plan9/client"
)
const (
wname = "/Denote/"
ftype = metadata.FileTypeMdYaml
)
// getDenoteDir returns the current denote directory.
func getDenoteDir() string {
if dir := os.Getenv("DENOTE_DIR"); dir != "" {
return dir
}
return config.DefaultDenoteDir
}
// Helper functions for 9P operations
const wname = "/Denote/"
// readIndex reads and parses the index from 9P server.
func readIndex(f *client.Fsys) (metadata.Results, error) {
@ -42,22 +26,7 @@ func readIndex(f *client.Fsys) (metadata.Results, error) {
if err != nil {
return nil, fmt.Errorf("failed to read index: %w", err)
}
rs, err := results.Unmarshal([]byte(indexContent))
if err != nil {
return nil, err
}
// Populate Path for each note
for _, note := range rs {
path, err := p9client.ReadFile(f, "n/"+note.Identifier+"/path")
if err != nil {
return nil, fmt.Errorf("failed to read path for %s: %w", note.Identifier, err)
}
note.Path = path
}
return rs, nil
return results.Unmarshal([]byte(indexContent))
}
// setFilter sets or clears the filter on the 9P server.
@ -69,212 +38,30 @@ func setFilter(f *client.Fsys, filterQuery string) error {
return p9client.WriteFile(f, "ctl", cmd)
}
// Event handling
// handleNewEvent is called when a new note is created.
// When a new note is created in 9P, open an acme window (file created on Put).
func handleNewEvent(f *client.Fsys, identifier string) error {
denoteDir := getDenoteDir()
fields, err := p9client.ReadFields(f, identifier, "title", "keywords", "signature")
if err != nil {
return fmt.Errorf("failed to get metadata: %w", err)
}
title := fields["title"]
signature := fields["signature"]
var tags []string
if keywords, ok := fields["keywords"]; ok && keywords != "" {
tags = strings.Split(keywords, ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
}
// Parse subdirectory from title if using '/:' separator
targetDir := denoteDir
if idx := strings.Index(title, "/:"); idx != -1 {
subdir := title[:idx+1] // Include trailing slash
title = title[idx+2:] // Skip past '/:'
targetDir = filepath.Join(denoteDir, subdir)
targetDir = filepath.Clean(targetDir)
absTarget, err := filepath.Abs(targetDir)
if err != nil {
return fmt.Errorf("failed to resolve target directory: %w", err)
}
absBase, err := filepath.Abs(denoteDir)
if err != nil {
return fmt.Errorf("failed to resolve base directory: %w", err)
}
if !strings.HasPrefix(absTarget, absBase) {
return fmt.Errorf("path traversal attempt: directory outside base")
}
// Create subdirectory if it doesn't exist
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
}
// Update metadata with cleaned title
if err := p9client.WriteFile(f, "n/"+identifier+"/title", title); err != nil {
return fmt.Errorf("failed to update title in metadata: %w", err)
}
}
newIdentifier := metadata.GenerateIdentifier()
fm := metadata.NewFrontMatter(title, signature, tags, newIdentifier)
ext := metadata.GetExtension(ftype)
filename := metadata.BuildFilename(fm, ext)
path := filepath.Join(targetDir, filename)
content := string(frontmatter.Marshal(fm, ftype))
if err := p9client.WriteFile(f, "n/"+identifier+"/path", path); err != nil {
return fmt.Errorf("failed to update path in metadata: %w", err)
}
// Check if file already exists (manual creation case)
if _, err := os.Stat(path); err == nil {
return nil
}
// Open acme window with initial content (file will be created on Put)
var win *acme.Win
if win = acme.Show(path); win == nil {
if win, err = acme.New(); err != nil {
return fmt.Errorf("failed to create window: %w", err)
}
if err = win.Name(path); err != nil {
win.Del(true)
return fmt.Errorf("failed to set window name: %w", err)
}
}
if err := win.Addr("0"); err != nil {
win.Del(true)
return fmt.Errorf("failed to seek: %w", err)
}
if _, err := win.Write("data", []byte(content)); err != nil {
win.Del(true)
return fmt.Errorf("failed to write content: %w", err)
}
win.Ctl("dirty")
win.Addr("$")
win.Ctl("dot=addr")
win.Ctl("show")
return nil
}
// handleServerEvents listens to the denotesrv event stream and handles events
func handleServerEvents() {
for {
err := p9client.With9P(func(f *client.Fsys) error {
eventFid, err := f.Open("event", 0)
if err != nil {
return err
}
defer eventFid.Close()
buf := make([]byte, 4096)
for {
n, err := eventFid.Read(buf)
if err != nil {
return err
}
if n == 0 {
continue
}
line := strings.TrimSpace(string(buf[:n]))
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
eventType := parts[0]
identifier := parts[1]
switch eventType {
case "n": // new
handleNewEvent(f, identifier)
case "u": // update
disk.HandleUpdateEvent(f, identifier, getDenoteDir())
case "r": // rename
disk.HandleRenameEvent(f, identifier, getDenoteDir())
case "d": // delete
disk.HandleDeleteEvent(f, identifier, getDenoteDir())
}
}
})
if err != nil {
log.Printf("event stream error: %v, reconnecting...", err)
}
}
}
func main() {
var err error
var w *acme.Win
args := os.Args[1:]
if len(args) == 1 {
if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok {
// Find note by identifier using 9P
var notePath string
err := p9client.With9P(func(f *client.Fsys) error {
// Set filter to find the identifier
if err := p9client.WriteFile(f, "ctl", "filter date:"+identifier); err != nil {
return err
}
defer p9client.WriteFile(f, "ctl", "filter") // Clear filter
// Read index
indexContent, err := p9client.ReadFile(f, "index")
if err != nil {
return err
}
// Parse index
rs, err := results.Unmarshal([]byte(indexContent))
if err != nil {
return err
}
if len(rs) == 0 {
return fmt.Errorf("no note found with identifier %s", identifier)
}
// Read path for first result
notePath, err = p9client.ReadFile(f, "n/"+rs[0].Identifier+"/path")
return err
})
if err != nil {
fmt.Fprintf(os.Stderr, "error finding note: %v\n", err)
os.Exit(1)
}
fmt.Println(notePath)
if err := exec.Command("plumb", notePath).Run(); err != nil {
log.Printf("failed to plumb identifier: %v", err)
// Plumb the identifier directly (plumbing rules handle the mount)
if err := exec.Command("plumb", "denote:"+identifier).Run(); err != nil {
log.Fatalf("failed to plumb identifier: %v", err)
}
return
} else {
fmt.Println("Usage:")
fmt.Println("Denote is designed to be used in Acme, however it does accept one argument:")
fmt.Println()
fmt.Println("\tDenote <identifier> - opens denote file by identifier")
fmt.Println()
}
fmt.Println("Usage: Denote [denote:<identifier>]")
return
}
// Connect to denotesrv, auto-starting if needed
if err := p9client.With9P(func(f *client.Fsys) error {
return nil // Just test connection
return nil
}); err != nil {
// Try to start denotesrv
cmd := exec.Command("denotesrv", "start")
if err := cmd.Run(); err != nil {
log.Fatalf("failed to start denotesrv: %v", err)
}
// Wait for server to be ready
for i := 0; i < 10; i++ {
if err := p9client.With9P(func(f *client.Fsys) error {
return nil
@ -287,52 +74,43 @@ func main() {
}
}
// Start event handler for server events
go handleServerEvents()
// Start acme log watcher
go disk.WatchAcmeLog()
// open window - look for existing /Denote/ window across all processes
// open window - look for existing /Denote/ window
if wins, err := acme.Windows(); err == nil {
for _, winInfo := range wins {
if winInfo.Name == wname {
// Found existing window, open it
if w, err = acme.Open(winInfo.ID, nil); err == nil {
break
}
}
}
}
// If no existing window found, create new one
if w == nil {
if w, err = acme.New(); err != nil {
log.Fatal(err)
}
if err = w.Name(wname); err != nil {
w.Del(true)
log.Fatal(fmt.Errorf("failed to set window name: %w", err))
log.Fatal(err)
}
}
defer w.CloseFiles()
if _, err = w.Write("tag", []byte("New Put Remove Get Path")); err != nil {
if _, err = w.Write("tag", []byte("New Put Remove Get")); err != nil {
w.Del(true)
log.Fatal(fmt.Errorf("failed to set tag: %w", err))
log.Fatal(err)
}
// get initial results (clear any filter, read index)
// get initial results
var rs metadata.Results
err = p9client.With9P(func(f *client.Fsys) error {
if err := setFilter(f, ""); err != nil {
return err
}
var err error
rs, err = readIndex(f)
return err
})
if err != nil {
panic(err)
log.Fatal(err)
}
metadata.Sort(rs, metadata.SortById, metadata.SortOrderDesc)
refreshWindow(w, rs)
@ -348,55 +126,41 @@ func main() {
case 'x', 'X':
switch string(e.Text) {
case "New":
// Read chorded argument
input := strings.TrimSpace(string(e.Arg))
if input == "" {
break
}
// Write to 9p (triggers callback which opens window)
if err := p9client.With9P(func(f *client.Fsys) error {
return p9client.WriteFile(f, "new", input)
}); err != nil {
log.Printf("failed to create note: %v", err)
}
// Refresh window and scroll to top to show new note
refreshWindowWithDefaults(w)
w.Addr("#0")
w.Ctl("dot=addr")
w.Ctl("show")
case "Remove":
// Read chorded argument
input := strings.TrimSpace(string(e.Arg))
if input == "" {
break
}
// Preserve current position for restoring after refresh
w.Ctl("addr=dot")
q0, q1, _ := w.ReadAddr()
// Write to denote/n/<identifier>/ctl via 9P
if err := p9client.With9P(func(f *client.Fsys) error {
return p9client.WriteFile(f, filepath.Join("n", input, "ctl"), "d")
}); err != nil {
log.Printf("failed to delete file: %v", err)
}
// Refresh window and restore position
refreshWindowWithDefaults(w)
w.Addr("#%d,#%d", q0, q1)
w.Ctl("dot=addr")
w.Ctl("show")
case "Look":
// Use e.Arg for the search arguments
performSearch(w, string(e.Arg))
w.Addr("#0")
w.Ctl("dot=addr")
w.Ctl("show")
case "Get":
// Refresh from server
refreshWindowWithDefaults(w)
w.Addr("#0")
w.Ctl("dot=addr")
@ -407,22 +171,16 @@ func main() {
log.Printf("failed to read window body: %v", err)
break
}
// Parse the window content with strict validation
updated, err := results.UnmarshalStrict(body)
if err != nil {
log.Printf("failed to parse window: %v", err)
break
}
// Get current results and apply changes
// Read unfiltered index
var current metadata.Results
err = p9client.With9P(func(f *client.Fsys) error {
if err := setFilter(f, ""); err != nil {
return err
}
var err error
current, err = readIndex(f)
return err
})
@ -430,41 +188,18 @@ func main() {
log.Printf("failed to get current results: %v", err)
break
}
// Validate and apply changes via individual file writes
if err := p9client.With9P(func(f *client.Fsys) error {
return applyIndexChanges(f, current, updated)
}); err != nil {
log.Printf("failed to apply changes: %v", err)
}
case "Path":
input := strings.TrimSpace(string(e.Arg))
if input == "" {
input = strings.TrimSpace(string(e.Text))
}
if input == "" || !isIdentifier(input) {
break
}
if err := p9client.With9P(func(f *client.Fsys) error {
path, err := p9client.ReadFile(f, "n/"+input+"/path")
if err != nil {
return fmt.Errorf("failed to read path: %w", err)
}
fmt.Println(path)
return nil
}); err != nil {
log.Printf("path error: %v", err)
}
default:
w.WriteEvent(e)
}
case 'l', 'L':
// Check if text matches identifier pattern
text := string(e.Text)
if isIdentifier(text) {
// Plumb with denote: prefix
if err := exec.Command("plumb", fmt.Sprintf("denote:%s", text)).Run(); err != nil {
if err := exec.Command("plumb", "denote:"+text).Run(); err != nil {
log.Printf("failed to plumb identifier: %v", err)
}
} else {
@ -478,7 +213,6 @@ func main() {
func performSearch(w *acme.Win, searchText string) {
args := parseArgs(searchText)
var filterArgs []string
sortBy := metadata.SortById
sortOrder := metadata.SortOrderDesc
@ -486,22 +220,13 @@ func performSearch(w *acme.Win, searchText string) {
for _, arg := range args {
if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok {
parts := strings.Split(sortSpec, ",")
switch parts[0] {
case "id", "date":
sortBy = metadata.SortById
case "title":
sortBy = metadata.SortByTitle
}
if len(parts) > 1 {
switch parts[1] {
case "asc":
sortOrder = metadata.SortOrderAsc
case "desc":
sortOrder = metadata.SortOrderDesc
}
} else {
if len(parts) > 1 && parts[1] == "asc" {
sortOrder = metadata.SortOrderAsc
}
} else {
@ -509,10 +234,7 @@ func performSearch(w *acme.Win, searchText string) {
}
}
// Build filter query string for ctl
filterQuery := strings.Join(filterArgs, " ")
// Set filter and read index
var rs metadata.Results
err := p9client.With9P(func(f *client.Fsys) error {
if err := setFilter(f, filterQuery); err != nil {
@ -527,7 +249,6 @@ func performSearch(w *acme.Win, searchText string) {
return
}
metadata.Sort(rs, sortBy, sortOrder)
refreshWindow(w, rs)
}
@ -555,7 +276,6 @@ func refreshWindowWithDefaults(w *acme.Win) {
refreshWindow(w, rs)
}
// parseArgs parses space-separated arguments, handling quoted strings
func parseArgs(s string) []string {
var args []string
var current strings.Builder
@ -568,19 +288,13 @@ func parseArgs(s string) []string {
escaped = false
continue
}
switch r {
case '\\':
if inQuote {
// Look ahead to see if next char is quote or backslash
if i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
if inQuote && i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') {
escaped = true
} else {
current.WriteRune(r)
}
} else {
current.WriteRune(r)
}
case '"':
inQuote = !inQuote
case ' ', '\t', '\n':
@ -594,58 +308,37 @@ func parseArgs(s string) []string {
current.WriteRune(r)
}
}
// Add final argument if any
if current.Len() > 0 {
args = append(args, current.String())
}
// Trim whitespace from args
for i := range args {
args[i] = strings.TrimFunc(args[i], unicode.IsSpace)
}
return args
}
func applyIndexChanges(f *client.Fsys, current, updated metadata.Results) error {
// Validate entry count matches
if len(updated) != len(current) {
return fmt.Errorf("entry count mismatch: got %d, expected %d", len(updated), len(current))
}
// Build map of current state
currentMap := make(map[string]*metadata.Metadata)
for _, m := range current {
currentMap[m.Identifier] = m
}
// Write title and keywords for changed entries
for _, upd := range updated {
orig, exists := currentMap[upd.Identifier]
if !exists {
return fmt.Errorf("identifier '%s' not found", upd.Identifier)
}
// Check if anything changed
titleChanged := orig.Title != upd.Title
tagsChanged := !slices.Equal(orig.Tags, upd.Tags)
if titleChanged || tagsChanged {
// Write both title and keywords
titlePath := "n/" + upd.Identifier + "/title"
if err := p9client.WriteFile(f, titlePath, upd.Title); err != nil {
if orig.Title != upd.Title || !slices.Equal(orig.Tags, upd.Tags) {
if err := p9client.WriteFile(f, "n/"+upd.Identifier+"/title", upd.Title); err != nil {
return fmt.Errorf("failed to write title for %s: %w", upd.Identifier, err)
}
keywordsPath := "n/" + upd.Identifier + "/keywords"
keywords := strings.Join(upd.Tags, ",")
if err := p9client.WriteFile(f, keywordsPath, keywords); err != nil {
if err := p9client.WriteFile(f, "n/"+upd.Identifier+"/keywords", strings.Join(upd.Tags, ",")); err != nil {
return fmt.Errorf("failed to write keywords for %s: %w", upd.Identifier, err)
}
}
}
return nil
}

2
mkfile
View File

@ -1,6 +1,6 @@
install:V:
go build -o $HOME/bin/Denote .
go build -o $HOME/bin/Drn cmd/Drn/main.go
cp scripts/Drn $HOME/bin/Drn
cp scripts/Djournal $HOME/bin/Djournal
cp scripts/Dmerge $HOME/bin/Dmerge
cp scripts/Dbkp $HOME/bin/Dbkp

72
scripts/Drn Executable file
View File

@ -0,0 +1,72 @@
#!/usr/local/plan9/bin/rc
# Drn - rename denote note metadata
# usage: Drn [identifier] 'title' [==signature] [tags]
rfork e
fn usage {
echo 'usage: Drn [identifier] ''title'' [==signature] [tags]' >[1=2]
exit usage
}
if(~ $#* 0) usage
# Check if first arg is identifier
id=()
if(~ $1 [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]) {
id=$1
shift
}
if not {
# Extract from window tag
if(~ $winid '') {
echo 'Drn: $winid not set' >[1=2]
exit 'no winid'
}
tag=`{9p read acme/$winid/tag}
id=`{echo $tag | grep -o '[0-9]\{8\}T[0-9]\{6\}'}
if(~ $#id 0) {
echo 'Drn: no identifier in window' >[1=2]
exit 'no id'
}
}
if(~ $#* 0) usage
# Parse: 'title' [==sig] [tags] or ==sig 'title' [tags]
sig=''
title=''
tags=''
args=$"*
# Check for leading ==signature
if(~ $1 ==*) {
sig=`{echo $1 | sed 's/^==//'}
shift
args=$"*
}
# Title is first arg (should be quoted in input)
title=$1
shift
# Remaining args
for(a in $*) {
if(~ $a ==*) {
sig=`{echo $a | sed 's/^==//'}
}
if not {
if(~ $tags '') {
tags=$a
}
if not {
tags=$tags^','$a
}
}
}
# Write to 9P
echo -n $title | 9p write denote/n/$id/title
echo -n $tags | 9p write denote/n/$id/keywords
echo -n $sig | 9p write denote/n/$id/signature