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:
parent
dae53fcbba
commit
d6f7f1a9b8
|
|
@ -8,8 +8,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.1.0-dev"
|
const version = "0.1.0-dev"
|
||||||
|
|
@ -31,6 +33,10 @@ func main() {
|
||||||
runNode(os.Args[2:])
|
runNode(os.Args[2:])
|
||||||
case "action":
|
case "action":
|
||||||
runAction(os.Args[2:])
|
runAction(os.Args[2:])
|
||||||
|
case "log":
|
||||||
|
runLog(os.Args[2:])
|
||||||
|
case "index":
|
||||||
|
runIndex(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -395,3 +401,195 @@ func runActionDelete(args []string) {
|
||||||
}
|
}
|
||||||
fmt.Println("deleted")
|
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, §ion); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tags := section
|
||||||
|
if tags != "" {
|
||||||
|
tags = "section:" + tags
|
||||||
|
}
|
||||||
|
svc.Index(id, title, "", "", tags, nodeType)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("indexed %d nodes\n", count)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
| 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен |
|
| 4 | Vault Files: Trash + File Service + CLI File | ✅ выполнен |
|
||||||
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
|
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
|
||||||
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
|
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
|
||||||
| 7 | Actions: Run URL/File/Command + GUI Tab | ⬜ не начат |
|
| 7 | Actions: Run URL/File/Command + GUI Tab | ✅ выполнен |
|
||||||
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
|
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
|
||||||
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |
|
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |
|
||||||
| 10 | DokuWiki Importer | ⬜ не начат |
|
| 10 | DokuWiki Importer | ⬜ не начат |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
@ -62,7 +62,9 @@ var migrationFiles = map[int]string{
|
||||||
3: migration003,
|
3: migration003,
|
||||||
4: migration004,
|
4: migration004,
|
||||||
5: migration005,
|
5: migration005,
|
||||||
// 6: migration006, etc.
|
6: migration006,
|
||||||
|
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||||
|
// 8: migration008, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -527,7 +527,7 @@ function switchTabNode(t){
|
||||||
}else if(t==='notes') loadNodeNotes(id);
|
}else if(t==='notes') loadNodeNotes(id);
|
||||||
else if(t==='files') loadNodeFiles(id);
|
else if(t==='files') loadNodeFiles(id);
|
||||||
else if(t==='actions') loadNodeActions(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>');
|
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
|
||||||
}
|
}
|
||||||
function switchTabSection(t){
|
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)}
|
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
|
MODALS
|
||||||
════════════════════════════════════════════ */
|
════════════════════════════════════════════ */
|
||||||
|
|
@ -735,12 +782,11 @@ async function handleSR(q){
|
||||||
if(!q||q.length<2){b.innerHTML='';return}
|
if(!q||q.length<2){b.innerHTML='';return}
|
||||||
sT=setTimeout(async()=>{
|
sT=setTimeout(async()=>{
|
||||||
try{
|
try{
|
||||||
const items=await api('/api/nodes');
|
const items=await api('/api/search?q='+encodeURIComponent(q));
|
||||||
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
|
if(!items||!items.length){b.innerHTML='';return}
|
||||||
if(!hits.length){b.innerHTML='';return}
|
|
||||||
let h='';
|
let h='';
|
||||||
for(const r of hits){
|
for(const r of items){
|
||||||
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>';
|
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;
|
b.innerHTML=h;
|
||||||
}catch(e){b.innerHTML=''}
|
}catch(e){b.innerHTML=''}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import (
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the GUI HTTP server bound to a vault.
|
// Server is the GUI HTTP server bound to a vault.
|
||||||
|
|
@ -24,6 +26,8 @@ type Server struct {
|
||||||
files *files.Service
|
files *files.Service
|
||||||
notes *notes.Service
|
notes *notes.Service
|
||||||
actions *actions.Service
|
actions *actions.Service
|
||||||
|
worklog *worklog.Service
|
||||||
|
search *search.Service
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
|
|
@ -35,9 +39,12 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||||
fileSvc := files.NewService(db, vaultRoot)
|
fileSvc := files.NewService(db, vaultRoot)
|
||||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
|
workSvc := worklog.NewService(db)
|
||||||
|
srchSvc := search.NewService(db)
|
||||||
return &Server{
|
return &Server{
|
||||||
db: db, vaultRoot: vaultRoot,
|
db: db, vaultRoot: vaultRoot,
|
||||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
|
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/notes/", s.handleNotes)
|
||||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||||
mux.HandleFunc("/api/actions/", s.handleActions)
|
mux.HandleFunc("/api/actions/", s.handleActions)
|
||||||
|
mux.HandleFunc("/api/worklog/", s.handleWorklog)
|
||||||
mux.HandleFunc("/api/search", s.handleSearch)
|
mux.HandleFunc("/api/search", s.handleSearch)
|
||||||
mux.HandleFunc("/", s.handleStatic)
|
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) {
|
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 {
|
if len(q) < 2 {
|
||||||
jsonOK(w, []interface{}{})
|
jsonOK(w, []interface{}{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 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, "")
|
roots, _ := s.nodes.ListRoots(false, "")
|
||||||
var hits []map[string]interface{}
|
ql := strings.ToLower(q)
|
||||||
for _, n := range roots {
|
for _, n := range roots {
|
||||||
if strings.Contains(strings.ToLower(n.Title), q) {
|
if strings.Contains(strings.ToLower(n.Title), ql) {
|
||||||
hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type})
|
results = append(results, search.Result{
|
||||||
|
NodeID: n.ID, Title: n.Title, Type: n.Type,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonOK(w, hits)
|
}
|
||||||
|
jsonOK(w, results)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue