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:
mirivlad 2026-05-31 01:26:46 +08:00
parent 14ff1a25b9
commit 9ee6df0d3f
11 changed files with 141 additions and 72 deletions

View File

@ -18,7 +18,7 @@ func runNodeCreate(vault, parentID, typ, title string) error {
defer db.Close() defer db.Close()
repo := nodes.NewRepository(db) repo := nodes.NewRepository(db)
n, err := repo.Create(parentID, typ, title) n, err := repo.Create(parentID, typ, title, "")
if err != nil { if err != nil {
return err return err
} }
@ -65,7 +65,7 @@ func runNodeList(vault, parentID string) error {
repo := nodes.NewRepository(db) repo := nodes.NewRepository(db)
var list []nodes.Node var list []nodes.Node
if parentID == "" { if parentID == "" {
list, err = repo.ListRoots(false) list, err = repo.ListRoots(false, "")
} else { } else {
list, err = repo.ListChildren(parentID, false) list, err = repo.ListChildren(parentID, false)
} }

View File

@ -16,7 +16,7 @@
| 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен | | 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен |
| 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен | | 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен |
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен | | 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен | | 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
| 7 | Actions: Run URL/File/Command + GUI Tab | ⬜ не начат | | 7 | Actions: Run URL/File/Command + GUI Tab | ⬜ не начат |
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат | | 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат | | 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |

View File

@ -13,6 +13,7 @@ type Node struct {
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Path *string `json:"path,omitempty"` Path *string `json:"path,omitempty"`
Section string `json:"section,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@ -52,10 +52,15 @@ func now() string {
// --- CRUD --- // --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// Create inserts a root or child node. // Create inserts a root or child node.
// parentID may be empty for root-level nodes. // parentID may be empty for root-level nodes.
// The id, timestamps, revision and slug are generated if not provided. // For root nodes, section determines sidebar placement (may be empty → inbox).
func (r *Repository) Create(parentID string, typ, title string) (*Node, error) { func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) { if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", 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, Type: typ,
Title: title, Title: title,
Slug: Slugify(title), Slug: Slugify(title),
Section: section,
SortOrder: 0, SortOrder: 0,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
@ -89,12 +95,16 @@ func (r *Repository) insertNode(n *Node) error {
if n.ParentID != nil { if n.ParentID != nil {
parent = *n.ParentID parent = *n.ParentID
} }
var sec interface{}
if n.Section != "" {
sec = n.Section
}
_, err := r.db.Exec( _, 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) created_at,updated_at,deleted_at,revision,device_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, parent, n.Type, n.Title, n.Slug, n.Path, 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.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
n.DeletedAt, n.Revision, n.DeviceID, 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). // Get returns a plain node (even if soft-deleted).
func (r *Repository) Get(id string) (*Node, error) { func (r *Repository) Get(id string) (*Node, error) {
row := r.db.QueryRow( 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 created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE id = ?`, id) FROM nodes WHERE id = ?`, id)
return scanNode(row) 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. // ListChildren returns direct children ordered by sort_order, then title.
// IncludeDeleted lists soft-deleted children too. // IncludeDeleted lists soft-deleted children too.
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) { 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 created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE parent_id = ?` FROM nodes WHERE parent_id = ?`
if !includeDeleted { if !includeDeleted {
@ -142,16 +152,29 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
} }
// ListRoots returns nodes with no parent (top-level). // ListRoots returns nodes with no parent (top-level).
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) { // When section is set, only returns roots with that exact section
q := `SELECT id,parent_id,type,title,slug,path,sort_order, // (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 created_at,updated_at,deleted_at,revision,device_id
FROM nodes WHERE parent_id IS NULL` FROM nodes WHERE parent_id IS NULL`
if section == "inbox" {
q += " AND section IS NULL"
} else if section != "" {
q += " AND section = ?"
}
if !includeDeleted { if !includeDeleted {
q += " AND deleted_at IS NULL" q += " AND deleted_at IS NULL"
} }
q += " ORDER BY sort_order, title" 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 { if err != nil {
return nil, err return nil, err
} }
@ -270,11 +293,11 @@ type scanner interface {
func scanNode(s scanner) (*Node, error) { func scanNode(s scanner) (*Node, error) {
var n Node var n Node
var parentID, path, deletedAt, deviceID sql.NullString var parentID, path, section, deletedAt, deviceID sql.NullString
var createdStr, updatedStr string var createdStr, updatedStr string
err := s.Scan( 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.SortOrder, &createdStr, &updatedStr, &deletedAt,
&n.Revision, &deviceID, &n.Revision, &deviceID,
) )
@ -291,6 +314,9 @@ func scanNode(s scanner) (*Node, error) {
if path.Valid { if path.Valid {
n.Path = &path.String n.Path = &path.String
} }
if section.Valid {
n.Section = section.String
}
if deletedAt.Valid { if deletedAt.Valid {
t, _ := time.Parse(time.RFC3339, deletedAt.String) t, _ := time.Parse(time.RFC3339, deletedAt.String)
n.DeletedAt = &t n.DeletedAt = &t

View File

@ -40,7 +40,7 @@ func TestCreateAndGet(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Test Case") n, err := repo.Create("", TypeCase, "Test Case", "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@ -69,12 +69,12 @@ func TestCreateChild(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
parent, err := repo.Create("", TypeFolder, "Folder") parent, err := repo.Create("", TypeFolder, "Folder", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
child, err := repo.Create(parent.ID, TypeCase, "Child") child, err := repo.Create(parent.ID, TypeCase, "Child", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -87,9 +87,9 @@ func TestListChildren(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
parent, _ := repo.Create("", TypeFolder, "Folder") parent, _ := repo.Create("", TypeFolder, "Folder", "")
repo.Create(parent.ID, TypeCase, "A") repo.Create(parent.ID, TypeCase, "A", "")
repo.Create(parent.ID, TypeCase, "B") repo.Create(parent.ID, TypeCase, "B", "")
children, err := repo.ListChildren(parent.ID, false) children, err := repo.ListChildren(parent.ID, false)
if err != nil { if err != nil {
@ -108,10 +108,10 @@ func TestListRoots(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
repo.Create("", TypeCase, "One") repo.Create("", TypeCase, "One", "")
repo.Create("", TypeCase, "Two") repo.Create("", TypeCase, "Two", "")
roots, err := repo.ListRoots(false) roots, err := repo.ListRoots(false, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -124,7 +124,7 @@ func TestUpdateTitle(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "Old") n, _ := repo.Create("", TypeCase, "Old", "")
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil { if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -142,9 +142,9 @@ func TestMove(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
a, _ := repo.Create("", TypeFolder, "A") a, _ := repo.Create("", TypeFolder, "A", "")
b, _ := repo.Create("", TypeFolder, "B") b, _ := repo.Create("", TypeFolder, "B", "")
child, _ := repo.Create(a.ID, TypeCase, "Child") child, _ := repo.Create(a.ID, TypeCase, "Child", "")
// Move child from A to B. // Move child from A to B.
if err := repo.Move(child.ID, b.ID, 0); err != nil { if err := repo.Move(child.ID, b.ID, 0); err != nil {
@ -170,7 +170,7 @@ func TestSoftDelete(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "To Delete") n, _ := repo.Create("", TypeCase, "To Delete", "")
if err := repo.SoftDelete(n.ID); err != nil { if err := repo.SoftDelete(n.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -189,8 +189,8 @@ func TestSoftDelete(t *testing.T) {
} }
// ListChildren without includeDeleted must skip it. // ListChildren without includeDeleted must skip it.
parent, _ := repo.Create("", TypeFolder, "P") parent, _ := repo.Create("", TypeFolder, "P", "")
child, _ := repo.Create(parent.ID, TypeCase, "Kid") child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
repo.SoftDelete(child.ID) repo.SoftDelete(child.ID)
kids, _ := repo.ListChildren(parent.ID, false) kids, _ := repo.ListChildren(parent.ID, false)
@ -208,7 +208,7 @@ func TestMetaKV(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
n, _ := repo.Create("", TypeCase, "M") n, _ := repo.Create("", TypeCase, "M", "")
if err := repo.MetaSet(n.ID, "status", "active"); err != nil { if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -266,7 +266,7 @@ func TestInitEndToEnd(t *testing.T) {
defer db.Close() defer db.Close()
repo := NewRepository(db) repo := NewRepository(db)
n, err := repo.Create("", TypeCase, "Integration Case") n, err := repo.Create("", TypeCase, "Integration Case", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

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. // 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) { func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
node, err := s.nodes.Create(parentID, nodes.TypeNote, title) node, err := s.nodes.Create(parentID, nodes.TypeNote, title, section)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("create node: %w", err) return nil, nil, fmt.Errorf("create node: %w", err)
} }

View File

@ -29,7 +29,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
func TestCreateAndRead(t *testing.T) { func TestCreateAndRead(t *testing.T) {
svc, _, vaultRoot := setupService(t) svc, _, vaultRoot := setupService(t)
node, fileRec, err := svc.Create("", "My Note") node, fileRec, err := svc.Create("", "My Note", "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@ -60,7 +60,7 @@ func TestCreateAndRead(t *testing.T) {
func TestSaveAndBackup(t *testing.T) { func TestSaveAndBackup(t *testing.T) {
svc, _, vaultRoot := setupService(t) svc, _, vaultRoot := setupService(t)
node, _, _ := svc.Create("", "Backup Test") node, _, _ := svc.Create("", "Backup Test", "")
// Save new content. // Save new content.
newContent := "# Updated\n\nThis is the new content." newContent := "# Updated\n\nThis is the new content."
@ -88,7 +88,7 @@ func TestSaveAndBackup(t *testing.T) {
func TestDeleteNote(t *testing.T) { func TestDeleteNote(t *testing.T) {
svc, nodeRepo, _ := setupService(t) svc, nodeRepo, _ := setupService(t)
node, _, _ := svc.Create("", "To Delete") node, _, _ := svc.Create("", "To Delete", "")
if err := svc.Delete(node.ID); err != nil { if err := svc.Delete(node.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -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);
`

View File

@ -60,7 +60,8 @@ var migrationFiles = map[int]string{
1: migration001, 1: migration001,
2: migration002, 2: migration002,
3: migration003, 3: migration003,
// 4: migration004, etc. 4: migration004,
// 5: migration005, etc.
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {

View File

@ -381,10 +381,8 @@ function renderTreeFromCache(activeId){
} }
/* /*
SECTION RENDERERS SECTION RENDERERS each section loads only
Each section shows its OWN content from the API, its own nodes via ?section= filter.
filtered by what belongs here. For now we fetch
all roots and filter client-side by simple heuristics.
*/ */
async function renderSectionContent(section){ async function renderSectionContent(section){
const m=SEC_META[section]||{}; const m=SEC_META[section]||{};
@ -404,7 +402,7 @@ async function renderSectionContent(section){
async function renderSectionToday(title){ async function renderSectionToday(title){
let items=[]; let items=[];
try{items=await api('/api/nodes')}catch(e){} try{items=await api('/api/nodes?section=')}catch(e){}
let h='<div class="dash"><h2>&#128197; '+esc(title)+'</h2>'; let h='<div class="dash"><h2>&#128197; '+esc(title)+'</h2>';
h+='<div class="subtitle">'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'</div>'; h+='<div class="subtitle">'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'</div>';
@ -423,17 +421,14 @@ async function renderSectionToday(title){
async function renderSectionInbox(title){ async function renderSectionInbox(title){
let items=[]; let items=[];
try{items=await api('/api/nodes')}catch(e){} try{items=await api('/api/nodes?section=inbox')}catch(e){}
// Inbox = root-level nodes with no clear section (heuristic: all roots since
// we don't have a parent yet). In v2 this will use a proper inbox flag.
const inbox=items; // all roots = potential inbox
let h='<div class="dash"><h2>&#9776; '+esc(title)+'</h2>'; let h='<div class="dash"><h2>&#9776; '+esc(title)+'</h2>';
h+='<div class="subtitle">Элементы без категории</div>'; h+='<div class="subtitle">Элементы без категории</div>';
if(inbox.length){ if(items.length){
h+='<div class="dash-section"><div class="dash-section-title">Неразобранные дела</div><div class="cg">'; h+='<div class="dash-section"><div class="dash-section-title">Неразобранные дела</div><div class="cg">';
for(const n of inbox){ for(const n of items){
h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>'; h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>';
} }
h+='</div></div>'; h+='</div></div>';
@ -445,14 +440,15 @@ async function renderSectionInbox(title){
async function renderSectionList(title, section){ async function renderSectionList(title, section){
let items=[]; let items=[];
try{items=await api('/api/nodes')}catch(e){} const qs = section==='inbox' ? 'inbox' : section;
try{items=await api('/api/nodes?section='+encodeURIComponent(qs))}catch(e){}
let h='<div class="dash"><h2>'+esc(title)+'</h2>'; let h='<div class="dash"><h2>'+esc(title)+'</h2>';
h+='<div class="subtitle">'+esc(title)+'</div>'; h+='<div class="subtitle">'+esc(title)+'</div>';
h+='<div class="dash-section">'; h+='<div class="dash-section">';
h+='<div class="qa-grid" style="margin-bottom:20px">'; h+='<div class="qa-grid" style="margin-bottom:20px">';
h+='<button class="qa-btn" onclick="doAdd(\'case\')">&#9670; '+esc(title.slice(0,-1))+'</button>'; h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'case\')">&#9670; '+esc(title.slice(0,-1))+'</button>';
h+='<button class="qa-btn" onclick="doAdd(\'note\')">&#9997; Заметка</button>'; h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'note\')">&#9997; Заметка</button>';
h+='</div>'; h+='</div>';
if(items.length){ if(items.length){
@ -465,7 +461,7 @@ async function renderSectionList(title, section){
const m=SEC_META[section]||{}; const m=SEC_META[section]||{};
h+='<div class="empty" style="margin-top:30px"><p>'+esc(m.empty||'Пусто')+'</p>'; h+='<div class="empty" style="margin-top:30px"><p>'+esc(m.empty||'Пусто')+'</p>';
if(m.hint)h+='<p style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(m.hint)+'</p>'; if(m.hint)h+='<p style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(m.hint)+'</p>';
h+='<button class="btn primary" style="margin-top:12px" onclick="doAdd(\''+(m.action||'case')+'\')">+ Создать</button></div>'; h+='<button class="btn primary" style="margin-top:12px" onclick="doAddSection(\''+section+'\',\''+(m.action||'case')+'\')">+ Создать</button></div>';
} }
h+='</div></div>';setCnt(h); h+='</div></div>';setCnt(h);
} }
@ -588,7 +584,6 @@ function doAdd(kind){
else if(kind==='note'){ else if(kind==='note'){
if(sel.kind==='node')openM('m-note'); if(sel.kind==='node')openM('m-note');
else if(sel.kind==='section'&&(sel.section==='today'||sel.section==='inbox'||sel.section==='clients'||sel.section==='projects')){ else if(sel.kind==='section'&&(sel.section==='today'||sel.section==='inbox'||sel.section==='clients'||sel.section==='projects')){
// create note under selected section — unclear target, use modal
openM('m-note'); openM('m-note');
}else{E('Выберите дело слева для заметки');return} }else{E('Выберите дело слева для заметки');return}
} }
@ -596,6 +591,24 @@ function doAdd(kind){
else if(kind==='action')openM('m-action'); else if(kind==='action')openM('m-action');
else if(kind==='worklog')openM('m-worklog'); else if(kind==='worklog')openM('m-worklog');
} }
function doAddSection(section, kind){
closeAddMenu();
// create node directly in the section (no modal)
const title = prompt('Название:');
if(!title||!title.trim())return;
submitSectionNode(section, kind, title.trim());
}
async function submitSectionNode(section, kind, title){
const body = {parent_id:'', type:kind, title};
if(section && section!=='today' && section!=='inbox') body.section=section;
try{
const n = await api('/api/nodes',{method:'POST',body:JSON.stringify(body)});
closeM('m-node');
const items=await api('/api/nodes?section='+encodeURIComponent(section||''));
if(sel.section)renderSectionContent(sel.section); else renderTree(items);
selectNode({dataset:{id:n.id}});
}catch(e){alert('Ошибка: '+e.message)}
}
function showAddMenu(e){ function showAddMenu(e){
e.stopPropagation(); e.stopPropagation();
const m=G('add-menu'); const m=G('add-menu');
@ -610,32 +623,41 @@ document.addEventListener('click',()=>closeAddMenu());
async function submitNode(){ async function submitNode(){
const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim(); const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim();
if(!title)return; if(!title)return;
let parentId=''; let parentId='', section='';
if(parentName){ if(parentName){
// find node by name try{const items=await api('/api/nodes?section=');
try{const items=await api('/api/nodes');
const found=items.find(n=>n.title.toLowerCase().startsWith(parentName.toLowerCase())); const found=items.find(n=>n.title.toLowerCase().startsWith(parentName.toLowerCase()));
if(found)parentId=found.id; if(found)parentId=found.id;
}catch(e){} }catch(e){}
}else if(sel.kind==='node'){parentId=sel.nodeId;} }else if(sel.kind==='node'){parentId=sel.nodeId;}
else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox'){
section=sel.section;
}
try{ try{
const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title})}); const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section})});
closeM('m-node'); closeM('m-node');
// refresh tree const qs = section||'';
const items=await api('/api/nodes');renderTree(items); const items=await api('/api/nodes?section='+encodeURIComponent(qs));
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
selectNode({dataset:{id:n.id}}); selectNode({dataset:{id:n.id}});
}catch(e){alert('Ошибка: '+e.message)} }catch(e){alert('Ошибка: '+e.message)}
} }
async function submitNote(){ async function submitNote(){
const title=G('mn2-title').value.trim(); const title=G('mn2-title').value.trim();
if(!title)return; if(!title)return;
let parentId=''; let parentId='', section='';
if(sel.kind==='node')parentId=sel.nodeId; if(sel.kind==='node')parentId=sel.nodeId;
else if(sel.kind==='section'&&sel.section!=='today'&&sel.section!=='inbox'&&sel.section!=='archive'){/* no node */return E('Выберите дело для заметки')} else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox' && sel.section!=='archive'){
section=sel.section;
}else{return E('Выберите дело для заметки')}
try{ try{
const n=await api('/api/notes/'+(parentId||''),{method:'POST',body:JSON.stringify({parent_id:parentId,title})}); const body = {parent_id:parentId, title};
if(section) body.section=section;
const n=await api('/api/notes/'+(parentId||''),{method:'POST',body:JSON.stringify(body)});
closeM('m-note'); closeM('m-note');
const items=await api('/api/nodes');renderTree(items); const qs = section||'';
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
selectNode({dataset:{id:n.id}}); selectNode({dataset:{id:n.id}});
}catch(e){alert('Ошибка: '+e.message)} }catch(e){alert('Ошибка: '+e.message)}
} }
@ -651,6 +673,11 @@ async function submitWorklog(){
/* /*
SEARCH SEARCH
*/ */
function selectBySearch(id){
G('sr-res').innerHTML='';
const el=document.querySelector('.ti[data-id="'+id+'"]');
if(el)el.click(); else selectNode({dataset:{id}});
}
let sT=null; let sT=null;
async function handleSR(q){ async function handleSR(q){
clearTimeout(sT);const b=G('sr-res'); clearTimeout(sT);const b=G('sr-res');
@ -662,7 +689,7 @@ async function handleSR(q){
if(!hits.length){b.innerHTML='';return} if(!hits.length){b.innerHTML='';return}
let h=''; let h='';
for(const r of hits){ for(const r of hits){
h+='<div class="sri" data-id="'+r.id+'" onclick="selectNode(this);b.innerHTML=\'\'"><span class="srt">'+TL[r.type]+'</span><span class="sr-title">'+esc(r.title)+'</span></div>'; h+='<div class="sri" data-id="'+r.id+'" onclick="selectBySearch(\''+r.id+'\')"><span class="srt">'+TL[r.type]+'</span><span class="sr-title">'+esc(r.title)+'</span></div>';
} }
b.innerHTML=h; b.innerHTML=h;
}catch(e){b.innerHTML=''} }catch(e){b.innerHTML=''}

View File

@ -99,15 +99,16 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
json.NewEncoder(w).Encode(map[string]string{"error": msg}) json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
// GET /api/nodes[?parent=ID] POST /api/nodes // GET /api/nodes[?parent=ID&section=X] POST /api/nodes
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case "GET": case "GET":
parent := r.URL.Query().Get("parent") parent := r.URL.Query().Get("parent")
section := r.URL.Query().Get("section")
var list interface{} var list interface{}
var err error var err error
if parent == "" { if parent == "" {
list, err = s.nodes.ListRoots(false) list, err = s.nodes.ListRoots(false, section)
} else { } else {
list, err = s.nodes.ListChildren(parent, false) list, err = s.nodes.ListChildren(parent, false)
} }
@ -121,12 +122,13 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
ParentID string `json:"parent_id"` ParentID string `json:"parent_id"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Section string `json:"section"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json") jsonErr(w, 400, "bad json")
return return
} }
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title) n, err := s.nodes.Create(req.ParentID, req.Type, req.Title, req.Section)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@ -188,9 +190,12 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
content, _ := s.notes.Read(path) content, _ := s.notes.Read(path)
jsonOK(w, map[string]interface{}{"record": rec, "content": content}) jsonOK(w, map[string]interface{}{"record": rec, "content": content})
case "POST": case "POST":
var req struct{ Title string `json:"title"` } var req struct {
Title string `json:"title"`
Section string `json:"section"`
}
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
n, _, err := s.notes.Create(path, req.Title) n, _, err := s.notes.Create(path, req.Title, req.Section)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@ -238,7 +243,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
jsonOK(w, []interface{}{}) jsonOK(w, []interface{}{})
return return
} }
roots, _ := s.nodes.ListRoots(false) roots, _ := s.nodes.ListRoots(false, "")
var hits []map[string]interface{} var hits []map[string]interface{}
for _, n := range roots { for _, n := range roots {
if strings.Contains(strings.ToLower(n.Title), q) { if strings.Contains(strings.ToLower(n.Title), q) {