verstak/internal/core/notes/note.go

691 lines
20 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"
// notesContainerTypes are node types that can host a "Notes" folder.
var notesContainerTypes = map[string]bool{
nodes.TypeFolder: true,
nodes.TypeProject: true,
nodes.TypeClient: true,
nodes.TypeDocument: true,
nodes.TypeRecipe: true,
nodes.TypeSpace: true,
nodes.TypeCase: true,
}
// SupportsNotes reports whether the node identified by nodeID should have
// a "Notes" folder. Returns false for non-container types and for the
// Notes folder itself (prevents Notes/Notes nesting).
func (s *Service) SupportsNotes(nodeID string) bool {
n, err := s.nodes.Get(nodeID)
if err != nil {
return false
}
return NodeSupportsNotes(n)
}
// NodeSupportsNotes is the stateless predicate used by SupportsNotes and
// RepairNotesLayout. It can be called with a freshly loaded node.
func NodeSupportsNotes(n *nodes.Node) bool {
if n == nil {
return false
}
// Never for the Notes folder itself — prevents Notes/Notes nesting.
if n.Type == nodes.TypeFolder && n.Title == NotesFolder {
return false
}
return notesContainerTypes[n.Type]
}
// 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}
}
// FindNotesFolder returns the TypeFolder "Notes" node under parentID, or nil.
func (s *Service) FindNotesFolder(parentID string) *nodes.Node {
children, err := s.nodes.ListChildren(parentID, false)
if err != nil {
return nil
}
for i := range children {
if children[i].Type == nodes.TypeFolder && children[i].Title == NotesFolder {
return &children[i]
}
}
return nil
}
// EnsureNotesFolder finds or creates a TypeFolder "Notes" under the given parent.
// Returns an error if the parent node type does not support notes.
func (s *Service) EnsureNotesFolder(parentID string) (*nodes.Node, error) {
// Already exists?
if n := s.FindNotesFolder(parentID); n != nil {
return n, nil
}
parent, err := s.nodes.GetActive(parentID)
if err != nil {
return nil, fmt.Errorf("get parent: %w", err)
}
// Only container types get a Notes folder.
if !NodeSupportsNotes(parent) {
return nil, fmt.Errorf("node %q (type=%s) does not support notes", parent.ID, parent.Type)
}
folderFsPath := ""
if parent.FsPath != "" {
folderFsPath = filepath.Join(parent.FsPath, NotesFolder)
}
folder, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, NotesFolder, 0, "", folderFsPath)
if err != nil {
return nil, fmt.Errorf("create notes folder node: %w", err)
}
// Create physical directory
absDir := noteFileRoot(s.vaultRoot, parent.FsPath)
if err := os.MkdirAll(absDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir notes dir: %w", err)
}
return folder, nil
}
// Create makes a new note node, an empty .md file, and links them.
// The note node is created inside a "Notes" folder under parentID if the
// parent type supports notes. If the parent does not support notes (e.g.
// file, note, action, secret, worklog, link) an error is returned.
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
// Reject non-notes-capable parents early.
if parentID != "" && !s.SupportsNotes(parentID) {
return nil, nil, fmt.Errorf("parent node does not support notes")
}
var notesFolder *nodes.Node
if parentID != "" {
var err error
notesFolder, err = s.EnsureNotesFolder(parentID)
if err != nil {
return nil, nil, fmt.Errorf("ensure notes folder: %w", err)
}
}
noteParentID := parentID
if notesFolder != nil {
noteParentID = notesFolder.ID
}
node, err := s.nodes.Create(strPtr(noteParentID), 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 from the case's FsPath
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
}
// ============================================================
// Repair / Backfill
// ============================================================
// RepairResult describes what the repair pass did.
type RepairResult struct {
RepairedNotes int `json:"repaired_notes"`
CreatedFolders int `json:"created_folders"`
MovedFiles int `json:"moved_files"`
UpdatedFilePaths int `json:"updated_file_paths"`
SkippedNotes int `json:"skipped_notes"`
AlreadyCorrect int `json:"already_correct"`
Conflicts []ConflictEntry `json:"conflicts,omitempty"`
Errors []RepairError `json:"errors,omitempty"`
}
// ConflictEntry details a case where both old and new locations exist.
type ConflictEntry struct {
NodeID string `json:"node_id"`
NoteTitle string `json:"note_title"`
OldPath string `json:"old_path"`
Canonical string `json:"canonical"`
Description string `json:"description"`
}
// RepairError records a non-fatal repair error for a single note.
type RepairError struct {
NodeID string `json:"node_id"`
Error string `json:"error"`
}
// RepairNotesLayout detects notes that live outside a "Notes" folder and
// migrates them into the canonical layout:
//
// <case>/
// Notes/ ← TypeFolder node
// Overview.md ← TypeNote node under Notes folder
//
// It also fixes stale files.path / files.filename records when the file
// was manually moved to Notes/ on disk but the DB was not updated.
//
// The function is idempotent — repeated calls are safe.
func (s *Service) RepairNotesLayout() (*RepairResult, error) {
res := &RepairResult{}
allNotes, err := s.nodes.ListByType(nodes.TypeNote)
if err != nil {
return nil, fmt.Errorf("list notes: %w", err)
}
for i := range allNotes {
n := allNotes[i]
if n.ParentID == nil {
res.SkippedNotes++
continue
}
parent, err := s.nodes.Get(*n.ParentID)
if err != nil {
res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: err.Error()})
continue
}
// Only repair notes whose parent is a notes-capable container or
// already a Notes folder. Notes under non-container types (file,
// action, etc.) are left as-is.
isNotesFolder := parent.Type == nodes.TypeFolder && parent.Title == NotesFolder
if !NodeSupportsNotes(parent) && !isNotesFolder {
res.SkippedNotes++
continue
}
// Determine real case / project / client ID
var caseID string
if isNotesFolder {
if parent.ParentID != nil {
caseID = *parent.ParentID
} else {
res.SkippedNotes++
continue
}
} else {
caseID = *n.ParentID
}
// Ensure Notes folder node exists
notesFolder := s.FindNotesFolder(caseID)
if notesFolder == nil {
notesFolder, err = s.EnsureNotesFolder(caseID)
if err != nil {
res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: fmt.Sprintf("create notes folder: %v", err)})
continue
}
res.CreatedFolders++
}
// Move note node into the Notes folder if it isn't already
if *n.ParentID != notesFolder.ID {
if err := s.nodes.Move(n.ID, strPtr(notesFolder.ID), 0); err != nil {
res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: fmt.Sprintf("move note: %v", err)})
continue
}
res.RepairedNotes++
} else {
res.AlreadyCorrect++
}
// Fix file path in the files table
if err := s.repairNoteFilePath(n.ID, caseID, res); err != nil {
res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: err.Error()})
}
}
return res, nil
}
// repairNoteFilePath checks and fixes the files.path / files.filename for one note.
func (s *Service) repairNoteFilePath(noteID, caseID string, res *RepairResult) error {
noteRec, err := s.Load(noteID)
if err != nil {
return fmt.Errorf("load note record: %w", err)
}
fileRec, err := s.files.Get(noteRec.FileID)
if err != nil {
return fmt.Errorf("get file record: %w", err)
}
caseNode, err := s.nodes.Get(caseID)
if err != nil {
return fmt.Errorf("get case: %w", err)
}
// Expected canonical path relative to vault root
canonicalRel := filepath.Join(caseNode.FsPath, NotesFolder, fileRec.Filename)
// Already canonical — nothing to do
if fileRec.Path == canonicalRel {
return nil
}
oldRel := filepath.Join(caseNode.FsPath, fileRec.Filename)
canonicalAbs := filepath.Join(s.vaultRoot, canonicalRel)
oldAbs := filepath.Join(s.vaultRoot, oldRel)
canonicalOnDisk := fileExists(canonicalAbs)
oldOnDisk := fileExists(oldAbs)
// Conflict: both locations have a file
if canonicalOnDisk && oldOnDisk {
res.Conflicts = append(res.Conflicts, ConflictEntry{
NodeID: noteID,
NoteTitle: caseNode.Title,
OldPath: oldRel,
Canonical: canonicalRel,
Description: "both old and canonical file locations exist — refusing to overwrite",
})
return nil
}
// File already at canonical location — just update DB
if canonicalOnDisk {
_, err = s.db.Exec(
`UPDATE files SET path=?, updated_at=? WHERE id=?`,
canonicalRel, utcNow(), noteRec.FileID,
)
if err != nil {
return fmt.Errorf("update file path: %w", err)
}
res.UpdatedFilePaths++
return nil
}
// File still at old location — move it
if oldOnDisk {
notesDir := filepath.Dir(canonicalAbs)
if err := os.MkdirAll(notesDir, 0o750); err != nil {
return fmt.Errorf("mkdir notes dir: %w", err)
}
if err := os.Rename(oldAbs, canonicalAbs); err != nil {
return fmt.Errorf("rename: %w", err)
}
_, err = s.db.Exec(
`UPDATE files SET path=?, updated_at=? WHERE id=?`,
canonicalRel, utcNow(), noteRec.FileID,
)
if err != nil {
return fmt.Errorf("update file path after move: %w", err)
}
res.MovedFiles++
return nil
}
// File doesn't exist at either location — just update DB path
_, err = s.db.Exec(
`UPDATE files SET path=?, updated_at=? WHERE id=?`,
canonicalRel, utcNow(), noteRec.FileID,
)
if err != nil {
return fmt.Errorf("update file path (neither exists): %w", err)
}
res.UpdatedFilePaths++
return nil
}
// fileExists returns true if path refers to an existing file or directory.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}