denote-acme/internal/disk/events.go

222 lines
5.7 KiB
Go

package disk
import (
"denote/internal/metadata"
p9client "denote/internal/p9/client"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"9fans.net/go/acme"
"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 frontmatter if file exists (skip if not yet created via Put)
if _, err := os.Stat(path); err == nil && SupportsFrontMatter(path) {
ext := strings.ToLower(filepath.Ext(path))
var fileType string
switch ext {
case ".org":
fileType = "org"
case ".md":
fileType = "md-yaml"
case ".txt":
fileType = "txt"
}
fm := &metadata.FrontMatter{
Title: title,
Tags: tags,
Identifier: identifier,
Signature: signature,
FileType: fileType,
}
if err := UpdateFrontMatter(path, fm); err != nil {
log.Printf("failed to update front matter for %s: %v", identifier, err)
}
}
// After updating content, check if a rename is needed
// Extract the FULL extension from original path (preserves .gpg, .txt.gpg, etc.)
dir := filepath.Dir(path)
if dir == "." {
dir = denoteDir
}
// Get original filename and extract extension by removing the denote components
originalFilename := filepath.Base(path)
// Find where the extension starts (after tags or after title)
extStart := strings.Index(originalFilename, ".")
if extStart == -1 {
extStart = len(originalFilename)
}
fullExt := originalFilename[extStart:]
filename := metadata.BuildFilename(identifier, signature, title, tags, fullExt)
newPath := filepath.Join(dir, filename)
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)
}
}
return nil
}
// HandleRenameEvent handles 'r' events from the 9P server.
// When path is updated in 9P, rename the file on the filesystem.
func HandleRenameEvent(f *client.Fsys, identifier, denoteDir string) error {
// Get new path from 9P server.
newPath, err := p9client.ReadFile(f, "n/"+identifier+"/path")
if err != nil {
return fmt.Errorf("failed to read path: %w", err)
}
if newPath == "" {
// Path is not set yet. This can happen during note creation.
return nil
}
// Find old file on disk (matches with or without signature).
pattern := filepath.Join(denoteDir, identifier+"*")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("failed to find file: %w", err)
}
if len(matches) == 0 {
// File doesn't exist on disk. Nothing to rename.
return nil
}
oldPath := matches[0]
// Rename if different.
if oldPath != newPath {
newPath = filepath.Clean(newPath)
absNew, err := filepath.Abs(newPath)
if err != nil {
return fmt.Errorf("failed to resolve new path: %w", err)
}
absBase, err := filepath.Abs(denoteDir)
if err != nil {
return fmt.Errorf("failed to resolve base directory: %w", err)
}
if !strings.HasPrefix(absNew, absBase) {
return fmt.Errorf("path traversal attempt: path outside base")
}
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("failed to rename file from %s to %s: %w", oldPath, newPath, err)
}
// Update window tag for renamed file
if wins, err := acme.Windows(); err == nil {
for _, w := range wins {
if err := updateWindowName(w.ID, oldPath, newPath); err != nil {
log.Printf("failed to update window %d: %v", w.ID, err)
}
}
}
}
return nil
}
// 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
}
// 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
}