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 /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} } // DB returns the underlying storage.DB for direct queries (used in tests). func (s *Service) DB() *storage.DB { return s.db } // 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 (/.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 /.verstak/trash/_ // 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: // // / // 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 } // FindByFileID looks up the note record associated with the given file ID. // LinkFile creates a notes record linking an existing node to an existing file record. // It is a no-op if a record for node_id already exists (idempotent). func (s *Service) LinkFile(nodeID, fileID, format string) error { if format == "" { format = "markdown" } _, err := s.db.Exec( `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, nodeID, fileID, format, ) return err } func (s *Service) FindByFileID(fileID string) (*Record, error) { row := s.db.QueryRow( `SELECT node_id, file_id, format, encrypted FROM notes WHERE file_id=?`, fileID, ) var rec Record var enc int if err := row.Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc); err != nil { return nil, err } rec.Encrypted = enc != 0 return &rec, 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 }