691 lines
20 KiB
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
|
|
}
|