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
+294
View File
@@ -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()
}
+158
View File
@@ -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)
}
}
}