diff --git a/cmd/Drn/main.go b/cmd/Drn/main.go deleted file mode 100644 index 2cf98b0..0000000 --- a/cmd/Drn/main.go +++ /dev/null @@ -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 -} diff --git a/internal/disk/backlinks.go b/internal/disk/backlinks.go deleted file mode 100644 index bfbe813..0000000 --- a/internal/disk/backlinks.go +++ /dev/null @@ -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 -} diff --git a/internal/disk/callbacks.go b/internal/disk/callbacks.go deleted file mode 100644 index 99da015..0000000 --- a/internal/disk/callbacks.go +++ /dev/null @@ -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 -} diff --git a/internal/disk/loader.go b/internal/disk/loader.go deleted file mode 100644 index 709c4de..0000000 --- a/internal/disk/loader.go +++ /dev/null @@ -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 -} diff --git a/internal/disk/sync.go b/internal/disk/sync.go deleted file mode 100644 index e1941fa..0000000 --- a/internal/disk/sync.go +++ /dev/null @@ -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 -} diff --git a/main.go b/main.go index b74d222..4d35a92 100644 --- a/main.go +++ b/main.go @@ -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 - opens denote file by identifier") - fmt.Println() } + fmt.Println("Usage: Denote [denote:]") + 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//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,16 +288,10 @@ 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] == '\\') { - escaped = true - } else { - current.WriteRune(r) - } + if inQuote && i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') { + escaped = true } else { current.WriteRune(r) } @@ -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 } diff --git a/mkfile b/mkfile index d925809..c2f5fb6 100644 --- a/mkfile +++ b/mkfile @@ -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 diff --git a/scripts/Drn b/scripts/Drn new file mode 100755 index 0000000..9a0ba97 --- /dev/null +++ b/scripts/Drn @@ -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