feat: ШАГ 2 — Staging-таблица browser_events + Store
This commit is contained in:
parent
358c649b42
commit
84d9725b17
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] + "..."
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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);
|
||||
`
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue