210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
// 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] + "..."
|
|
}
|