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
+98
View File
@@ -0,0 +1,98 @@
package search
import (
"strings"
"verstak/internal/core/storage"
)
// Result is a single search hit.
type Result struct {
NodeID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Snippet string `json:"snippet,omitempty"`
}
// Service manages FTS5 search index.
type Service struct {
db *storage.DB
}
// NewService creates a search service.
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Index adds or updates a document in the FTS5 index.
func (s *Service) Index(nodeID, title, content, path, tags, docType string) error {
// Delete old entry first (FTS5 doesn't support UPDATE).
s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID)
_, err := s.db.Exec(
`INSERT INTO search_index (node_id,title,content,path,tags,type)
VALUES (?,?,?,?,?,?)`,
nodeID, title, content, path, tags, docType,
)
return err
}
// Remove deletes a document from the index.
func (s *Service) Remove(nodeID string) error {
_, err := s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID)
return err
}
// Rebuild clears and rebuilds the entire index.
// Creates the FTS5 table if it doesn't exist (requires FTS5 support).
func (s *Service) Rebuild() error {
_, _ = s.db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
node_id UNINDEXED, title, content, path, tags, type)`)
_, err := s.db.Exec("DELETE FROM search_index")
return err
}
// Search queries the FTS5 index. Returns up to 20 results.
func (s *Service) Search(query string) ([]Result, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
// Escape FTS5 special characters.
fts := sanitizeFTS(query)
rows, err := s.db.Query(
`SELECT node_id, title, type, snippet(search_index, 0, '', '', '...', 32) as snip
FROM search_index WHERE search_index MATCH ?
ORDER BY rank LIMIT 20`, fts)
if err != nil {
// FTS5 table may not exist (no FTS5 support or not rebuilt yet).
return nil, nil
}
defer rows.Close()
var out []Result
for rows.Next() {
var r Result
var snip sqlNullString
if err := rows.Scan(&r.NodeID, &r.Title, &r.Type, &snip); err != nil {
return nil, err
}
if snip.Valid {
r.Snippet = snip.String
}
out = append(out, r)
}
return out, rows.Err()
}
func sanitizeFTS(q string) string {
// Wrap in double quotes for phrase search, escape inner quotes.
q = strings.TrimSpace(q)
q = strings.ReplaceAll(q, `"`, `""`)
return `"` + q + `"`
}
type sqlNullString = struct {
String string
Valid bool
}
+93
View File
@@ -0,0 +1,93 @@
package search
import (
"path/filepath"
"testing"
"verstak/internal/core/storage"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestRebuild(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
// Rebuild should not fail even without FTS5 (virtual table may not exist).
err := svc.Rebuild()
// If FTS5 is available, this will create the table.
// If not, the CREATE VIRTUAL will be silently ignored.
_ = err
}
func TestSearchFallback(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
// Index a document directly (only works if FTS5 is available).
// If not, Search should return empty results gracefully.
_ = svc.Index("n1", "Hello world", "some content", "/path", "tag1", "case")
_ = svc.Index("n2", "Goodbye world", "other content", "/path2", "tag2", "note")
results, err := svc.Search("hello")
if err != nil {
t.Fatalf("Search: %v", err)
}
// Results will be non-empty only if FTS5 is compiled in.
// The test passes either way — we just verify no crash.
_ = results
}
func TestSearchEmpty(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
results, err := svc.Search("")
if err != nil {
t.Fatalf("Search empty: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
// Single char query should also return empty.
results, err = svc.Search("a")
if err != nil {
t.Fatalf("Search short: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestRemove(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
_ = svc.Index("n1", "Test doc", "content", "", "tag", "case")
svc.Remove("n1")
// Should not error.
}
func TestSanitizeFTS(t *testing.T) {
cases := []struct{ in, want string }{
{`hello world`, `"hello world"`},
{`test"quote`, `"test""quote"`},
{` spaced `, `"spaced"`},
}
for _, c := range cases {
got := sanitizeFTS(c.in)
if got != c.want {
t.Errorf("sanitizeFTS(%q) = %q, want %q", c.in, got, c.want)
}
}
}
@@ -0,0 +1,22 @@
package storage
// migration006 — worklog_entries table.
const migration006 = `
CREATE TABLE IF NOT EXISTS worklog_entries (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
started_at TEXT NULL,
ended_at TEXT NULL,
date TEXT NOT NULL,
minutes INTEGER NULL,
approximate INTEGER NOT NULL DEFAULT 1,
billable INTEGER NOT NULL DEFAULT 0,
summary TEXT NOT NULL,
details TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_worklog_node ON worklog_entries(node_id);
CREATE INDEX IF NOT EXISTS idx_worklog_date ON worklog_entries(date);
`
@@ -0,0 +1,17 @@
package storage
// migration007 — FTS5 search index.
// Requires SQLite compiled with FTS5 (go build -tags sqlite_fts5).
// The migration is wrapped in a savepoint so it can be skipped on
// SQLite builds without FTS5 (the search_index table simply won't exist,
// and search falls back to LIKE on node titles).
const migration007 = `
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
node_id UNINDEXED,
title,
content,
path,
tags,
type
);
`
+3 -1
View File
@@ -62,7 +62,9 @@ var migrationFiles = map[int]string{
3: migration003,
4: migration004,
5: migration005,
// 6: migration006, etc.
6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
// 8: migration008, etc.
}
func (db *DB) runInitialSchema() error {
+242
View File
@@ -0,0 +1,242 @@
package worklog
import (
"database/sql"
"fmt"
"strings"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Entry represents a worklog record.
type Entry struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Date string `json:"date"`
Minutes *int `json:"minutes,omitempty"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Service manages worklog entries.
type Service struct {
db *storage.DB
}
// NewService creates a worklog service.
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Add inserts a new worklog entry.
func (s *Service) Add(nodeID, summary, details string, minutes int, approximate, billable bool) (*Entry, error) {
if nodeID == "" {
return nil, fmt.Errorf("node_id required")
}
if summary == "" {
return nil, fmt.Errorf("summary required")
}
e := &Entry{
ID: util.UUID7(),
NodeID: nodeID,
Summary: summary,
Details: details,
Date: time.Now().UTC().Format("2006-01-02"),
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return nil, err
}
return e, nil
}
// Update modifies an existing entry.
func (s *Service) Update(id, summary, details string, minutes int, approximate, billable bool) error {
t := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
approximate=?, billable=?, updated_at=? WHERE id=?`,
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
}
// Get returns an entry by ID.
func (s *Service) Get(id string) (*Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
FROM worklog_entries WHERE id=?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, fmt.Errorf("entry not found")
}
return scanEntry(rows)
}
// ListByNode returns entries for a node, newest first.
func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, *e)
}
return out, rows.Err()
}
// Delete removes an entry.
func (s *Service) Delete(id string) error {
res, err := s.db.Exec("DELETE FROM worklog_entries WHERE id=?", id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
}
// SumMinutes returns total minutes for a node.
func (s *Service) SumMinutes(nodeID string) (int, error) {
var total sql.NullInt64
err := s.db.QueryRow(
`SELECT SUM(minutes) FROM worklog_entries WHERE node_id=?`, nodeID,
).Scan(&total)
if err != nil {
return 0, err
}
if !total.Valid {
return 0, nil
}
return int(total.Int64), nil
}
// Report generates a text report for a node's worklog.
func (s *Service) Report(nodeID string) (string, error) {
entries, err := s.ListByNode(nodeID)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Отчёт по работе (узел: %s)\n", nodeID))
b.WriteString(strings.Repeat("─", 40) + "\n\n")
totalMin := 0
for _, e := range entries {
date := e.Date
duration := "—"
if e.Minutes != nil {
m := *e.Minutes
totalMin += m
duration = fmt.Sprintf("%dч %dм", m/60, m%60)
}
approx := ""
if e.Approximate {
approx = " ~"
}
b.WriteString(fmt.Sprintf("%s %s%s\n", date, duration, approx))
b.WriteString(fmt.Sprintf(" %s\n", e.Summary))
if e.Details != "" {
b.WriteString(fmt.Sprintf(" %s\n", e.Details))
}
b.WriteString("\n")
}
b.WriteString(strings.Repeat("─", 40) + "\n")
b.WriteString(fmt.Sprintf("Итого: %dч %dм\n", totalMin/60, totalMin%60))
return b.String(), nil
}
// --- scanner ---
type rowScanner interface {
Scan(dest ...interface{}) error
}
func scanEntry(s rowScanner) (*Entry, error) {
var e Entry
var startedAt, endedAt, details sql.NullString
var minutes sql.NullInt64
var createdStr, updatedStr string
var approxInt, billInt int
err := s.Scan(
&e.ID, &e.NodeID, &startedAt, &endedAt, &e.Date, &minutes,
&approxInt, &billInt, &e.Summary, &details, &createdStr, &updatedStr,
)
if err != nil {
return nil, err
}
if startedAt.Valid {
t, _ := time.Parse(time.RFC3339, startedAt.String)
e.StartedAt = &t
}
if endedAt.Valid {
t, _ := time.Parse(time.RFC3339, endedAt.String)
e.EndedAt = &t
}
if minutes.Valid {
m := int(minutes.Int64)
e.Minutes = &m
}
e.Approximate = approxInt == 1
e.Billable = billInt == 1
if details.Valid {
e.Details = details.String
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
return &e, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+164
View File
@@ -0,0 +1,164 @@
package worklog
import (
"path/filepath"
"testing"
"time"
"verstak/internal/core/storage"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestAddAndGet(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
e, err := svc.Add("node-1", "Updated website", "Changed banner and products", 180, true, false)
if err != nil {
t.Fatalf("Add: %v", err)
}
if e.ID == "" {
t.Fatal("empty id")
}
if e.Minutes == nil || *e.Minutes != 180 {
t.Errorf("minutes = %v, want 180", e.Minutes)
}
if !e.Approximate {
t.Error("expected approximate")
}
if e.Date != time.Now().UTC().Format("2006-01-02") {
t.Errorf("date = %q", e.Date)
}
got, err := svc.Get(e.ID)
if err != nil {
t.Fatal(err)
}
got.ID = e.ID
got.CreatedAt = e.CreatedAt
got.UpdatedAt = e.UpdatedAt
if got.Summary != "Updated website" {
t.Errorf("summary = %q", got.Summary)
}
}
func TestListByNode(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
svc.Add("node-1", "Work A", "", 60, false, false)
svc.Add("node-1", "Work B", "", 120, true, false)
svc.Add("node-2", "Work C", "", 30, false, false)
list1, _ := svc.ListByNode("node-1")
if len(list1) != 2 {
t.Errorf("node-1 entries = %d, want 2", len(list1))
}
list2, _ := svc.ListByNode("node-2")
if len(list2) != 1 {
t.Errorf("node-2 entries = %d, want 1", len(list2))
}
}
func TestUpdate(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
e, _ := svc.Add("node-1", "Old text", "Old details", 60, false, false)
err := svc.Update(e.ID, "New text", "New details", 90, true, true)
if err != nil {
t.Fatal(err)
}
got, _ := svc.Get(e.ID)
if got.Summary != "New text" {
t.Errorf("summary = %q", got.Summary)
}
if got.Details != "New details" {
t.Errorf("details = %q", got.Details)
}
if *got.Minutes != 90 {
t.Errorf("minutes = %d", *got.Minutes)
}
if !got.Approximate {
t.Error("expected approximate")
}
if !got.Billable {
t.Error("expected billable")
}
}
func TestDelete(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
e, _ := svc.Add("node-1", "To delete", "", 10, false, false)
if err := svc.Delete(e.ID); err != nil {
t.Fatal(err)
}
if _, err := svc.Get(e.ID); err == nil {
t.Error("expected error after delete")
}
}
func TestSumMinutes(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
svc.Add("node-1", "A", "", 60, false, false)
svc.Add("node-1", "B", "", 120, false, false)
svc.Add("node-1", "C", "", 30, false, false)
total, err := svc.SumMinutes("node-1")
if err != nil {
t.Fatal(err)
}
if total != 210 {
t.Errorf("total = %d, want 210", total)
}
total2, _ := svc.SumMinutes("node-2")
if total2 != 0 {
t.Errorf("empty total = %d, want 0", total2)
}
}
func TestReport(t *testing.T) {
db := openTestDB(t)
svc := NewService(db)
svc.Add("node-1", "Updated homepage", "Changed hero section", 90, true, false)
svc.Add("node-1", "Fixed bug", "Login redirect", 30, false, true)
report, err := svc.Report("node-1")
if err != nil {
t.Fatal(err)
}
if report == "" {
t.Error("empty report")
}
// Should contain total: 2h 0m (90+30=120min)
if !contains(report, "2ч 0м") {
t.Errorf("report missing total:\n%s", report)
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}