// 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] + "..." }