step 3: nodes repository + CRUD + CLI node commands
- types.go: TypeSpace/Case/Folder/Note/... + Slugify() - node.go: Node struct, Meta, IsDeleted/IsRoot helpers - repository.go: full CRUD (Create, Get, GetActive, ListChildren, ListRoots, UpdateTitle, Move, SoftDelete) + Meta KV (MetaSet, MetaGet, MetaList) - node_cmd.go: thin wrappers around repository - main.go: verstak node create/list/get/move/delete subcommands - repository_test.go: 12 tests covering all CRUD paths Acceptance: go build pass, go test pass (12 tests), CLI create+list+get+move+delete all working.
This commit is contained in:
parent
b8d8427c46
commit
69eb909d48
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"verstak/internal/core/vault"
|
||||
)
|
||||
|
|
@ -22,7 +23,9 @@ func main() {
|
|||
case "--help", "-h":
|
||||
usage()
|
||||
case "init":
|
||||
runInit()
|
||||
runInit(os.Args[2:])
|
||||
case "node":
|
||||
runNode(os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
|
|
@ -35,34 +38,156 @@ func usage() {
|
|||
fmt.Println("Usage: verstak <command> [flags]")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" init --vault PATH Initialize a new vault at PATH")
|
||||
fmt.Println(" --version Show version")
|
||||
fmt.Println(" --help Show this help")
|
||||
fmt.Println(" init Initialize a new vault")
|
||||
fmt.Println(" node Manage nodes")
|
||||
fmt.Println(" --version Show version")
|
||||
fmt.Println(" --help Show this help")
|
||||
}
|
||||
|
||||
func runInit() {
|
||||
vaultPath := "."
|
||||
// Parse --vault flag
|
||||
for i := 2; i < len(os.Args); i++ {
|
||||
if os.Args[i] == "--vault" && i+1 < len(os.Args) {
|
||||
vaultPath = os.Args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
// --- init ---
|
||||
|
||||
func runInit(args []string) {
|
||||
vaultPath := vaultPathFromFlags(args)
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := vault.Init(abs); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Init failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Vault initialized at", abs)
|
||||
fmt.Println(" .verstak/index.db")
|
||||
fmt.Println(" .verstak/config.yml")
|
||||
fmt.Println(" spaces/")
|
||||
}
|
||||
|
||||
// --- node ---
|
||||
|
||||
func runNode(args []string) {
|
||||
if len(args) == 0 {
|
||||
nodeUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
|
||||
switch sub {
|
||||
case "create":
|
||||
runNodeCreateCmd(rest)
|
||||
case "list":
|
||||
runNodeListCmd(rest)
|
||||
case "get":
|
||||
runNodeGetCmd(rest)
|
||||
case "move":
|
||||
runNodeMoveCmd(rest)
|
||||
case "delete":
|
||||
runNodeDeleteCmd(rest)
|
||||
case "--help", "-h":
|
||||
nodeUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown node command: %s\n", sub)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runNodeCreateCmd(args []string) {
|
||||
typ, _ := stringFlag(args, "--type")
|
||||
title, _ := stringFlag(args, "--title")
|
||||
parentID, _ := stringFlag(args, "--parent")
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
|
||||
if typ == "" || title == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --type and --title required")
|
||||
nodeUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
if err := runNodeCreate(abs, parentID, typ, title); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Create failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runNodeListCmd(args []string) {
|
||||
parentID, _ := stringFlag(args, "--parent")
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
if err := runNodeList(abs, parentID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "List failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runNodeGetCmd(args []string) {
|
||||
id, _ := stringFlag(args, "--id")
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
|
||||
if id == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --id required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
if err := runNodeGet(abs, id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Get failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runNodeMoveCmd(args []string) {
|
||||
id, _ := stringFlag(args, "--id")
|
||||
parentID, _ := stringFlag(args, "--parent")
|
||||
sortStr, _ := stringFlag(args, "--sort")
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
|
||||
if id == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --id required")
|
||||
os.Exit(1)
|
||||
}
|
||||
sort, _ := strconv.Atoi(sortStr)
|
||||
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
if err := runNodeMove(abs, id, parentID, sort); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Move failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runNodeDeleteCmd(args []string) {
|
||||
id, _ := stringFlag(args, "--id")
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
|
||||
if id == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --id required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
if err := runNodeDelete(abs, id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Delete failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- flag parsing ---
|
||||
|
||||
func stringFlag(args []string, name string) (string, bool) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == name && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func vaultPathFromFlags(args []string) string {
|
||||
if v, ok := stringFlag(args, "--vault"); ok {
|
||||
return v
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
// Command helpers for the node subcommand.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func runNodeCreate(vault, parentID, typ, title string) error {
|
||||
dbPath := filepath.Join(vault, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
n, err := repo.Create(parentID, typ, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created\t%s\t%s\t%s\n", n.ID, n.Type, n.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNodeGet(vault, id string) error {
|
||||
dbPath := filepath.Join(vault, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
n, err := repo.GetActive(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("id=%s\n", n.ID)
|
||||
fmt.Printf("parent=%v\n", ptrStr(n.ParentID))
|
||||
fmt.Printf("type=%s\n", n.Type)
|
||||
fmt.Printf("title=%s\n", n.Title)
|
||||
fmt.Printf("slug=%s\n", n.Slug)
|
||||
fmt.Printf("revision=%d\n", n.Revision)
|
||||
fmt.Printf("created=%s\n", n.CreatedAt)
|
||||
fmt.Printf("updated=%s\n", n.UpdatedAt)
|
||||
meta, _ := repo.MetaList(n.ID)
|
||||
for _, m := range meta {
|
||||
fmt.Printf("meta:%s=%s\n", m.Key, m.Value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNodeList(vault, parentID string) error {
|
||||
dbPath := filepath.Join(vault, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
var list []nodes.Node
|
||||
if parentID == "" {
|
||||
list, err = repo.ListRoots(false)
|
||||
} else {
|
||||
list, err = repo.ListChildren(parentID, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range list {
|
||||
fmt.Printf("%s\t%s\t%s\n", n.ID, n.Type, n.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNodeMove(vault, id, parentID string, sortOrder int) error {
|
||||
dbPath := filepath.Join(vault, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
if err := repo.Move(id, parentID, sortOrder); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("moved")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNodeDelete(vault, id string) error {
|
||||
dbPath := filepath.Join(vault, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
if err := repo.SoftDelete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func nodeUsage() {
|
||||
fmt.Println("verstak node — manage nodes in the vault")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: verstak node <command> [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" create --type TYPE --title TITLE [--parent ID] Create a node")
|
||||
fmt.Println(" list [--parent ID] List root or children")
|
||||
fmt.Println(" get --id ID Show node details")
|
||||
fmt.Println(" move --id ID [--parent ID] [--sort N] Move node")
|
||||
fmt.Println(" delete --id ID Soft-delete node")
|
||||
fmt.Println()
|
||||
fmt.Println("Types: space, case, folder, note, document, file, action, recipe, secret, worklog, link")
|
||||
}
|
||||
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
| # | Шаг | Статус |
|
||||
|---|-----|--------|
|
||||
| 1 | Git init + Skeleton | ✅ выполнен |
|
||||
| 2 | Init + SQLite + First Migration | ⬜ не начат |
|
||||
| 2 | Init + SQLite + First Migration | ✅ выполнен |
|
||||
| 3 | Nodes Repository + CRUD + CLI Node | ⬜ не начат |
|
||||
| 4 | Vault Files: Trash + File Service + CLI File | ⬜ не начат |
|
||||
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ⬜ не начат |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
package nodes
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Node is the central entity of Verstak — a tree item that can be
|
||||
// a case, folder, note, document, etc.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
DeviceID *string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// IsDeleted reports whether the node has been soft-deleted.
|
||||
func (n *Node) IsDeleted() bool {
|
||||
return n.DeletedAt != nil
|
||||
}
|
||||
|
||||
// IsRoot reports whether the node has no parent.
|
||||
func (n *Node) IsRoot() bool {
|
||||
return n.ParentID == nil
|
||||
}
|
||||
|
||||
// Meta is a generic key-value attached to a node.
|
||||
type Meta struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// NodeWithMeta bundles a node and its metadata entries.
|
||||
type NodeWithMeta struct {
|
||||
Node Node `json:"node"`
|
||||
Meta []Meta `json:"meta"`
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
package nodes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a node cannot be located.
|
||||
ErrNotFound = errors.New("node not found")
|
||||
// ErrDuplicateSlug means slug uniqueness is violated within the parent.
|
||||
ErrDuplicateSlug = errors.New("duplicate slug under the same parent")
|
||||
)
|
||||
|
||||
// Repository provides CRUD for the nodes table.
|
||||
type Repository struct {
|
||||
db *storage.DB
|
||||
}
|
||||
|
||||
// NewRepository wraps an open storage DB.
|
||||
func NewRepository(db *storage.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// DB returns the underlying storage (useful for transactions).
|
||||
func (r *Repository) DB() *storage.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// tx is a small helper for one-statement anonymous function transactions.
|
||||
func tx(db *storage.DB, fn func(*sql.Tx) error) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer t.Rollback()
|
||||
if err := fn(t); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Commit()
|
||||
}
|
||||
|
||||
// now returns an RFC3339 timestamp.
|
||||
func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// Create inserts a root or child node.
|
||||
// parentID may be empty for root-level nodes.
|
||||
// The id, timestamps, revision and slug are generated if not provided.
|
||||
func (r *Repository) Create(parentID string, typ, title string) (*Node, error) {
|
||||
if !IsValidType(typ) {
|
||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||
}
|
||||
if title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
|
||||
n := &Node{
|
||||
ID: util.UUID7(),
|
||||
Type: typ,
|
||||
Title: title,
|
||||
Slug: Slugify(title),
|
||||
SortOrder: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Revision: 1,
|
||||
}
|
||||
if parentID != "" {
|
||||
n.ParentID = &parentID
|
||||
}
|
||||
|
||||
err := r.insertNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) insertNode(n *Node) error {
|
||||
var parent interface{}
|
||||
if n.ParentID != nil {
|
||||
parent = *n.ParentID
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,path,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.Path,
|
||||
n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||
n.DeletedAt, n.Revision, n.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns a plain node (even if soft-deleted).
|
||||
func (r *Repository) Get(id string) (*Node, error) {
|
||||
row := r.db.QueryRow(
|
||||
`SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE id = ?`, id)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
// GetActive returns ErrNotFound if the node is soft-deleted or missing.
|
||||
func (r *Repository) GetActive(id string) (*Node, error) {
|
||||
n, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.IsDeleted() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListChildren returns direct children ordered by sort_order, then title.
|
||||
// IncludeDeleted lists soft-deleted children too.
|
||||
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id = ?`
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
q += " ORDER BY sort_order, title"
|
||||
|
||||
rows, err := r.db.Query(q, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// ListRoots returns nodes with no parent (top-level).
|
||||
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id IS NULL`
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
q += " ORDER BY sort_order, title"
|
||||
|
||||
rows, err := r.db.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// UpdateTitle changes title (slug is recomputed).
|
||||
func (r *Repository) UpdateTitle(id, title string) error {
|
||||
if title == "" {
|
||||
return errors.New("title required")
|
||||
}
|
||||
slug := Slugify(title)
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
title, slug, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move changes the parent and/or sort order of a node.
|
||||
// parentID="" means move to root.
|
||||
func (r *Repository) Move(id, parentID string, sortOrder int) error {
|
||||
var parent interface{}
|
||||
if parentID != "" {
|
||||
parent = parentID
|
||||
}
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, sort_order=?, updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
parent, sortOrder, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete marks a node as deleted (does not touch its children).
|
||||
func (r *Repository) SoftDelete(id string) error {
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
t, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MetaSet sets or overwrites a key-value pair for a node.
|
||||
func (r *Repository) MetaSet(nodeID, key, value string) error {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO node_meta (node_id,key,value) VALUES (?,?,?)
|
||||
ON CONFLICT(node_id,key) DO UPDATE SET value=excluded.value`,
|
||||
nodeID, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// MetaGet returns a single meta value, ok=false if missing.
|
||||
func (r *Repository) MetaGet(nodeID, key string) (string, bool, error) {
|
||||
var v string
|
||||
err := r.db.QueryRow(
|
||||
`SELECT value FROM node_meta WHERE node_id=? AND key=?`, nodeID, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
// MetaList returns all meta for a node.
|
||||
func (r *Repository) MetaList(nodeID string) ([]Meta, error) {
|
||||
rows, err := r.db.Query(
|
||||
`SELECT key,value FROM node_meta WHERE node_id=?`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Meta
|
||||
for rows.Next() {
|
||||
var m Meta
|
||||
if err := rows.Scan(&m.Key, &m.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- scanning helpers ---
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanNode(s scanner) (*Node, error) {
|
||||
var n Node
|
||||
var parentID, path, deletedAt, deviceID sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
|
||||
err := s.Scan(
|
||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path,
|
||||
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
|
||||
&n.Revision, &deviceID,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if parentID.Valid {
|
||||
n.ParentID = &parentID.String
|
||||
}
|
||||
if path.Valid {
|
||||
n.Path = &path.String
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, deletedAt.String)
|
||||
n.DeletedAt = &t
|
||||
}
|
||||
if deviceID.Valid {
|
||||
n.DeviceID = &deviceID.String
|
||||
}
|
||||
|
||||
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func scanNodes(rows *sql.Rows) ([]Node, error) {
|
||||
var out []Node
|
||||
for rows.Next() {
|
||||
n, err := scanNode(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
package nodes
|
||||
|
||||
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 TestSlugify(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"ООО Ромашка", "ооо-ромашка"},
|
||||
{"My Project!", "my-project"},
|
||||
{"---", "untitled"},
|
||||
{"", "untitled"},
|
||||
{"A B", "a-b"},
|
||||
{"hello_world", "hello-world"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Slugify(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, err := repo.Create("", TypeCase, "Test Case")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if n.ID == "" {
|
||||
t.Fatal("empty ID")
|
||||
}
|
||||
if n.Slug != "test-case" {
|
||||
t.Errorf("slug = %q, want test-case", n.Slug)
|
||||
}
|
||||
|
||||
got, err := repo.Get(n.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Title != "Test Case" {
|
||||
t.Errorf("title = %q", got.Title)
|
||||
}
|
||||
|
||||
// GetActive on a live node.
|
||||
if _, err := repo.GetActive(n.ID); err != nil {
|
||||
t.Errorf("GetActive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChild(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, err := repo.Create("", TypeFolder, "Folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child, err := repo.Create(parent.ID, TypeCase, "Child")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if child.ParentID == nil || *child.ParentID != parent.ID {
|
||||
t.Errorf("parent_id = %v, want %s", child.ParentID, parent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChildren(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, _ := repo.Create("", TypeFolder, "Folder")
|
||||
repo.Create(parent.ID, TypeCase, "A")
|
||||
repo.Create(parent.ID, TypeCase, "B")
|
||||
|
||||
children, err := repo.ListChildren(parent.ID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(children) != 2 {
|
||||
t.Errorf("children = %d, want 2", len(children))
|
||||
}
|
||||
// Ordered by title.
|
||||
if children[0].Title != "A" || children[1].Title != "B" {
|
||||
t.Errorf("order: %s, %s", children[0].Title, children[1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoots(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
repo.Create("", TypeCase, "One")
|
||||
repo.Create("", TypeCase, "Two")
|
||||
|
||||
roots, err := repo.ListRoots(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) != 2 {
|
||||
t.Errorf("roots = %d, want 2", len(roots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTitle(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "Old")
|
||||
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := repo.Get(n.ID)
|
||||
if got.Title != "New Title" {
|
||||
t.Errorf("title = %q", got.Title)
|
||||
}
|
||||
if got.Revision != 2 {
|
||||
t.Errorf("revision = %d, want 2", got.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMove(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
a, _ := repo.Create("", TypeFolder, "A")
|
||||
b, _ := repo.Create("", TypeFolder, "B")
|
||||
child, _ := repo.Create(a.ID, TypeCase, "Child")
|
||||
|
||||
// Move child from A to B.
|
||||
if err := repo.Move(child.ID, b.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := repo.Get(child.ID)
|
||||
if got.ParentID == nil || *got.ParentID != b.ID {
|
||||
t.Errorf("parent = %v, want %s", got.ParentID, b.ID)
|
||||
}
|
||||
|
||||
// Move to root.
|
||||
if err := repo.Move(child.ID, "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got2, _ := repo.Get(child.ID)
|
||||
if got2.ParentID != nil {
|
||||
t.Errorf("parent = %v, want nil", got2.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDelete(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "To Delete")
|
||||
if err := repo.SoftDelete(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := repo.GetActive(n.ID); err != ErrNotFound {
|
||||
t.Errorf("GetActive returned %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Should still be fetchable via Get.
|
||||
got, err := repo.Get(n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.IsDeleted() {
|
||||
t.Error("node should be deleted")
|
||||
}
|
||||
|
||||
// ListChildren without includeDeleted must skip it.
|
||||
parent, _ := repo.Create("", TypeFolder, "P")
|
||||
child, _ := repo.Create(parent.ID, TypeCase, "Kid")
|
||||
repo.SoftDelete(child.ID)
|
||||
|
||||
kids, _ := repo.ListChildren(parent.ID, false)
|
||||
if len(kids) != 0 {
|
||||
t.Errorf("expected 0 children, got %d", len(kids))
|
||||
}
|
||||
|
||||
kidsAll, _ := repo.ListChildren(parent.ID, true)
|
||||
if len(kidsAll) != 1 {
|
||||
t.Errorf("expected 1 child with deleted, got %d", len(kidsAll))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaKV(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "M")
|
||||
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, ok, err := repo.MetaGet(n.ID, "status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || v != "active" {
|
||||
t.Errorf("meta got %q, ok=%v", v, ok)
|
||||
}
|
||||
|
||||
// Overwrite.
|
||||
repo.MetaSet(n.ID, "status", "archived")
|
||||
v, _, _ = repo.MetaGet(n.ID, "status")
|
||||
if v != "archived" {
|
||||
t.Errorf("meta = %q, want archived", v)
|
||||
}
|
||||
|
||||
list, err := repo.MetaList(n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("meta count = %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
if _, err := repo.Get("nonexistent"); err != ErrNotFound {
|
||||
t.Errorf("Get returned %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.UpdateTitle("nonexistent", "x"); err != ErrNotFound {
|
||||
t.Errorf("UpdateTitle returned %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.SoftDelete("nonexistent"); err != ErrNotFound {
|
||||
t.Errorf("SoftDelete returned %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.Move("nonexistent", "", 0); err != ErrNotFound {
|
||||
t.Errorf("Move returned %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitEndToEnd(t *testing.T) {
|
||||
// Integration-like test: open a temp vault through storage, create and
|
||||
// read a node — proves the migration + repository stack works.
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
repo := NewRepository(db)
|
||||
n, err := repo.Create("", TypeCase, "Integration Case")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repo.Get(n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "Integration Case" {
|
||||
t.Errorf("title = %q", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Silence "os" import; keep unused-reference guard from breaking.
|
||||
var _ = os.Args
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package nodes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Valid node types.
|
||||
const (
|
||||
TypeSpace = "space"
|
||||
TypeCase = "case"
|
||||
TypeFolder = "folder"
|
||||
TypeNote = "note"
|
||||
TypeDocument = "document"
|
||||
TypeFile = "file"
|
||||
TypeAction = "action"
|
||||
TypeRecipe = "recipe"
|
||||
TypeSecret = "secret"
|
||||
TypeWorklog = "worklog"
|
||||
TypeLink = "link"
|
||||
)
|
||||
|
||||
// TypeSet for quick validation.
|
||||
var TypeSet = map[string]struct{}{
|
||||
TypeSpace: {},
|
||||
TypeCase: {},
|
||||
TypeFolder: {},
|
||||
TypeNote: {},
|
||||
TypeDocument: {},
|
||||
TypeFile: {},
|
||||
TypeAction: {},
|
||||
TypeRecipe: {},
|
||||
TypeSecret: {},
|
||||
TypeWorklog: {},
|
||||
TypeLink: {},
|
||||
}
|
||||
|
||||
// IsValidType checks whether a type string is recognized.
|
||||
func IsValidType(t string) bool {
|
||||
_, ok := TypeSet[t]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Slugify converts a title into a filesystem-safe slug.
|
||||
// Examples:
|
||||
//
|
||||
// "ООО Ромашка" → "ooo-romashka"
|
||||
// "My Project!" → "my-project"
|
||||
// "---" → "untitled"
|
||||
func Slugify(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case unicode.IsLetter(r):
|
||||
// Keep non-latin letters (Cyrillic etc.) as-is.
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
default:
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
result := strings.TrimRight(b.String(), "-")
|
||||
if result == "" {
|
||||
return "untitled"
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
Reference in New Issue