verstak/internal/core/notes/note.go

372 lines
11 KiB
Go

package notes
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
// NotesFolder is the canonical name for the notes subdirectory inside a case/project.
const NotesFolder = "Notes"
// noteFileRoot returns the absolute path to the notes subdirectory for a given parent.
// For parentless notes it returns <vaultRoot>/Notes.
func noteFileRoot(vaultRoot, parentFsPath string) string {
if parentFsPath != "" {
return filepath.Join(vaultRoot, parentFsPath, NotesFolder)
}
return filepath.Join(vaultRoot, NotesFolder)
}
// assertContained verifies that targetPath is strictly under rootDir.
// It resolves symlinks in the target path to prevent symlink-based escapes.
// Returns an error if targetPath escapes rootDir via .. or symlinks.
func assertContained(rootDir, targetPath string) error {
cleanRoot := filepath.Clean(rootDir)
cleanTarget := filepath.Clean(targetPath)
// Resolve symlinks in the target path to get the real path.
// If the path doesn't exist yet (e.g. for Create), we resolve as much
// as possible and check the unresolved remainder separately.
resolvedTarget, err := filepath.EvalSymlinks(cleanTarget)
if err != nil {
// Path doesn't exist — resolve the parent directory instead.
dir := filepath.Dir(cleanTarget)
resolvedDir, dirErr := filepath.EvalSymlinks(dir)
if dirErr != nil {
// Parent doesn't exist either — fall back to Clean-based check.
rel, relErr := filepath.Rel(cleanRoot, cleanTarget)
if relErr != nil {
return fmt.Errorf("path containment check failed: %w", relErr)
}
if strings.HasPrefix(rel, "..") {
return fmt.Errorf("path %q escapes root %q", cleanTarget, cleanRoot)
}
return nil
}
// Reconstruct target with resolved parent + original base name.
resolvedTarget = filepath.Join(resolvedDir, filepath.Base(cleanTarget))
}
rel, err := filepath.Rel(cleanRoot, resolvedTarget)
if err != nil {
return fmt.Errorf("path containment check failed: %w", err)
}
if strings.HasPrefix(rel, "..") {
return fmt.Errorf("path %q (resolved: %q) escapes root %q", cleanTarget, resolvedTarget, cleanRoot)
}
return nil
}
// Record represents a note entry (links a node to a file).
type Record struct {
NodeID string `json:"node_id"`
FileID string `json:"file_id"`
Format string `json:"format"`
Encrypted bool `json:"encrypted"`
}
// Service handles markdown notes.
type Service struct {
db *storage.DB
vaultRoot string
nodes *nodes.Repository
files *files.Service
}
// NewService creates a note service.
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fileSvc *files.Service) *Service {
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc}
}
// Create makes a new note node, an empty .md file, and links them.
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeNote, title, 0, "", "")
if err != nil {
return nil, nil, fmt.Errorf("create node: %w", err)
}
seg := templates.SafeDisplayNameToPathSegment(title)
if seg == "" {
seg = "note"
}
filename := seg + ".md"
// Determine the canonical notes directory
var parentFsPath string
if parentID != "" {
parent, err := s.nodes.GetActive(parentID)
if err == nil && parent.FsPath != "" {
parentFsPath = parent.FsPath
}
}
destDir := noteFileRoot(s.vaultRoot, parentFsPath)
if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, nil, fmt.Errorf("mkdir: %w", err)
}
dest := filepath.Join(destDir, filename)
// Path containment check: the resolved file must stay under destDir
if err := assertContained(destDir, dest); err != nil {
return nil, nil, err
}
if _, err := os.Stat(dest); err == nil {
filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8])
dest = filepath.Join(destDir, filename)
// Re-check containment after rename
if err := assertContained(destDir, dest); err != nil {
return nil, nil, err
}
}
if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil {
return nil, nil, fmt.Errorf("write: %w", err)
}
relPath, _ := filepath.Rel(s.vaultRoot, dest)
fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0)
if err != nil {
return nil, nil, fmt.Errorf("insert file: %w", err)
}
_, err = s.db.Exec(
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
node.ID, fileRec.ID, "markdown")
if err != nil {
return nil, nil, fmt.Errorf("link note: %w", err)
}
return node, fileRec, nil
}
// Read returns the content of a note.
func (s *Service) Read(nodeID string) (string, error) {
var filePath, storageMode string
err := s.db.QueryRow(
`SELECT f.path, f.storage_mode
FROM notes n JOIN files f ON n.file_id = f.id
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
if err != nil {
return "", fmt.Errorf("query note: %w", err)
}
var abs string
if storageMode == "vault" {
abs = filepath.Join(s.vaultRoot, filePath)
} else {
abs = filePath
}
data, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return string(data), nil
}
// Save writes new content, backing up the old version.
func (s *Service) Save(nodeID, content string) error {
var filePath, storageMode string
err := s.db.QueryRow(
`SELECT f.path, f.storage_mode
FROM notes n JOIN files f ON n.file_id = f.id
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
if err != nil {
return fmt.Errorf("query: %w", err)
}
var abs string
if storageMode == "vault" {
abs = filepath.Join(s.vaultRoot, filePath)
} else {
abs = filePath
}
// Backup old version.
if info, err := os.Stat(abs); err == nil && info.Size() > 0 {
histDir := filepath.Join(s.vaultRoot, ".verstak", "history")
os.MkdirAll(histDir, 0o750)
name := filepath.Base(abs)
backup := filepath.Join(histDir,
fmt.Sprintf("%s_%d.bak", name, time.Now().Unix()))
os.WriteFile(backup, mustRead(abs), 0o640)
}
if err := os.WriteFile(abs, []byte(content), 0o640); err != nil {
return fmt.Errorf("write: %w", err)
}
// Update file size.
info, _ := os.Stat(abs)
size := int64(0)
if info != nil {
size = info.Size()
}
_, err = s.db.Exec(
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
size, utcNow(), filePath, storageMode)
return err
}
// Delete soft-deletes the note node and moves the backing .md file to the
// shared vault trash directory (<vault>/.verstak/trash/) using the same
// trashRecord mechanism as files.DeleteNodeAndChildren. This ensures the
// deleted note appears in the unified Trash UI and can be restored/permanently
// deleted through the existing trash workflow.
func (s *Service) Delete(nodeID string) error {
// Load the note record to find the file.
rec, err := s.Load(nodeID)
if err != nil {
return fmt.Errorf("load note: %w", err)
}
// Get the full file record for trashRecord.
fileRec, err := s.files.Get(rec.FileID)
if err != nil {
return fmt.Errorf("get file record: %w", err)
}
// Soft-delete the node first.
if err := s.nodes.SoftDelete(nodeID); err != nil {
return fmt.Errorf("soft-delete node: %w", err)
}
// Move the .md file to the shared trash using the existing trashRecord.
// This places the file in <vault>/.verstak/trash/<fileID>_<filename>
// and marks the file record as missing=1 so it can be restored later.
if err := s.files.TrashFile(fileRec); err != nil {
return fmt.Errorf("trash file: %w", err)
}
return nil
}
// Rename changes the note title and renames the backing .md file on disk.
// If a file with the target name already exists, the operation is rejected.
func (s *Service) Rename(nodeID, newTitle string) error {
if err := s.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return fmt.Errorf("update title: %w", err)
}
// Load the note record to find the file.
rec, err := s.Load(nodeID)
if err != nil {
return fmt.Errorf("load note: %w", err)
}
// Get the current file record.
var oldPath, oldFilename, storageMode string
err = s.db.QueryRow(
`SELECT path, filename, storage_mode FROM files WHERE id = ?`, rec.FileID,
).Scan(&oldPath, &oldFilename, &storageMode)
if err != nil {
return fmt.Errorf("query file: %w", err)
}
// Build old and new absolute paths.
var oldAbs string
if storageMode == "vault" {
oldAbs = filepath.Join(s.vaultRoot, oldPath)
} else {
oldAbs = oldPath
}
oldDir := filepath.Dir(oldAbs)
seg := templates.SafeDisplayNameToPathSegment(newTitle)
if seg == "" {
seg = "note"
}
newFilename := seg + ".md"
newAbs := filepath.Join(oldDir, newFilename)
// Collision check: reject if target exists and is different from source.
if newAbs != oldAbs {
if _, err := os.Stat(newAbs); err == nil {
return fmt.Errorf("file %q already exists", newFilename)
}
if err := os.Rename(oldAbs, newAbs); err != nil {
return fmt.Errorf("rename file: %w", err)
}
// Update file record.
newRel, _ := filepath.Rel(s.vaultRoot, newAbs)
_, err = s.db.Exec(
`UPDATE files SET filename=?, path=?, updated_at=? WHERE id=?`,
newFilename, newRel, utcNow(), rec.FileID,
)
if err != nil {
return fmt.Errorf("update file record: %w", err)
}
}
return nil
}
// Load looks up the note record for a node.
func (s *Service) Load(nodeID string) (*Record, error) {
var rec Record
var enc int
err := s.db.QueryRow(
`SELECT node_id, file_id, format, encrypted FROM notes WHERE node_id=?`, nodeID,
).Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc)
if err != nil {
return nil, err
}
rec.Encrypted = enc == 1
return &rec, nil
}
// --- helpers ---
func insertFileRecord(db *storage.DB, nodeID, filename, relPath, mode string, size int64) (*files.Record, error) {
rec := &files.Record{
ID: util.UUID7(),
NodeID: nodeID,
Filename: filename,
Path: relPath,
StorageMode: mode,
Size: size,
MIME: "text/plain",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,
created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,0)`,
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
rec.Size, rec.MIME,
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
if err != nil {
return nil, err
}
return rec, nil
}
func mustRead(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
return data
}
func utcNow() string {
return time.Now().UTC().Format(time.RFC3339)
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}