refactor: implement template-driven node tree and human-readable vault layout

Unified Node model: added template_id, fs_path, archived, sort_order fields.
Template registry: system templates embedded as JSON (folder/project/client/
document/recipe), with Registry for enabled/disabled/filtered access.
SafeDisplayNameToPathSegment: human-readable path segments with Cyrillic
support, illegal char replacement, uniqueness via numeric suffixes.
Sidebar refactored: system views (Today/Inbox/Activity) separate from
workspace tree. Creation menu built dynamically from enabled templates.
Create/Rename/Move: physical folder operations with fs_path update,
recursive descendant path updates.
DB migration 012: adds template_id, fs_path, archived columns.
Vault migration command: rebuilds fs_path for existing nodes.
Tests: safename, registry, node model, repository integration.
Docs: VAULT_LAYOUT.md, TEMPLATES.md, PLAN.md updated.
i18n: nav.system, nav.workspace, template.*, common.rename/archive,
migrate.* keys added to ru.json and en.json.
This commit is contained in:
2026-06-02 12:47:06 +08:00
parent 12f2916a24
commit 0b26f7e5b3
37 changed files with 1479 additions and 338 deletions
+15 -13
View File
@@ -7,19 +7,21 @@ import (
// 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"`
Section string `json:"section,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"`
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
Section string `json:"section,omitempty"`
SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
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.
+32
View File
@@ -0,0 +1,32 @@
package nodes
import (
"testing"
"time"
)
func TestNodeIsRoot(t *testing.T) {
n := &Node{ID: "1", ParentID: nil}
if !n.IsRoot() {
t.Error("expected node with nil parent to be root")
}
pid := "parent"
n2 := &Node{ID: "2", ParentID: &pid}
if n2.IsRoot() {
t.Error("expected node with parent to not be root")
}
}
func TestNodeIsDeleted(t *testing.T) {
n := &Node{ID: "1", DeletedAt: nil}
if n.IsDeleted() {
t.Error("expected node with nil DeletedAt to not be deleted")
}
now := time.Now()
n2 := &Node{ID: "2", DeletedAt: &now}
if !n2.IsDeleted() {
t.Error("expected node with DeletedAt set to be deleted")
}
}
+115 -69
View File
@@ -50,43 +50,40 @@ func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
// Create inserts a root or child node.
// parentID may be empty for root-level nodes.
// For root nodes, section determines sidebar placement (may be empty = inbox).
// section must be a valid section (clients, projects, etc.) or empty for inbox.
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
// columns used in all SELECT queries.
var nodeColumns = "id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,deleted_at,revision,device_id"
// Create inserts a node. parentID may be nil for root-level nodes.
func (r *Repository) Create(parentID *string, typ, title string, sortOrder int, templateID, fsPath string) (*Node, error) {
if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ)
}
if title == "" {
return nil, errors.New("title is required")
}
if section != "" && !IsValidSection(section) {
return nil, fmt.Errorf("invalid section: %s", section)
}
n := &Node{
ID: util.UUID7(),
Type: typ,
Title: title,
Slug: Slugify(title),
Section: section,
SortOrder: 0,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
Revision: 1,
ID: util.UUID7(),
Type: typ,
Title: title,
Slug: Slugify(title),
TemplateID: templateID,
FsPath: fsPath,
SortOrder: sortOrder,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
Revision: 1,
}
if parentID != "" {
n.ParentID = &parentID
if parentID != nil {
n.ParentID = parentID
}
err := r.insertNode(n)
if err != nil {
return nil, err
}
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
if parentID != nil {
_ = r.touch(*parentID)
}
return n, nil
}
@@ -105,17 +102,13 @@ func (r *Repository) insertNode(n *Node) error {
if n.ParentID != nil {
parent = *n.ParentID
}
var sec interface{}
if n.Section != "" {
sec = n.Section
}
_, err := r.db.Exec(
`INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order,
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
created_at,updated_at,deleted_at,revision,device_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, parent, n.Type, n.Title, n.Slug, n.Path, sec,
n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
n.SortOrder, n.Archived, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
n.DeletedAt, n.Revision, n.DeviceID,
)
return err
@@ -124,9 +117,7 @@ func (r *Repository) insertNode(n *Node) error {
// 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,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE id = ?`, id)
`SELECT `+nodeColumns+` FROM nodes WHERE id = ?`, id)
return scanNode(row)
}
@@ -145,9 +136,7 @@ func (r *Repository) GetActive(id string) (*Node, error) {
// 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,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE parent_id = ?`
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id = ?`
if !includeDeleted {
q += " AND deleted_at IS NULL"
}
@@ -162,29 +151,14 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
}
// ListRoots returns nodes with no parent (top-level).
// When section is set, only returns roots with that exact section
// (or section IS NULL when section="inbox").
func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, error) {
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE parent_id IS NULL`
if section == "inbox" {
q += " AND section IS NULL"
} else if section != "" {
q += " AND section = ?"
}
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id IS NULL`
if !includeDeleted {
q += " AND deleted_at IS NULL"
}
q += " ORDER BY sort_order, title"
var rows *sql.Rows
var err error
if section != "" && section != "inbox" {
rows, err = r.db.Query(q, section)
} else {
rows, err = r.db.Query(q)
}
rows, err := r.db.Query(q)
if err != nil {
return nil, err
}
@@ -192,6 +166,26 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
return scanNodes(rows)
}
// ListByParent returns children as *Node pointers. parentID must not be empty.
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
rows, err := r.db.Query(
`SELECT `+nodeColumns+` FROM nodes WHERE parent_id = ? AND deleted_at IS NULL ORDER BY sort_order, title`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Node
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// todayBoundaries returns RFC3339 start and end strings for the current day
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
func todayBoundaries() (string, string) {
@@ -203,14 +197,9 @@ func todayBoundaries() (string, string) {
}
// ListTodayNodes returns active root-level nodes created or updated today.
// This is a dynamic view, not a section — it shows the day's activity.
// Child nodes (notes, files, folders) are not listed directly; instead,
// their parent is bumped via touch() on creation.
func (r *Repository) ListTodayNodes() ([]Node, error) {
start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes
q := `SELECT ` + nodeColumns + ` FROM nodes
WHERE deleted_at IS NULL
AND parent_id IS NULL
AND (
@@ -248,12 +237,47 @@ func (r *Repository) UpdateTitle(id, title string) error {
return nil
}
// UpdateFsPath updates the fs_path of a single node.
func (r *Repository) UpdateFsPath(id, fsPath string) error {
t := now()
res, err := r.db.Exec(
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`,
fsPath, t, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// UpdateFsPathRecursive updates fs_path for a node and all its descendants.
func (r *Repository) UpdateFsPathRecursive(id, newFsPath string) error {
if err := r.UpdateFsPath(id, newFsPath); err != nil {
return err
}
children, err := r.ListChildren(id, false)
if err != nil {
return err
}
for _, child := range children {
childPath := newFsPath + "/" + child.Slug
if err := r.UpdateFsPathRecursive(child.ID, childPath); err != nil {
return err
}
}
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 {
// newParentID = nil means move to root.
func (r *Repository) Move(id string, newParentID *string, sortOrder int) error {
var parent interface{}
if parentID != "" {
parent = parentID
if newParentID != nil {
parent = *newParentID
}
t := now()
res, err := r.db.Exec(
@@ -270,6 +294,23 @@ func (r *Repository) Move(id, parentID string, sortOrder int) error {
return nil
}
// SetArchived sets the archived flag on a node.
func (r *Repository) SetArchived(id string, archived bool) error {
t := now()
res, err := r.db.Exec(
`UPDATE nodes SET archived=?, updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`,
archived, 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()
@@ -338,12 +379,13 @@ type scanner interface {
func scanNode(s scanner) (*Node, error) {
var n Node
var parentID, path, section, deletedAt, deviceID sql.NullString
var parentID, templateID, fsPath, section, deletedAt, deviceID sql.NullString
var archived int
var createdStr, updatedStr string
err := s.Scan(
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, &section,
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &templateID, &fsPath,
&section, &n.SortOrder, &archived, &createdStr, &updatedStr, &deletedAt,
&n.Revision, &deviceID,
)
if err == sql.ErrNoRows {
@@ -356,8 +398,11 @@ func scanNode(s scanner) (*Node, error) {
if parentID.Valid {
n.ParentID = &parentID.String
}
if path.Valid {
n.Path = &path.String
if templateID.Valid {
n.TemplateID = templateID.String
}
if fsPath.Valid {
n.FsPath = fsPath.String
}
if section.Valid {
n.Section = section.String
@@ -369,6 +414,7 @@ func scanNode(s scanner) (*Node, error) {
if deviceID.Valid {
n.DeviceID = &deviceID.String
}
n.Archived = archived != 0
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
+23 -21
View File
@@ -19,6 +19,8 @@ func openTestDB(t *testing.T) *storage.DB {
return db
}
func nodePtr(s string) *string { return &s }
func TestSlugify(t *testing.T) {
cases := []struct{ in, want string }{
{"ООО Ромашка", "ооо-ромашка"},
@@ -40,7 +42,7 @@ func TestCreateAndGet(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Test Case", "")
n, err := repo.Create(nil, TypeCase, "Test Case", 0, "", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -69,12 +71,12 @@ func TestCreateChild(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
parent, err := repo.Create("", TypeFolder, "Folder", "")
parent, err := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
if err != nil {
t.Fatal(err)
}
child, err := repo.Create(parent.ID, TypeCase, "Child", "")
child, err := repo.Create(nodePtr(parent.ID), TypeCase, "Child", 0, "", "")
if err != nil {
t.Fatal(err)
}
@@ -87,9 +89,9 @@ 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", "")
parent, _ := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
repo.Create(nodePtr(parent.ID), TypeCase, "A", 0, "", "")
repo.Create(nodePtr(parent.ID), TypeCase, "B", 0, "", "")
children, err := repo.ListChildren(parent.ID, false)
if err != nil {
@@ -108,10 +110,10 @@ func TestListRoots(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
repo.Create("", TypeCase, "One", "")
repo.Create("", TypeCase, "Two", "")
repo.Create(nil, TypeCase, "One", 0, "", "")
repo.Create(nil, TypeCase, "Two", 0, "", "")
roots, err := repo.ListRoots(false, "")
roots, err := repo.ListRoots(false)
if err != nil {
t.Fatal(err)
}
@@ -124,7 +126,7 @@ func TestUpdateTitle(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "Old", "")
n, _ := repo.Create(nil, TypeCase, "Old", 0, "", "")
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
t.Fatal(err)
}
@@ -142,12 +144,12 @@ 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", "")
a, _ := repo.Create(nil, TypeFolder, "A", 0, "", "")
b, _ := repo.Create(nil, TypeFolder, "B", 0, "", "")
child, _ := repo.Create(nodePtr(a.ID), TypeCase, "Child", 0, "", "")
// Move child from A to B.
if err := repo.Move(child.ID, b.ID, 0); err != nil {
if err := repo.Move(child.ID, nodePtr(b.ID), 0); err != nil {
t.Fatal(err)
}
@@ -157,7 +159,7 @@ func TestMove(t *testing.T) {
}
// Move to root.
if err := repo.Move(child.ID, "", 0); err != nil {
if err := repo.Move(child.ID, nil, 0); err != nil {
t.Fatal(err)
}
got2, _ := repo.Get(child.ID)
@@ -170,7 +172,7 @@ func TestSoftDelete(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "To Delete", "")
n, _ := repo.Create(nil, TypeCase, "To Delete", 0, "", "")
if err := repo.SoftDelete(n.ID); err != nil {
t.Fatal(err)
}
@@ -189,8 +191,8 @@ func TestSoftDelete(t *testing.T) {
}
// ListChildren without includeDeleted must skip it.
parent, _ := repo.Create("", TypeFolder, "P", "")
child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
parent, _ := repo.Create(nil, TypeFolder, "P", 0, "", "")
child, _ := repo.Create(nodePtr(parent.ID), TypeCase, "Kid", 0, "", "")
repo.SoftDelete(child.ID)
kids, _ := repo.ListChildren(parent.ID, false)
@@ -208,7 +210,7 @@ func TestMetaKV(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "M", "")
n, _ := repo.Create(nil, TypeCase, "M", 0, "", "")
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
t.Fatal(err)
}
@@ -249,7 +251,7 @@ func TestNotFound(t *testing.T) {
if err := repo.SoftDelete("nonexistent"); err != ErrNotFound {
t.Errorf("SoftDelete returned %v, want ErrNotFound", err)
}
if err := repo.Move("nonexistent", "", 0); err != ErrNotFound {
if err := repo.Move("nonexistent", nil, 0); err != ErrNotFound {
t.Errorf("Move returned %v, want ErrNotFound", err)
}
}
@@ -266,7 +268,7 @@ func TestInitEndToEnd(t *testing.T) {
defer db.Close()
repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Integration Case", "")
n, err := repo.Create(nil, TypeCase, "Integration Case", 0, "", "")
if err != nil {
t.Fatal(err)
}
+14 -34
View File
@@ -5,16 +5,18 @@ import (
"unicode"
)
// Valid node types.
// Node types.
const (
TypeFolder = "folder"
TypeProject = "project"
TypeClient = "client"
TypeDocument = "document"
TypeRecipe = "recipe"
TypeSpace = "space"
TypeCase = "case"
TypeFolder = "folder"
TypeNote = "note"
TypeDocument = "document"
TypeFile = "file"
TypeAction = "action"
TypeRecipe = "recipe"
TypeSecret = "secret"
TypeWorklog = "worklog"
TypeLink = "link"
@@ -22,34 +24,24 @@ const (
// TypeSet for quick validation.
var TypeSet = map[string]struct{}{
TypeFolder: {},
TypeProject: {},
TypeClient: {},
TypeDocument: {},
TypeRecipe: {},
TypeSpace: {},
TypeCase: {},
TypeFolder: {},
TypeNote: {},
TypeDocument: {},
TypeFile: {},
TypeAction: {},
TypeRecipe: {},
TypeSecret: {},
TypeWorklog: {},
TypeLink: {},
}
// Valid sections for root-level nodes.
// today and inbox are service sections, not stored in nodes.section.
var validSections = map[string]struct{}{
"clients": {},
"projects": {},
"recipes": {},
"documents": {},
"archive": {},
}
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
"activity": {},
// RootTypes returns the node types that can appear at workspace root.
func RootTypes() []string {
return []string{TypeFolder, TypeProject, TypeClient, TypeDocument, TypeRecipe, TypeSpace, TypeCase}
}
// IsValidType checks whether a type string is recognized.
@@ -58,18 +50,6 @@ func IsValidType(t string) bool {
return ok
}
// IsValidSection returns true for sections that can be stored on a node.
func IsValidSection(s string) bool {
_, ok := validSections[s]
return ok
}
// IsServiceSection returns true for sidebar-only sections (today, inbox).
func IsServiceSection(s string) bool {
_, ok := serviceSections[s]
return ok
}
// Slugify converts a title into a filesystem-safe slug.
// Examples:
//