diff --git a/cmd/verstak/node_cmd.go b/cmd/verstak/node_cmd.go index 1af4fc2..76d23c9 100644 --- a/cmd/verstak/node_cmd.go +++ b/cmd/verstak/node_cmd.go @@ -18,7 +18,7 @@ func runNodeCreate(vault, parentID, typ, title string) error { defer db.Close() repo := nodes.NewRepository(db) - n, err := repo.Create(parentID, typ, title) + n, err := repo.Create(parentID, typ, title, "") if err != nil { return err } @@ -65,7 +65,7 @@ func runNodeList(vault, parentID string) error { repo := nodes.NewRepository(db) var list []nodes.Node if parentID == "" { - list, err = repo.ListRoots(false) + list, err = repo.ListRoots(false, "") } else { list, err = repo.ListChildren(parentID, false) } diff --git a/docs/PLAN.md b/docs/PLAN.md index c2b9147..82df0b4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,7 +16,7 @@ | 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен | | 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен | | 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 | ⬜ не начат | | 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат | | 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат | diff --git a/internal/core/nodes/node.go b/internal/core/nodes/node.go index 33eb289..ec89871 100644 --- a/internal/core/nodes/node.go +++ b/internal/core/nodes/node.go @@ -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"` diff --git a/internal/core/nodes/repository.go b/internal/core/nodes/repository.go index 4f75f15..de3f56c 100644 --- a/internal/core/nodes/repository.go +++ b/internal/core/nodes/repository.go @@ -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, §ion, &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 diff --git a/internal/core/nodes/repository_test.go b/internal/core/nodes/repository_test.go index c88e5d1..938a745 100644 --- a/internal/core/nodes/repository_test.go +++ b/internal/core/nodes/repository_test.go @@ -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) } diff --git a/internal/core/notes/note.go b/internal/core/notes/note.go index d9efcf4..9e4d3fc 100644 --- a/internal/core/notes/note.go +++ b/internal/core/notes/note.go @@ -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) } diff --git a/internal/core/notes/note_test.go b/internal/core/notes/note_test.go index a8246da..a1612bf 100644 --- a/internal/core/notes/note_test.go +++ b/internal/core/notes/note_test.go @@ -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) } diff --git a/internal/core/storage/migrations_004.sql.go b/internal/core/storage/migrations_004.sql.go new file mode 100644 index 0000000..6a237c5 --- /dev/null +++ b/internal/core/storage/migrations_004.sql.go @@ -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); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 46b9610..7d5c822 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -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 { diff --git a/internal/gui/index.html.go b/internal/gui/index.html.go index 11afd68..d782a2d 100644 --- a/internal/gui/index.html.go +++ b/internal/gui/index.html.go @@ -381,10 +381,8 @@ function renderTreeFromCache(activeId){ } /* ════════════════════════════════════════════ - SECTION RENDERERS - Each section shows its OWN content from the API, - filtered by what belongs here. For now we fetch - all roots and filter client-side by simple heuristics. + SECTION RENDERERS — each section loads only + its own nodes via ?section= filter. ════════════════════════════════════════════ */ async function renderSectionContent(section){ const m=SEC_META[section]||{}; @@ -404,7 +402,7 @@ async function renderSectionContent(section){ async function renderSectionToday(title){ let items=[]; - try{items=await api('/api/nodes')}catch(e){} + try{items=await api('/api/nodes?section=')}catch(e){} let h='

📅 '+esc(title)+'

'; h+='
'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'
'; @@ -423,17 +421,14 @@ async function renderSectionToday(title){ async function renderSectionInbox(title){ let items=[]; - try{items=await api('/api/nodes')}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 + try{items=await api('/api/nodes?section=inbox')}catch(e){} let h='

☰ '+esc(title)+'

'; h+='
Элементы без категории
'; - if(inbox.length){ + if(items.length){ h+='
Неразобранные дела
'; - for(const n of inbox){ + for(const n of items){ h+='
'+esc(n.title)+'
'+TL[n.type]+'
'; } h+='
'; @@ -445,14 +440,15 @@ async function renderSectionInbox(title){ async function renderSectionList(title, section){ 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='

'+esc(title)+'

'; h+='
'+esc(title)+'
'; h+='
'; h+='
'; - h+=''; - h+=''; + h+=''; + h+=''; h+='
'; if(items.length){ @@ -465,7 +461,7 @@ async function renderSectionList(title, section){ const m=SEC_META[section]||{}; h+='

'+esc(m.empty||'Пусто')+'

'; if(m.hint)h+='

'+esc(m.hint)+'

'; - h+='
'; + h+='
'; } h+='
';setCnt(h); } @@ -588,7 +584,6 @@ function doAdd(kind){ else if(kind==='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')){ - // create note under selected section — unclear target, use modal openM('m-note'); }else{E('Выберите дело слева для заметки');return} } @@ -596,6 +591,24 @@ function doAdd(kind){ else if(kind==='action')openM('m-action'); 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){ e.stopPropagation(); const m=G('add-menu'); @@ -610,32 +623,41 @@ document.addEventListener('click',()=>closeAddMenu()); async function submitNode(){ const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim(); if(!title)return; - let parentId=''; + let parentId='', section=''; if(parentName){ - // find node by name - try{const items=await api('/api/nodes'); + try{const items=await api('/api/nodes?section='); const found=items.find(n=>n.title.toLowerCase().startsWith(parentName.toLowerCase())); if(found)parentId=found.id; }catch(e){} }else if(sel.kind==='node'){parentId=sel.nodeId;} + else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox'){ + section=sel.section; + } 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'); - // refresh tree - 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}}); }catch(e){alert('Ошибка: '+e.message)} } async function submitNote(){ const title=G('mn2-title').value.trim(); if(!title)return; - let parentId=''; + let parentId='', section=''; 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{ - 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'); - 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}}); }catch(e){alert('Ошибка: '+e.message)} } @@ -651,6 +673,11 @@ async function submitWorklog(){ /* ════════════════════════════════════════════ 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; async function handleSR(q){ clearTimeout(sT);const b=G('sr-res'); @@ -662,7 +689,7 @@ async function handleSR(q){ if(!hits.length){b.innerHTML='';return} let h=''; for(const r of hits){ - h+='
'+TL[r.type]+''+esc(r.title)+'
'; + h+='
'+TL[r.type]+''+esc(r.title)+'
'; } b.innerHTML=h; }catch(e){b.innerHTML=''} diff --git a/internal/gui/server.go b/internal/gui/server.go index a0acbdc..a014a23 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -99,15 +99,16 @@ func jsonErr(w http.ResponseWriter, code int, msg string) { json.NewEncoder(w).Encode(map[string]string{"error": msg}) } -// GET /api/nodes[?parent=ID] POST /api/nodes +// GET /api/nodes[?parent=ID§ion=X] POST /api/nodes func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": parent := r.URL.Query().Get("parent") + section := r.URL.Query().Get("section") var list interface{} var err error if parent == "" { - list, err = s.nodes.ListRoots(false) + list, err = s.nodes.ListRoots(false, section) } else { 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"` Type string `json:"type"` Title string `json:"title"` + Section string `json:"section"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "bad json") 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 { jsonErr(w, 500, err.Error()) return @@ -188,9 +190,12 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) { content, _ := s.notes.Read(path) jsonOK(w, map[string]interface{}{"record": rec, "content": content}) 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) - n, _, err := s.notes.Create(path, req.Title) + n, _, err := s.notes.Create(path, req.Title, req.Section) if err != nil { jsonErr(w, 500, err.Error()) return @@ -238,7 +243,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { jsonOK(w, []interface{}{}) return } - roots, _ := s.nodes.ListRoots(false) + roots, _ := s.nodes.ListRoots(false, "") var hits []map[string]interface{} for _, n := range roots { if strings.Contains(strings.ToLower(n.Title), q) {