feat: node section assignment for sidebar filtering + search fix

Backend:
- Migration 004: add 'section' column to nodes table
  (NULL=inbox, values: clients/projects/recipes/documents/archive)
- Create(parentID, type, title, section) — section stored on root nodes
- ListRoots(includeDeleted, section) — filters by section
  (section='inbox' returns nodes with NULL section)
- GET /api/nodes?section=X filters root nodes by section
- POST /api/nodes accepts 'section' field in body

Frontend:
- Sidebar separates 'НАВИГАЦИЯ' (virtual sections) from 'ДЕЛА' (real nodes)
- Each section loads only its own nodes: GET /api/nodes?section=clients etc.
- Creating from a section sets the section automatically
- Inbox shows only nodes with no section
- selectBySearch(id) closes result dropdown after selection
- All types shown in Russian (Дело, Заметка, Папка, etc.)

Acceptance: go build pass, go test pass (all packages),
  manual: Pro projects section shows only project-nodes,
  clients only client-nodes, inbox only unsectioned nodes.
This commit is contained in:
2026-05-31 01:26:46 +08:00
parent 14ff1a25b9
commit 9ee6df0d3f
11 changed files with 141 additions and 72 deletions
+1
View File
@@ -13,6 +13,7 @@ type Node struct {
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"`
+38 -12
View File
@@ -52,10 +52,15 @@ func now() string {
// --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// 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) {
// For root nodes, section determines sidebar placement (may be empty → inbox).
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ)
}
@@ -68,6 +73,7 @@ func (r *Repository) Create(parentID string, typ, title string) (*Node, error) {
Type: typ,
Title: title,
Slug: Slugify(title),
Section: section,
SortOrder: 0,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
@@ -89,12 +95,16 @@ 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,sort_order,
`INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, parent, n.Type, n.Title, n.Slug, n.Path,
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),
n.DeletedAt, n.Revision, n.DeviceID,
)
@@ -104,7 +114,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,sort_order,
`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)
return scanNode(row)
@@ -125,7 +135,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,sort_order,
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 = ?`
if !includeDeleted {
@@ -142,16 +152,29 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
}
// 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,
// 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 = ?"
}
if !includeDeleted {
q += " AND deleted_at IS NULL"
}
q += " ORDER BY sort_order, title"
rows, err := r.db.Query(q)
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)
}
if err != nil {
return nil, err
}
@@ -270,11 +293,11 @@ type scanner interface {
func scanNode(s scanner) (*Node, error) {
var n Node
var parentID, path, deletedAt, deviceID sql.NullString
var parentID, path, section, deletedAt, deviceID sql.NullString
var createdStr, updatedStr string
err := s.Scan(
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path,
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, &section,
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
&n.Revision, &deviceID,
)
@@ -291,6 +314,9 @@ func scanNode(s scanner) (*Node, error) {
if path.Valid {
n.Path = &path.String
}
if section.Valid {
n.Section = section.String
}
if deletedAt.Valid {
t, _ := time.Parse(time.RFC3339, deletedAt.String)
n.DeletedAt = &t
+18 -18
View File
@@ -40,7 +40,7 @@ func TestCreateAndGet(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Test Case")
n, err := repo.Create("", TypeCase, "Test Case", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -69,12 +69,12 @@ func TestCreateChild(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
parent, err := repo.Create("", TypeFolder, "Folder")
parent, err := repo.Create("", TypeFolder, "Folder", "")
if err != nil {
t.Fatal(err)
}
child, err := repo.Create(parent.ID, TypeCase, "Child")
child, err := repo.Create(parent.ID, TypeCase, "Child", "")
if err != nil {
t.Fatal(err)
}
@@ -87,9 +87,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("", TypeFolder, "Folder", "")
repo.Create(parent.ID, TypeCase, "A", "")
repo.Create(parent.ID, TypeCase, "B", "")
children, err := repo.ListChildren(parent.ID, false)
if err != nil {
@@ -108,10 +108,10 @@ func TestListRoots(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
repo.Create("", TypeCase, "One")
repo.Create("", TypeCase, "Two")
repo.Create("", TypeCase, "One", "")
repo.Create("", TypeCase, "Two", "")
roots, err := repo.ListRoots(false)
roots, err := repo.ListRoots(false, "")
if err != nil {
t.Fatal(err)
}
@@ -124,7 +124,7 @@ func TestUpdateTitle(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "Old")
n, _ := repo.Create("", TypeCase, "Old", "")
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
t.Fatal(err)
}
@@ -142,9 +142,9 @@ 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("", 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 {
@@ -170,7 +170,7 @@ func TestSoftDelete(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "To Delete")
n, _ := repo.Create("", TypeCase, "To Delete", "")
if err := repo.SoftDelete(n.ID); err != nil {
t.Fatal(err)
}
@@ -189,8 +189,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("", TypeFolder, "P", "")
child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
repo.SoftDelete(child.ID)
kids, _ := repo.ListChildren(parent.ID, false)
@@ -208,7 +208,7 @@ func TestMetaKV(t *testing.T) {
db := openTestDB(t)
repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "M")
n, _ := repo.Create("", TypeCase, "M", "")
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
t.Fatal(err)
}
@@ -266,7 +266,7 @@ func TestInitEndToEnd(t *testing.T) {
defer db.Close()
repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Integration Case")
n, err := repo.Create("", TypeCase, "Integration Case", "")
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -34,8 +34,8 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fi
}
// Create makes a new note node, an empty .md file, and links them.
func (s *Service) Create(parentID, title string) (*nodes.Node, *files.Record, error) {
node, err := s.nodes.Create(parentID, nodes.TypeNote, title)
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
node, err := s.nodes.Create(parentID, nodes.TypeNote, title, section)
if err != nil {
return nil, nil, fmt.Errorf("create node: %w", err)
}
+3 -3
View File
@@ -29,7 +29,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
func TestCreateAndRead(t *testing.T) {
svc, _, vaultRoot := setupService(t)
node, fileRec, err := svc.Create("", "My Note")
node, fileRec, err := svc.Create("", "My Note", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -60,7 +60,7 @@ func TestCreateAndRead(t *testing.T) {
func TestSaveAndBackup(t *testing.T) {
svc, _, vaultRoot := setupService(t)
node, _, _ := svc.Create("", "Backup Test")
node, _, _ := svc.Create("", "Backup Test", "")
// Save new content.
newContent := "# Updated\n\nThis is the new content."
@@ -88,7 +88,7 @@ func TestSaveAndBackup(t *testing.T) {
func TestDeleteNote(t *testing.T) {
svc, nodeRepo, _ := setupService(t)
node, _, _ := svc.Create("", "To Delete")
node, _, _ := svc.Create("", "To Delete", "")
if err := svc.Delete(node.ID); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,9 @@
package storage
// migration004 — add section column to nodes for sidebar grouping.
// Valid sections: clients, projects, recipes, documents, archive, inbox.
// NULL = unassigned (inbox).
const migration004 = `
ALTER TABLE nodes ADD COLUMN section TEXT NULL;
CREATE INDEX IF NOT EXISTS idx_nodes_section ON nodes(section);
`
+2 -1
View File
@@ -60,7 +60,8 @@ var migrationFiles = map[int]string{
1: migration001,
2: migration002,
3: migration003,
// 4: migration004, etc.
4: migration004,
// 5: migration005, etc.
}
func (db *DB) runInitialSchema() error {