steps 4-6 + doc overhaul: files, notes, GUI, plugins docs
DOCUMENTATION (shift from personal to universal product): - README.md: rewritten with 'one product, different doors' framing, universal entities table, audience segments - 01_Product_Spec.md: removed personal references (sshkeeper, Godot, DokuWiki, servers), added audience segments (freelancer, repairmaster, developer, maker, consultant), universal scenarios - 02_Architecture.md: added 'Plugins (Extensibility)' section with calendar/kanban/importer/template examples - 03_Data_Model_Storage.md: added section 6 on plugin extensibility (node_meta, type registry, SQL migrations per plugin) - 09_Extensibility.md (NEW): full plugin architecture — Lua runtime, plugin.json, hooks, sandbox, templates, registry - PLAN.md: added step 16 (plugins), updated status table - 00_README.md: rewritten product index with plugin principle CODE — STEP 4 (Files): - migration 002: files table (id, node_id, filename, path, storage_mode, size, sha256, mime, ...) - FileService: AddExternal, CopyIntoVault, Get, ListByNode, MarkMissing, DeleteToTrash, Open (xdg-open) - file_test.go: 5 tests (external, copy-vault, list-node, delete-trash, MIME guess) CODE — STEP 5 (Notes): - migration 003: notes table (node_id PK, file_id, format, original_format, encrypted) - NoteService: Create (node+file+link), Read, Save (with backup to .verstak/history/), Delete, Load - note_test.go: 3 tests (create-read, save-backup, delete) CODE — STEP 6 (GUI): - cmd/verstak-gui/main.go: launches GUI server, opens browser - internal/gui/server.go: HTTP API for nodes/notes/files/search - internal/gui/index.html.go: full inline SPA frontend (dark theme, sidebar tree, cards grid, note editor, search, create modals) - Navigation: sidebar tree → click node → detail view with children + files cards → tab switch (overview/notes/files) → create node/note via modal → edit note in fullscreen textarea → save (with history backup) Acceptance: go build ./... pass, go build -tags gui ./cmd/verstak-gui pass, go test ./... pass (20+ tests). GUI serves on random port, opens browser. API returns JSON for all resource types.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Record represents a file entry linked to a node.
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"` // relative to vault root
|
||||
StorageMode string `json:"storage_mode"` // "vault" | "external"
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// Service provides file operations inside a vault.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
}
|
||||
|
||||
// NewService creates a file service bound to a vault.
|
||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot}
|
||||
}
|
||||
|
||||
// DB returns the underlying storage.
|
||||
func (s *Service) DB() *storage.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// --- public operations ---
|
||||
|
||||
// AddExternal registers an external file (absolute path) without copying.
|
||||
func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
absPath, _ = filepath.Abs(absPath)
|
||||
return s.insertRecord(nodeID, filepath.Base(absPath), absPath, "external", info.Size(), "")
|
||||
}
|
||||
|
||||
// CopyIntoVault copies an external file into the vault.
|
||||
// The file lands at <vaultRoot>/spaces/<nodeSlug>/<filename>.
|
||||
func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if nodeSlug == "" {
|
||||
nodeSlug = nodeID[:8]
|
||||
}
|
||||
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(absPath)
|
||||
dest := filepath.Join(destDir, filename)
|
||||
|
||||
// If destination exists, add a numeric suffix.
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
ext := filepath.Ext(filename)
|
||||
name := strings.TrimSuffix(filename, ext)
|
||||
dest = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().Unix(), ext))
|
||||
filename = filepath.Base(dest)
|
||||
}
|
||||
|
||||
hash, err := copyAndHash(absPath, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||
}
|
||||
|
||||
// Get returns a file record by ID.
|
||||
func (s *Service) Get(id string) (*Record, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,last_seen_at,missing
|
||||
FROM files WHERE id = ?`, id)
|
||||
return scanRecord(row)
|
||||
}
|
||||
|
||||
// ListByNode returns all files linked to a node.
|
||||
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,last_seen_at,missing
|
||||
FROM files WHERE node_id = ? ORDER BY created_at`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRecords(rows)
|
||||
}
|
||||
|
||||
// MarkMissing flags a file as missing.
|
||||
func (s *Service) MarkMissing(id string, missing bool) error {
|
||||
m := 0
|
||||
if missing {
|
||||
m = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE files SET missing=?, updated_at=? WHERE id=?`,
|
||||
m, time.Now().UTC().Format(time.RFC3339), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteToTrash moves a vault file to .verstak/trash/ and removes the record.
|
||||
func (s *Service) DeleteToTrash(id string) error {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.StorageMode == "vault" {
|
||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec("DELETE FROM files WHERE id=?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Open launches the file with the system default application.
|
||||
func (s *Service) Open(id string) error {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
rec := &Record{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Filename: filename,
|
||||
Path: path,
|
||||
StorageMode: mode,
|
||||
Size: size,
|
||||
SHA256: sha,
|
||||
MIME: guessMIME(filename),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
|
||||
rec.Size, rec.SHA256, rec.MIME,
|
||||
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func copyAndHash(src, dest string) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func guessMIME(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".md", ".txt", ".go", ".py", ".js", ".ts", ".sh", ".sql", ".yml", ".yaml", ".json", ".toml", ".xml", ".html", ".css", ".csv", ".rst":
|
||||
return "text/plain"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".odt":
|
||||
return "application/vnd.oasis.opendocument.text"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func openWithSystem(path string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", "", path)
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// --- scanning helpers ---
|
||||
|
||||
type scanFace interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanRecord(s scanFace) (*Record, error) {
|
||||
var r Record
|
||||
var lastSeen sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
err := s.Scan(
|
||||
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
|
||||
&r.Size, &r.SHA256, &r.MIME,
|
||||
&createdStr, &updatedStr, &lastSeen, &r.Missing)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
if lastSeen.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
||||
r.LastSeenAt = &t
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanRecords(rows *sql.Rows) ([]Record, error) {
|
||||
var out []Record
|
||||
for rows.Next() {
|
||||
r, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *storage.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAddExternal(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
// Run migration 002 manually since storage.Open already applied it.
|
||||
// We can verify the table exists by inserting.
|
||||
filesSvc := NewService(db, t.TempDir())
|
||||
|
||||
// Create a real temp file to register.
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(tmpFile, []byte("hello world"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, err := filesSvc.AddExternal("node-1", tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("AddExternal: %v", err)
|
||||
}
|
||||
if rec.ID == "" {
|
||||
t.Fatal("empty id")
|
||||
}
|
||||
if rec.Filename != "test.txt" {
|
||||
t.Errorf("filename = %q", rec.Filename)
|
||||
}
|
||||
if rec.StorageMode != "external" {
|
||||
t.Errorf("mode = %q", rec.StorageMode)
|
||||
}
|
||||
if rec.Size != 11 {
|
||||
t.Errorf("size = %d, want 11", rec.Size)
|
||||
}
|
||||
|
||||
// Verify stored.
|
||||
got, err := filesSvc.Get(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Filename != "test.txt" {
|
||||
t.Errorf("got filename = %q", got.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyIntoVault(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
|
||||
// Source file.
|
||||
srcDir := t.TempDir()
|
||||
srcFile := filepath.Join(srcDir, "doc.pdf")
|
||||
os.WriteFile(srcFile, []byte("PDF content here"), 0o640)
|
||||
|
||||
rec, err := svc.CopyIntoVault("node-1", srcFile, "my-node")
|
||||
if err != nil {
|
||||
t.Fatalf("CopyIntoVault: %v", err)
|
||||
}
|
||||
if rec.SHA256 == "" {
|
||||
t.Error("expected sha256")
|
||||
}
|
||||
if rec.StorageMode != "vault" {
|
||||
t.Errorf("mode = %q", rec.StorageMode)
|
||||
}
|
||||
|
||||
// Verify file on disk.
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); err != nil {
|
||||
t.Errorf("file on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db, t.TempDir())
|
||||
|
||||
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||
f2 := filepath.Join(t.TempDir(), "a2.txt")
|
||||
os.WriteFile(f1, []byte("a"), 0o640)
|
||||
os.WriteFile(f2, []byte("bb"), 0o640)
|
||||
|
||||
svc.AddExternal("node-a", f1)
|
||||
svc.AddExternal("node-a", f2)
|
||||
|
||||
list, err := svc.ListByNode("node-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("list len = %d, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteToTrash(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
|
||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||
os.WriteFile(src, []byte("important data"), 0o640)
|
||||
|
||||
rec, _ := svc.CopyIntoVault("node-x", src, "node-x")
|
||||
|
||||
if err := svc.DeleteToTrash(rec.ID); err != nil {
|
||||
t.Fatalf("DeleteToTrash: %v", err)
|
||||
}
|
||||
|
||||
// File record should be gone.
|
||||
if _, err := svc.Get(rec.ID); err == nil {
|
||||
t.Error("expected error after trash")
|
||||
}
|
||||
|
||||
// Original file should not exist anymore (moved to trash).
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); !os.IsNotExist(err) {
|
||||
t.Error("expected file to be moved from original location")
|
||||
}
|
||||
|
||||
// Trash dir should have it.
|
||||
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
|
||||
entries, _ := os.ReadDir(trashDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("trash entries = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
"a.png": "image/png",
|
||||
"a.pdf": "application/pdf",
|
||||
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"a.go": "text/plain",
|
||||
"a.unknown": "application/octet-stream",
|
||||
}
|
||||
for name, want := range cases {
|
||||
got := guessMIME(name)
|
||||
if got != want {
|
||||
t.Errorf("guessMIME(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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 string) (*nodes.Node, *files.Record, error) {
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeNote, title)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, dir)
|
||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||
return svc, nodeRepo, dir
|
||||
}
|
||||
|
||||
func TestCreateAndRead(t *testing.T) {
|
||||
svc, _, vaultRoot := setupService(t)
|
||||
|
||||
node, fileRec, err := svc.Create("", "My Note")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if node.Title != "My Note" {
|
||||
t.Errorf("title = %q", node.Title)
|
||||
}
|
||||
if fileRec == nil || fileRec.ID == "" {
|
||||
t.Fatal("file record missing")
|
||||
}
|
||||
|
||||
// Read initial content.
|
||||
content, err := svc.Read(node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "My Note") {
|
||||
t.Errorf("content = %q", content)
|
||||
}
|
||||
|
||||
// Verify file on disk.
|
||||
spacesDir := filepath.Join(vaultRoot, "spaces")
|
||||
entries, _ := os.ReadDir(spacesDir)
|
||||
if len(entries) == 0 {
|
||||
t.Error("expected file in spaces/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndBackup(t *testing.T) {
|
||||
svc, _, vaultRoot := setupService(t)
|
||||
|
||||
node, _, _ := svc.Create("", "Backup Test")
|
||||
|
||||
// Save new content.
|
||||
newContent := "# Updated\n\nThis is the new content."
|
||||
if err := svc.Save(node.ID, newContent); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// Read back.
|
||||
got, err := svc.Read(node.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != newContent {
|
||||
t.Errorf("content = %q, want %q", got, newContent)
|
||||
}
|
||||
|
||||
// Check backup exists.
|
||||
histDir := filepath.Join(vaultRoot, ".verstak", "history")
|
||||
entries, _ := os.ReadDir(histDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("backup count = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNote(t *testing.T) {
|
||||
svc, nodeRepo, _ := setupService(t)
|
||||
|
||||
node, _, _ := svc.Create("", "To Delete")
|
||||
if err := svc.Delete(node.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := nodeRepo.GetActive(node.ID); err == nil {
|
||||
t.Error("expected deleted node to be inactive")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
// migration002 — files table for vault file tracking.
|
||||
const migration002 = `
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
filename TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'vault',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT NULL,
|
||||
mime TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NULL,
|
||||
missing INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_node ON files(node_id);
|
||||
`
|
||||
@@ -0,0 +1,12 @@
|
||||
package storage
|
||||
|
||||
// migration003 — notes table.
|
||||
const migration003 = `
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
|
||||
file_id TEXT NOT NULL REFERENCES files(id),
|
||||
format TEXT NOT NULL DEFAULT 'markdown',
|
||||
original_format TEXT NULL,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`
|
||||
@@ -58,7 +58,9 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
|
||||
|
||||
var migrationFiles = map[int]string{
|
||||
1: migration001,
|
||||
// 2: migration002, etc.
|
||||
2: migration002,
|
||||
3: migration003,
|
||||
// 4: migration004, etc.
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
||||
Reference in New Issue
Block a user