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
+198
View File
@@ -8,8 +8,10 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
const version = "0.1.0-dev"
@@ -31,6 +33,10 @@ func main() {
runNode(os.Args[2:])
case "action":
runAction(os.Args[2:])
case "log":
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
os.Exit(1)
@@ -395,3 +401,195 @@ func runActionDelete(args []string) {
}
fmt.Println("deleted")
}
// --- log ---
func runLog(args []string) {
if len(args) == 0 {
fmt.Println("verstak log — manage worklog entries")
fmt.Println()
fmt.Println("Usage: verstak log <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" add --node ID --time MIN --text TEXT Add worklog entry")
fmt.Println(" list --node ID List entries for node")
fmt.Println(" report --node ID Copy report to stdout")
os.Exit(1)
}
switch args[0] {
case "add":
runLogAdd(args[1:])
case "list":
runLogList(args[1:])
case "report":
runLogReport(args[1:])
case "--help", "-h":
runLog(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown log command: %s\n", args[0])
os.Exit(1)
}
}
func runLogAdd(args []string) {
nodeID, _ := stringFlag(args, "--node")
minutesStr, _ := stringFlag(args, "--time")
text, _ := stringFlag(args, "--text")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" || text == "" {
fmt.Fprintln(os.Stderr, "Error: --node and --text required")
os.Exit(1)
}
minutes := 0
if minutesStr != "" {
fmt.Sscanf(minutesStr, "%d", &minutes)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
e, err := svc.Add(nodeID, text, "", minutes, true, false)
if err != nil {
fmt.Fprintf(os.Stderr, "Add failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("logged\t%s\t%dm\t%s\n", e.ID, minutes, e.Summary)
}
func runLogList(args []string) {
nodeID, _ := stringFlag(args, "--node")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" {
fmt.Fprintln(os.Stderr, "Error: --node required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
entries, err := svc.ListByNode(nodeID)
if err != nil {
fmt.Fprintf(os.Stderr, "List failed: %v\n", err)
os.Exit(1)
}
for _, e := range entries {
dur := "—"
if e.Minutes != nil {
dur = fmt.Sprintf("%dm", *e.Minutes)
}
approx := ""
if e.Approximate {
approx = " ~"
}
fmt.Printf("%s\t%s%s\t%s\n", e.Date, dur, approx, e.Summary)
}
}
func runLogReport(args []string) {
nodeID, _ := stringFlag(args, "--node")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" {
fmt.Fprintln(os.Stderr, "Error: --node required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
report, err := svc.Report(nodeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Report failed: %v\n", err)
os.Exit(1)
}
fmt.Print(report)
}
// --- index ---
func runIndex(args []string) {
if len(args) == 0 {
fmt.Println("verstak index — manage search index")
fmt.Println()
fmt.Println("Usage: verstak index <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" rebuild Rebuild FTS5 search index")
os.Exit(1)
}
switch args[0] {
case "rebuild":
runIndexRebuild(args[1:])
case "--help", "-h":
runIndex(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown index command: %s\n", args[0])
os.Exit(1)
}
}
func runIndexRebuild(args []string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := search.NewService(db)
if err := svc.Rebuild(); err != nil {
fmt.Fprintf(os.Stderr, "Rebuild failed: %v\n", err)
os.Exit(1)
}
// Index all node titles.
rows, err := db.Query(
`SELECT id, title, type, section FROM nodes WHERE deleted_at IS NULL`)
if err != nil {
fmt.Fprintf(os.Stderr, "Query nodes: %v\n", err)
os.Exit(1)
}
defer rows.Close()
count := 0
for rows.Next() {
var id, title, nodeType, section string
if err := rows.Scan(&id, &title, &nodeType, &section); err != nil {
continue
}
tags := section
if tags != "" {
tags = "section:" + tags
}
svc.Index(id, title, "", "", tags, nodeType)
count++
}
fmt.Printf("indexed %d nodes\n", count)
}