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:
2026-05-30 20:35:04 +08:00
parent 69eb909d48
commit 39271fc28f
19 changed files with 1948 additions and 236 deletions
+203
View File
@@ -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)
}
+99
View File
@@ -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")
}
}