diff --git a/cmd/verstak/main.go b/cmd/verstak/main.go index 65c6676..6ffcd48 100644 --- a/cmd/verstak/main.go +++ b/cmd/verstak/main.go @@ -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 [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 "." +} diff --git a/cmd/verstak/node_cmd.go b/cmd/verstak/node_cmd.go new file mode 100644 index 0000000..1af4fc2 --- /dev/null +++ b/cmd/verstak/node_cmd.go @@ -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 [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 +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 07952b8..afe59bd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 | ⬜ не начат | diff --git a/internal/core/nodes/node.go b/internal/core/nodes/node.go new file mode 100644 index 0000000..33eb289 --- /dev/null +++ b/internal/core/nodes/node.go @@ -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"` +} diff --git a/internal/core/nodes/repository.go b/internal/core/nodes/repository.go new file mode 100644 index 0000000..4f75f15 --- /dev/null +++ b/internal/core/nodes/repository.go @@ -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() +} diff --git a/internal/core/nodes/repository_test.go b/internal/core/nodes/repository_test.go new file mode 100644 index 0000000..c88e5d1 --- /dev/null +++ b/internal/core/nodes/repository_test.go @@ -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 diff --git a/internal/core/nodes/types.go b/internal/core/nodes/types.go new file mode 100644 index 0000000..be96a83 --- /dev/null +++ b/internal/core/nodes/types.go @@ -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 +}