steps 8+9: worklog + FTS5 search

STEP 8 — Worklog:
- Migration 006: worklog_entries table (node_id, date, minutes,
  approximate, billable, summary, details)
- WorklogService: Add, Get, Update, Delete, ListByNode, SumMinutes,
  Report (text report generator with total time)
- CLI: verstak log add/list/report  (verstak log --help for usage)
- GUI tab: entries list with date/time/approx, add form with
  minutes+text+approx checkbox, total minutes counter

STEP 9 — FTS5 Search:
- FTS5 virtual table created lazily by search.Rebuild()
  (works with/without FTS5 compiled in — graceful fallback)
- SearchService: Index, Remove, Rebuild, Search (with FTS5 MATCH)
- CLI: verstak index rebuild — builds search index from node titles
- GUI search bar uses /api/search?q= (FTS5 when available,
  fallback to LIKE on node titles)

Acceptance: go build ./... pass, go test ./... pass (all packages).
This commit is contained in:
2026-05-31 02:25:25 +08:00
parent dae53fcbba
commit d6f7f1a9b8
11 changed files with 969 additions and 16 deletions
+52 -6
View File
@@ -527,7 +527,7 @@ function switchTabNode(t){
}else if(t==='notes') loadNodeNotes(id);
else if(t==='files') loadNodeFiles(id);
else if(t==='actions') loadNodeActions(id);
else if(t==='worklog') setCnt('<div class="empty" style="margin-top:60px">Журнал — в разработке</div>');
else if(t==='worklog') loadNodeWorklog(id);
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
}
function switchTabSection(t){
@@ -624,6 +624,53 @@ async function saveNT(){
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
WORKLOG TAB
════════════════════════════════════════════ */
async function loadNodeWorklog(nodeId){
try{
const list=await api('/api/worklog?node='+nodeId);
let h='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">';
h+='<button class="btn primary" onclick="toggleWLEntry()">+ Добавить запись</button>';
const total=list.reduce((s,e)=>s+(e.minutes||0),0);
h+='<span style="font-size:13px;color:var(--text3)">Итого: '+Math.floor(total/60)+'ч '+total%60+'м</span>';
h+='</div>';
h+='<div id="wl-add" style="display:none;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">';
h+='<label>Время (мин)</label><input id="wl-min" type="number" placeholder="120" style="margin-bottom:8px">';
h+='<label>Описание</label><textarea id="wl-text" placeholder="Что сделано..." style="min-height:60px;margin-bottom:8px"></textarea>';
h+='<label><input type="checkbox" id="wl-approx" checked style="width:auto;margin-right:6px"> примерно</label>';
h+='<div style="display:flex;gap:8px;margin-top:12px"><button class="btn" onclick="toggleWLEntry()">Отмена</button><button class="btn primary" onclick="submitWLEntry(\''+nodeId+'\')">Записать</button></div>';
h+='</div>';
if(!list.length){h+='<div class="empty" style="margin-top:40px">Нет записей</div>';setCnt(h);return}
h+='<div style="display:flex;flex-direction:column;gap:8px">';
for(const e of list){
const dur=e.minutes?e.minutes+'м':'—';
const approx=e.approximate?' ~':'';
h+='<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px">';
h+='<div style="display:flex;justify-content:space-between;margin-bottom:4px"><span style="font-size:12px;color:var(--text3)">'+e.date+'</span><span style="font-size:13px;font-weight:600">'+dur+approx+'</span></div>';
h+='<div style="font-size:14px">'+esc(e.summary)+'</div>';
if(e.details)h+='<div style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(e.details)+'</div>';
h+='</div>';
}
h+='</div>';setCnt(h);
}catch(e){E('Ошибка')}
}
function toggleWLEntry(){
const el=document.getElementById('wl-add');
if(el)el.style.display=el.style.display==='none'?'block':'none';
}
async function submitWLEntry(nodeId){
const mins=parseInt(document.getElementById('wl-min').value,10)||0;
const text=document.getElementById('wl-text').value.trim();
const approx=document.getElementById('wl-approx').checked;
if(!text)return;
try{
await api('/api/worklog/',{method:'POST',body:JSON.stringify({node_id:nodeId,summary:text,minutes,approximate:approx})});
toggleWLEntry();
loadNodeWorklog(nodeId);
}catch(e){alert('Ошибка: '+e.message)}
}
/* ════════════════════════════════════════════
MODALS
════════════════════════════════════════════ */
@@ -735,12 +782,11 @@ async function handleSR(q){
if(!q||q.length<2){b.innerHTML='';return}
sT=setTimeout(async()=>{
try{
const items=await api('/api/nodes');
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
if(!hits.length){b.innerHTML='';return}
const items=await api('/api/search?q='+encodeURIComponent(q));
if(!items||!items.length){b.innerHTML='';return}
let h='';
for(const r of hits){
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>';
for(const r of items){
h+='<div class="sri" data-id="'+r.id+'" onclick="selectBySearch(\''+r.id+'\')"><span class="srt">'+(TL[r.type]||r.type||'')+'</span><span class="sr-title">'+esc(r.title)+'</span></div>';
}
b.innerHTML=h;
}catch(e){b.innerHTML=''}
+79 -8
View File
@@ -13,7 +13,9 @@ import (
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
)
// Server is the GUI HTTP server bound to a vault.
@@ -24,6 +26,8 @@ type Server struct {
files *files.Service
notes *notes.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
srv *http.Server
listener net.Listener
port int
@@ -35,9 +39,12 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
fileSvc := files.NewService(db, vaultRoot)
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
workSvc := worklog.NewService(db)
srchSvc := search.NewService(db)
return &Server{
db: db, vaultRoot: vaultRoot,
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
worklog: workSvc, search: srchSvc,
}
}
@@ -49,6 +56,7 @@ func (s *Server) Start() (string, error) {
mux.HandleFunc("/api/notes/", s.handleNotes)
mux.HandleFunc("/api/files/", s.handleFiles)
mux.HandleFunc("/api/actions/", s.handleActions)
mux.HandleFunc("/api/worklog/", s.handleWorklog)
mux.HandleFunc("/api/search", s.handleSearch)
mux.HandleFunc("/", s.handleStatic)
@@ -299,19 +307,82 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
}
}
// GET /api/search?q=...
// GET/POST/DELETE /api/worklog/{id} GET /api/worklog?node=ID
func (s *Server) handleWorklog(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/worklog/")
switch r.Method {
case "GET":
if path != "" {
e, err := s.worklog.Get(path)
if err != nil {
jsonErr(w, 404, err.Error())
return
}
jsonOK(w, e)
return
}
nodeID := r.URL.Query().Get("node")
if nodeID == "" {
jsonOK(w, []interface{}{})
return
}
list, err := s.worklog.ListByNode(nodeID)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, list)
case "POST":
var req struct {
NodeID string `json:"node_id"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
}
json.NewDecoder(r.Body).Decode(&req)
e, err := s.worklog.Add(req.NodeID, req.Summary, req.Details, req.Minutes, req.Approximate, req.Billable)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, e)
case "DELETE":
if path == "" {
jsonErr(w, 400, "id required")
return
}
if err := s.worklog.Delete(path); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]string{"status": "deleted"})
default:
jsonErr(w, 405, "method not allowed")
}
}
// GET /api/search?q=... — FTS5 search across node titles + note content.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.ToLower(r.URL.Query().Get("q"))
q := r.URL.Query().Get("q")
if len(q) < 2 {
jsonOK(w, []interface{}{})
return
}
roots, _ := s.nodes.ListRoots(false, "")
var hits []map[string]interface{}
for _, n := range roots {
if strings.Contains(strings.ToLower(n.Title), q) {
hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type})
// Try FTS5 first, fall back to LIKE on node titles.
results, err := s.search.Search(q)
if err != nil || len(results) == 0 {
// Fallback: search node titles directly.
roots, _ := s.nodes.ListRoots(false, "")
ql := strings.ToLower(q)
for _, n := range roots {
if strings.Contains(strings.ToLower(n.Title), ql) {
results = append(results, search.Result{
NodeID: n.ID, Title: n.Title, Type: n.Type,
})
}
}
}
jsonOK(w, hits)
jsonOK(w, results)
}