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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user