372 lines
11 KiB
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
|
|
}
|