211 lines
5.3 KiB
Go
211 lines
5.3 KiB
Go
package notes
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"verstak/internal/core/files"
|
|
"verstak/internal/core/nodes"
|
|
"verstak/internal/core/storage"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
slug := node.Slug
|
|
if slug == "" {
|
|
slug = "note"
|
|
}
|
|
filename := slug + ".md"
|
|
destDir := filepath.Join(s.vaultRoot, "spaces")
|
|
os.MkdirAll(destDir, 0o750)
|
|
|
|
dest := filepath.Join(destDir, filename)
|
|
if _, err := os.Stat(dest); err == nil {
|
|
filename = fmt.Sprintf("%s_%s.md", slug, node.ID[:8])
|
|
dest = filepath.Join(destDir, filename)
|
|
}
|
|
|
|
// Write initial content.
|
|
if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil {
|
|
return nil, nil, fmt.Errorf("write: %w", err)
|
|
}
|
|
|
|
// Register file record.
|
|
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)
|
|
}
|
|
|
|
// Link.
|
|
_, 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.
|
|
func (s *Service) Delete(nodeID string) error {
|
|
return s.nodes.SoftDelete(nodeID)
|
|
}
|
|
|
|
// 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
|
|
}
|