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:
mirivlad 2026-05-30 19:09:25 +08:00
parent b8d8427c46
commit 69eb909d48
7 changed files with 993 additions and 16 deletions

View File

@ -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 "."
}

133
cmd/verstak/node_cmd.go Normal file
View File

@ -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
}

View File

@ -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 | ⬜ не начат |

View File

@ -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"`
}

View File

@ -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()
}

View File

@ -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

View File

@ -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
}