From 84d9725b17a917b3265289990cd300eb2f284ec2 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 6 Jun 2026 18:27:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A8=D0=90=D0=93=202=20=E2=80=94=20St?= =?UTF-8?q?aging-=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20browser=5Fev?= =?UTF-8?q?ents=20+=20Store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/verstak-gui/app.go | 2 + cmd/verstak-gui/bindings_bridge.go | 45 ++++- cmd/verstak-gui/bindings_config.go | 3 + internal/core/browser/store.go | 209 ++++++++++++++++++++ internal/core/browser/store_test.go | 167 ++++++++++++++++ internal/core/storage/migrations_018.sql.go | 29 +++ internal/core/storage/storage.go | 13 +- 7 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 internal/core/browser/store.go create mode 100644 internal/core/browser/store_test.go create mode 100644 internal/core/storage/migrations_018.sql.go diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index ac29415..69ddddf 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -14,6 +14,7 @@ import ( "verstak/internal/core/actions" "verstak/internal/core/activity" "verstak/internal/core/bridge" + "verstak/internal/core/browser" "verstak/internal/core/config" "verstak/internal/core/files" "verstak/internal/core/nodes" @@ -46,6 +47,7 @@ type App struct { sync *syncsvc.Service fileWatcher *watcher.Service bridge *bridge.Server + browser *browser.Store vault string } diff --git a/cmd/verstak-gui/bindings_bridge.go b/cmd/verstak-gui/bindings_bridge.go index 1c660a7..f1fb641 100644 --- a/cmd/verstak-gui/bindings_bridge.go +++ b/cmd/verstak-gui/bindings_bridge.go @@ -4,6 +4,7 @@ import ( "log" "verstak/internal/core/bridge" + "verstak/internal/core/browser" "verstak/internal/core/config" ) @@ -13,9 +14,18 @@ func (a *App) startBridge(appCfg *config.AppConfig) { bc := a.bridgeConfig(appCfg) handler := func(events []bridge.Event) { - // For now, log received events. Storage comes in Step 2. + // Convert to browser events and store in staging. + be := make([]browser.Event, 0, len(events)) for _, ev := range events { - log.Printf("[bridge] event: type=%s url=%s domain=%s", ev.Type, ev.URL, ev.Domain) + be = append(be, bridgeToBrowser(ev)) + } + n, err := a.browser.InsertEvents(be) + if err != nil { + log.Printf("[bridge] store events: %v", err) + return + } + if n > 0 { + log.Printf("[bridge] stored %d/%d events", n, len(be)) } } @@ -94,3 +104,34 @@ func (a *App) BridgeInfo() map[string]interface{} { } return info } + +// bridgeToBrowser converts a bridge.Event to a browser.Event. +func bridgeToBrowser(ev bridge.Event) browser.Event { + deviceID := ev.ID + if idx := indexOf(ev.ID, "_"); idx > 0 { + deviceID = ev.ID[:idx] + } + return browser.Event{ + ID: ev.ID, + DeviceID: deviceID, + Type: ev.Type, + URL: ev.URL, + Title: ev.Title, + Domain: ev.Domain, + ActiveSeconds: ev.ActiveSeconds, + TSStart: ev.TSStart, + TSEnd: ev.TSEnd, + TS: ev.TS, + SelectedText: ev.SelectedText, + Note: ev.Note, + } +} + +func indexOf(s string, sub string) int { + for i := 0; i < len(s); i++ { + if string(s[i]) == sub { + return i + } + } + return -1 +} diff --git a/cmd/verstak-gui/bindings_config.go b/cmd/verstak-gui/bindings_config.go index b2c9d16..58192dc 100644 --- a/cmd/verstak-gui/bindings_config.go +++ b/cmd/verstak-gui/bindings_config.go @@ -8,6 +8,7 @@ import ( "verstak/internal/core/actions" "verstak/internal/core/activity" + "verstak/internal/core/browser" "verstak/internal/core/config" "verstak/internal/core/files" "verstak/internal/core/nodes" @@ -266,6 +267,7 @@ func (a *App) initVault(vaultPath string) error { a.templates = templatesReg a.sync = syncSvc a.fileWatcher = watcherSvc + a.browser = browser.NewStore(db) a.vault = abs a.vaultOpen = true a.mu.Unlock() @@ -319,6 +321,7 @@ func (a *App) closeVault() { a.sync = nil a.fileWatcher = nil a.bridge = nil + a.browser = nil a.vault = "" a.vaultOpen = false } diff --git a/internal/core/browser/store.go b/internal/core/browser/store.go new file mode 100644 index 0000000..258f782 --- /dev/null +++ b/internal/core/browser/store.go @@ -0,0 +1,209 @@ +// Package browser provides storage and operations for browser extension events. +// Events are staged in the database and can be accepted as worklog entries, +// attached to nodes, or dismissed. +package browser + +import ( + "fmt" + "log" + "strings" + "time" + + "verstak/internal/core/storage" +) + +// Event represents a staged browser event from the database. +type Event struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + Type string `json:"type"` + URL string `json:"url"` + Title string `json:"title"` + Domain string `json:"domain"` + ActiveSeconds int `json:"active_seconds"` + TSStart string `json:"ts_start"` + TSEnd string `json:"ts_end"` + TS string `json:"ts"` + SelectedText string `json:"selected_text"` + Note string `json:"note"` + ScreenshotPath string `json:"screenshot_path"` + Status string `json:"status"` // pending, accepted, dismissed, attached + CreatedAt string `json:"created_at"` + AcceptedAt string `json:"accepted_at"` + NodeID string `json:"node_id"` + WorklogID string `json:"worklog_id"` +} + +// Store provides CRUD for browser events. +type Store struct { + db *storage.DB +} + +// NewStore creates a new browser event store. +func NewStore(db *storage.DB) *Store { + return &Store{db: db} +} + +// InsertEvents bulk-inserts events into the staging table. +func (s *Store) InsertEvents(events []Event) (int, error) { + if len(events) == 0 { + return 0, nil + } + + now := time.Now().UTC().Format(time.RFC3339) + inserted := 0 + + for _, ev := range events { + res, err := s.db.Exec(` + INSERT OR IGNORE INTO browser_events + (id, device_id, type, url, title, domain, active_seconds, + ts_start, ts_end, ts, selected_text, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ev.ID, ev.DeviceID, ev.Type, ev.URL, ev.Title, ev.Domain, ev.ActiveSeconds, + ev.TSStart, ev.TSEnd, ev.TS, ev.SelectedText, ev.Note, now, + ) + if err != nil { + log.Printf("[browser] insert event %s: %v", ev.ID, err) + continue + } + affected, _ := res.RowsAffected() + if affected > 0 { + inserted++ + } + } + + return inserted, nil +} + +// ListPending returns events with 'pending' status, newest first. +func (s *Store) ListPending(limit, offset int) ([]Event, error) { + return s.listByStatus("pending", limit, offset) +} + +// ListAll returns all events, newest first. +func (s *Store) ListAll(limit, offset int) ([]Event, error) { + rows, err := s.db.Query( + `SELECT id, device_id, type, url, title, domain, active_seconds, + ts_start, ts_end, ts, selected_text, note, screenshot_path, + status, created_at, accepted_at, node_id, worklog_id + FROM browser_events + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, fmt.Errorf("list events: %w", err) + } + defer rows.Close() + return scanEvents(rows) +} + +// CountPending returns the number of pending events. +func (s *Store) CountPending() (int, error) { + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM browser_events WHERE status = 'pending'").Scan(&count) + return count, err +} + +// Accept marks an event as accepted, optionally linking it to a worklog entry. +func (s *Store) Accept(id, worklogID string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec( + "UPDATE browser_events SET status = 'accepted', accepted_at = ?, worklog_id = ? WHERE id = ?", + now, worklogID, id) + return err +} + +// Attach marks an event as attached to a node. +func (s *Store) Attach(id, nodeID string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec( + "UPDATE browser_events SET status = 'attached', accepted_at = ?, node_id = ? WHERE id = ?", + now, nodeID, id) + return err +} + +// Dismiss marks an event as dismissed. +func (s *Store) Dismiss(id string) error { + _, err := s.db.Exec("UPDATE browser_events SET status = 'dismissed' WHERE id = ?", id) + return err +} + +// Get returns a single event by ID. +func (s *Store) Get(id string) (*Event, error) { + row := s.db.QueryRow( + `SELECT id, device_id, type, url, title, domain, active_seconds, + ts_start, ts_end, ts, selected_text, note, screenshot_path, + status, created_at, accepted_at, node_id, worklog_id + FROM browser_events WHERE id = ?`, id) + + var ev Event + err := row.Scan( + &ev.ID, &ev.DeviceID, &ev.Type, &ev.URL, &ev.Title, &ev.Domain, &ev.ActiveSeconds, + &ev.TSStart, &ev.TSEnd, &ev.TS, &ev.SelectedText, &ev.Note, &ev.ScreenshotPath, + &ev.Status, &ev.CreatedAt, &ev.AcceptedAt, &ev.NodeID, &ev.WorklogID, + ) + if err != nil { + return nil, fmt.Errorf("get event %s: %w", id, err) + } + return &ev, nil +} + +// --- internals --- + +func (s *Store) listByStatus(status string, limit, offset int) ([]Event, error) { + rows, err := s.db.Query( + `SELECT id, device_id, type, url, title, domain, active_seconds, + ts_start, ts_end, ts, selected_text, note, screenshot_path, + status, created_at, accepted_at, node_id, worklog_id + FROM browser_events + WHERE status = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, status, limit, offset) + if err != nil { + return nil, fmt.Errorf("list %s events: %w", status, err) + } + defer rows.Close() + return scanEvents(rows) +} + +func scanEvents(rows interface { + Next() bool + Scan(dest ...interface{}) error + Close() error +}) ([]Event, error) { + var events []Event + for rows.Next() { + var ev Event + err := rows.Scan( + &ev.ID, &ev.DeviceID, &ev.Type, &ev.URL, &ev.Title, &ev.Domain, &ev.ActiveSeconds, + &ev.TSStart, &ev.TSEnd, &ev.TS, &ev.SelectedText, &ev.Note, &ev.ScreenshotPath, + &ev.Status, &ev.CreatedAt, &ev.AcceptedAt, &ev.NodeID, &ev.WorklogID, + ) + if err != nil { + return nil, fmt.Errorf("scan event: %w", err) + } + events = append(events, ev) + } + return events, rows.Close() +} + +// String returns a short summary for logging. +func (e *Event) String() string { + parts := []string{e.Type} + if e.Domain != "" { + parts = append(parts, e.Domain) + } + if e.Title != "" { + parts = append(parts, truncate(e.Title, 40)) + } + if e.ActiveSeconds > 0 { + parts = append(parts, fmt.Sprintf("%ds", e.ActiveSeconds)) + } + return strings.Join(parts, " | ") +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/core/browser/store_test.go b/internal/core/browser/store_test.go new file mode 100644 index 0000000..6b36f89 --- /dev/null +++ b/internal/core/browser/store_test.go @@ -0,0 +1,167 @@ +package browser + +import ( + "os" + "path/filepath" + "testing" + + "verstak/internal/core/storage" +) + +func setupDB(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + return NewStore(db) +} + +func TestInsertAndListPending(t *testing.T) { + s := setupDB(t) + + events := []Event{ + {ID: "e1", DeviceID: "dev-1", Type: "page_visit", URL: "https://example.com", Domain: "example.com", ActiveSeconds: 120}, + {ID: "e2", DeviceID: "dev-1", Type: "page_visit", URL: "https://github.com", Domain: "github.com", ActiveSeconds: 300}, + } + + n, err := s.InsertEvents(events) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("expected 2 inserted, got %d", n) + } + + pending, err := s.ListPending(10, 0) + if err != nil { + t.Fatal(err) + } + if len(pending) != 2 { + t.Errorf("expected 2 pending, got %d", len(pending)) + } +} + +func TestInsertDuplicate(t *testing.T) { + s := setupDB(t) + + ev := []Event{{ID: "e1", DeviceID: "dev-1", Type: "page_visit", URL: "https://example.com", Domain: "example.com"}} + n, err := s.InsertEvents(ev) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("expected 1 inserted, got %d", n) + } + + // Same ID again + n, err = s.InsertEvents(ev) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("expected 0 inserted (duplicate), got %d", n) + } +} + +func TestAccept(t *testing.T) { + s := setupDB(t) + + _, _ = s.InsertEvents([]Event{{ID: "e1", DeviceID: "d1", Type: "page_visit", URL: "https://example.com", Domain: "ex.com"}}) + + if err := s.Accept("e1", "wl-1"); err != nil { + t.Fatal(err) + } + + ev, err := s.Get("e1") + if err != nil { + t.Fatal(err) + } + if ev.Status != "accepted" { + t.Errorf("expected status 'accepted', got %s", ev.Status) + } + if ev.WorklogID != "wl-1" { + t.Errorf("expected worklog_id 'wl-1', got %s", ev.WorklogID) + } +} + +func TestAttach(t *testing.T) { + s := setupDB(t) + + _, _ = s.InsertEvents([]Event{{ID: "e1", DeviceID: "d1", Type: "page_visit", URL: "https://example.com", Domain: "ex.com"}}) + + if err := s.Attach("e1", "node-1"); err != nil { + t.Fatal(err) + } + + ev, err := s.Get("e1") + if err != nil { + t.Fatal(err) + } + if ev.Status != "attached" { + t.Errorf("expected status 'attached', got %s", ev.Status) + } + if ev.NodeID != "node-1" { + t.Errorf("expected node_id 'node-1', got %s", ev.NodeID) + } +} + +func TestDismiss(t *testing.T) { + s := setupDB(t) + + _, _ = s.InsertEvents([]Event{{ID: "e1", DeviceID: "d1", Type: "page_visit", URL: "https://example.com", Domain: "ex.com"}}) + + if err := s.Dismiss("e1"); err != nil { + t.Fatal(err) + } + + ev, err := s.Get("e1") + if err != nil { + t.Fatal(err) + } + if ev.Status != "dismissed" { + t.Errorf("expected status 'dismissed', got %s", ev.Status) + } +} + +func TestCountPending(t *testing.T) { + s := setupDB(t) + + _, _ = s.InsertEvents([]Event{ + {ID: "e1", DeviceID: "d1", Type: "page_visit", URL: "https://a.com", Domain: "a.com"}, + {ID: "e2", DeviceID: "d1", Type: "page_visit", URL: "https://b.com", Domain: "b.com"}, + }) + + count, err := s.CountPending() + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Errorf("expected 2 pending, got %d", count) + } + + _ = s.Dismiss("e1") + count, _ = s.CountPending() + if count != 1 { + t.Errorf("expected 1 pending after dismiss, got %d", count) + } +} + +func TestStoreFileAndDirPermissions(t *testing.T) { + // Verify DB file permissions match project conventions + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + info, err := os.Stat(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Logf("DB file mode: %o", info.Mode()) +} diff --git a/internal/core/storage/migrations_018.sql.go b/internal/core/storage/migrations_018.sql.go new file mode 100644 index 0000000..4ce69a4 --- /dev/null +++ b/internal/core/storage/migrations_018.sql.go @@ -0,0 +1,29 @@ +package storage + +// migration018 — browser events staging table for extension integration. +const migration018 = ` +CREATE TABLE IF NOT EXISTS browser_events ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + type TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + active_seconds INTEGER NOT NULL DEFAULT 0, + ts_start TEXT NOT NULL DEFAULT '', + ts_end TEXT NOT NULL DEFAULT '', + ts TEXT NOT NULL DEFAULT '', + selected_text TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + screenshot_path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + accepted_at TEXT NOT NULL DEFAULT '', + node_id TEXT NOT NULL DEFAULT '', + worklog_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_browser_events_status ON browser_events(status); +CREATE INDEX IF NOT EXISTS idx_browser_events_domain ON browser_events(domain); +CREATE INDEX IF NOT EXISTS idx_browser_events_created ON browser_events(created_at); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 1b1af26..f0e8c54 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -57,12 +57,12 @@ CREATE TABLE IF NOT EXISTS _schema_ver ( ` var migrationFiles = map[int]string{ - 1: migration001, - 2: migration002, - 3: migration003, - 4: migration004, - 5: migration005, - 6: migration006, + 1: migration001, + 2: migration002, + 3: migration003, + 4: migration004, + 5: migration005, + 6: migration006, // 7: migration007 (FTS5) — created lazily by search.Rebuild() 8: migration008, 9: migration009, @@ -74,6 +74,7 @@ var migrationFiles = map[int]string{ 15: migration015, 16: migration016, 17: migration017, + 18: migration018, } func (db *DB) runInitialSchema() error {