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:
parent
264ac251ae
commit
20bc5324a6
420
cmd/Drn/main.go
420
cmd/Drn/main.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
347
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 <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
2
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue