From 6811b91a17b7cf4107964b07aca581d735815e00 Mon Sep 17 00:00:00 2001 From: Octech2722 Date: Sat, 18 Oct 2025 12:08:41 -0500 Subject: [PATCH] feat: implement centralized logging system Components: - CentralizedLogger static class for log aggregation - Logger class with source context (background/content/popup/options) - Persistent storage in chrome.storage.local (up to 1000 entries) - Log viewer UI with filtering, search, and export - Survives service worker restarts Critical for MV3 debugging where service workers terminate frequently. Provides unified debugging across all extension contexts. --- .../src/logs/index.html | 280 ++++++++++ apps/web-clipper-manifestv3/src/logs/logs.css | 495 ++++++++++++++++++ apps/web-clipper-manifestv3/src/logs/logs.ts | 294 +++++++++++ .../src/shared/utils.ts | 344 ++++++++++++ 4 files changed, 1413 insertions(+) create mode 100644 apps/web-clipper-manifestv3/src/logs/index.html create mode 100644 apps/web-clipper-manifestv3/src/logs/logs.css create mode 100644 apps/web-clipper-manifestv3/src/logs/logs.ts create mode 100644 apps/web-clipper-manifestv3/src/shared/utils.ts diff --git a/apps/web-clipper-manifestv3/src/logs/index.html b/apps/web-clipper-manifestv3/src/logs/index.html new file mode 100644 index 000000000..03bb0dd96 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/logs/index.html @@ -0,0 +1,280 @@ + + + + + + Trilium Web Clipper - Log Viewer + + + +
+

Extension Log Viewer

+ +
+ + + + + + + + + + + + + + + +
+ + +
+
+ +
+
Loading logs...
+
+
+ + + + \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/logs/logs.css b/apps/web-clipper-manifestv3/src/logs/logs.css new file mode 100644 index 000000000..05660b529 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/logs/logs.css @@ -0,0 +1,495 @@ +/* + * Clean, simple log viewer CSS - no complex layouts + * This file is now unused - styles are inline in index.html + * Keeping this file for compatibility but styles are embedded + */ + +body { + background: #1a1a1a; + color: #e0e0e0; +} + +/* Force normal text layout for all log elements */ +.log-entry * { + writing-mode: horizontal-tb !important; + text-orientation: mixed !important; + direction: ltr !important; +} + +/* Force vertical stacking - override any inherited flexbox/grid/column layouts */ +.log-entries, #logs-list { + display: block !important; + flex-direction: column !important; + grid-template-columns: none !important; + column-count: 1 !important; + columns: none !important; +} + +.log-entry { + break-inside: avoid !important; + page-break-inside: avoid !important; +} + +/* Nuclear option - force all log entries to stack vertically */ +.log-entries .log-entry { + display: block !important; + width: 100% !important; + float: none !important; + position: relative !important; + left: 0 !important; + right: 0 !important; + top: auto !important; + margin-right: 0 !important; + margin-left: 0 !important; +} + +/* Make sure no flexbox/grid on any parent containers */ +.log-entries * { + box-sizing: border-box !important; +} + +.container { + background: var(--color-surface); + padding: 20px; + border-radius: 8px; + box-shadow: var(--shadow-lg); + max-width: 1200px; + margin: 0 auto; + border: 1px solid var(--color-border-primary); +} + +h1 { + color: var(--color-text-primary); + margin-bottom: 20px; + font-size: 24px; + font-weight: 600; +} + +.controls { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; + padding: 15px; + background: var(--color-surface-secondary); + border-radius: 6px; + border: 1px solid var(--color-border-primary); +} + +.control-group { + display: flex; + align-items: center; + gap: 8px; +} + +label { + font-weight: 500; + color: var(--color-text-primary); + font-size: 14px; +} + +select, +input[type="text"], +input[type="search"] { + padding: 6px 10px; + border: 1px solid var(--color-border-primary); + border-radius: 4px; + font-size: 14px; + background: var(--color-surface); + color: var(--color-text-primary); + transition: var(--theme-transition); +} + +select:focus, +input[type="text"]:focus, +input[type="search"]:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +button { + background: var(--color-primary); + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: var(--theme-transition); +} + +button:hover { + background: var(--color-primary-hover); +} + +button:active { + transform: translateY(1px); +} + +.secondary-btn { + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border-primary); +} + +.secondary-btn:hover { + background: var(--color-surface-hover); +} + +.danger-btn { + background: var(--color-error); +} + +.danger-btn:hover { + background: var(--color-error-hover); +} + +/* Log entries */ +.log-entries { + max-height: 70vh; + overflow-y: auto; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + background: var(--color-surface); + display: block !important; + width: 100%; +} + +#logs-list { + display: block !important; + width: 100%; + column-count: unset !important; + columns: unset !important; +} + +.log-entry { + display: block !important; + width: 100% !important; + max-width: 100% !important; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border-primary); + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 13px; + line-height: 1.4; + margin-bottom: 0; + background: var(--color-surface); + float: none !important; + position: static !important; + flex: none !important; + clear: both !important; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-entry:hover { + background: var(--color-surface-hover); +} + +.log-header { + display: block; + width: 100%; + margin-bottom: 6px; + font-size: 12px; +} + +.log-timestamp { + color: var(--color-text-secondary); + display: inline-block; + margin-right: 12px; +} + +.log-level { + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + min-width: 50px; + text-align: center; + margin-right: 8px; +} + +.log-level.debug { + background: var(--color-surface-secondary); + color: var(--color-text-secondary); +} + +.log-level.info { + background: var(--color-info-bg); + color: var(--color-info-text); +} + +.log-level.warn { + background: var(--color-warning-bg); + color: var(--color-warning-text); +} + +.log-level.error { + background: var(--color-error-bg); + color: var(--color-error-text); +} + +.log-source { + background: var(--color-primary-light); + color: var(--color-primary); + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + display: inline-block; + min-width: 70px; + text-align: center; +} + +.log-message { + color: var(--color-text-primary); + display: block; + width: 100%; + margin-top: 4px; + word-wrap: break-word; + overflow-wrap: break-word; + clear: both; +} + +.log-message-text { + display: block; + width: 100%; + margin-bottom: 4px; +} + +.log-message-text.truncated { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.expand-btn { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + background: var(--color-primary-light); + color: var(--color-primary); + border: none; + border-radius: 3px; + font-size: 11px; + cursor: pointer; + font-family: inherit; +} + +.expand-btn:hover { + background: var(--color-primary); + color: white; +} + +.log-data { + margin-top: 8px; + padding: 8px; + background: var(--color-surface-secondary); + border-radius: 4px; + border: 1px solid var(--color-border-primary); + font-size: 12px; + color: var(--color-text-secondary); + white-space: pre-wrap; + overflow-x: auto; +} + +/* Statistics */ +.stats { + display: flex; + gap: 20px; + margin-bottom: 20px; + padding: 15px; + background: var(--color-surface-secondary); + border-radius: 6px; + border: 1px solid var(--color-border-primary); + flex-wrap: wrap; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.stat-value { + font-size: 24px; + font-weight: 600; + color: var(--color-primary); +} + +.stat-label { + font-size: 12px; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 40px; + color: var(--color-text-secondary); +} + +.empty-state h3 { + color: var(--color-text-primary); + margin-bottom: 10px; +} + +/* Theme toggle */ +.theme-toggle { + background: var(--color-surface); + border: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); +} + +.theme-toggle:hover { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +/* Responsive design */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + .container { + padding: 15px; + } + + .controls { + flex-direction: column; + align-items: stretch; + } + + .control-group { + justify-content: space-between; + } + + .log-entry { + display: block !important; + width: 100% !important; + } + + .log-timestamp, + .log-level, + .log-source { + min-width: auto; + } + + .stats { + justify-content: center; + } +} + +/* Loading state */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; + color: var(--color-text-secondary); +} + +.loading::after { + content: ''; + width: 20px; + height: 20px; + border: 2px solid var(--color-border-primary); + border-top: 2px solid var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 10px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Scrollbar styling */ +.log-entries::-webkit-scrollbar { + width: 8px; +} + +.log-entries::-webkit-scrollbar-track { + background: var(--color-surface-secondary); +} + +.log-entries::-webkit-scrollbar-thumb { + background: var(--color-border-primary); + border-radius: 4px; +} + +.log-entries::-webkit-scrollbar-thumb:hover { + background: var(--color-text-secondary); +} + +/* Export dialog styling */ +.export-dialog { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.export-content { + background: var(--color-surface); + padding: 24px; + border-radius: 8px; + box-shadow: var(--shadow-lg); + max-width: 500px; + width: 90%; + border: 1px solid var(--color-border-primary); +} + +.export-content h3 { + margin-top: 0; + color: var(--color-text-primary); +} + +.export-options { + display: flex; + flex-direction: column; + gap: 12px; + margin: 20px 0; +} + +.export-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--color-border-primary); + border-radius: 4px; + cursor: pointer; + transition: var(--theme-transition); +} + +.export-option:hover { + background: var(--color-surface-hover); +} + +.export-option input[type="radio"] { + margin: 0; +} + +.export-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/logs/logs.ts b/apps/web-clipper-manifestv3/src/logs/logs.ts new file mode 100644 index 000000000..87bea277b --- /dev/null +++ b/apps/web-clipper-manifestv3/src/logs/logs.ts @@ -0,0 +1,294 @@ +import { CentralizedLogger, LogEntry } from '@/shared/utils'; + +class SimpleLogViewer { + private logs: LogEntry[] = []; + private autoRefreshTimer: number | null = null; + private lastLogCount: number = 0; + private autoRefreshEnabled: boolean = true; + private expandedLogs: Set = new Set(); // Track which logs are expanded + + constructor() { + this.initialize(); + } + + private async initialize(): Promise { + this.setupEventHandlers(); + await this.loadLogs(); + } + + private setupEventHandlers(): void { + const refreshBtn = document.getElementById('refresh-btn'); + const exportBtn = document.getElementById('export-btn'); + const clearBtn = document.getElementById('clear-btn'); + const expandAllBtn = document.getElementById('expand-all-btn'); + const collapseAllBtn = document.getElementById('collapse-all-btn'); + const levelFilter = document.getElementById('level-filter') as HTMLSelectElement; + const sourceFilter = document.getElementById('source-filter') as HTMLSelectElement; + const searchBox = document.getElementById('search-box') as HTMLInputElement; + const autoRefreshSelect = document.getElementById('auto-refresh-interval') as HTMLSelectElement; + + refreshBtn?.addEventListener('click', () => this.loadLogs()); + exportBtn?.addEventListener('click', () => this.exportLogs()); + clearBtn?.addEventListener('click', () => this.clearLogs()); + expandAllBtn?.addEventListener('click', () => this.expandAllLogs()); + collapseAllBtn?.addEventListener('click', () => this.collapseAllLogs()); + levelFilter?.addEventListener('change', () => this.renderLogs()); + sourceFilter?.addEventListener('change', () => this.renderLogs()); + searchBox?.addEventListener('input', () => this.renderLogs()); + autoRefreshSelect?.addEventListener('change', (e) => this.handleAutoRefreshChange(e)); + + // Start auto-refresh with default interval (5 seconds) + this.startAutoRefresh(5000); + + // Pause auto-refresh when tab is not visible + this.setupVisibilityHandling(); + } + + private setupVisibilityHandling(): void { + document.addEventListener('visibilitychange', () => { + this.autoRefreshEnabled = !document.hidden; + + // If tab becomes visible again, refresh immediately + if (!document.hidden) { + this.loadLogs(); + } + }); + + // Cleanup on page unload + window.addEventListener('beforeunload', () => { + this.stopAutoRefresh(); + }); + } + + private async loadLogs(): Promise { + try { + const newLogs = await CentralizedLogger.getLogs(); + const hasNewLogs = newLogs.length !== this.lastLogCount; + + this.logs = newLogs; + this.lastLogCount = newLogs.length; + + this.renderLogs(); + + // Show notification if new logs arrived during auto-refresh + if (hasNewLogs && this.logs.length > 0) { + this.showNewLogsIndicator(); + } + } catch (error) { + console.error('Failed to load logs:', error); + this.showError('Failed to load logs'); + } + } + + private handleAutoRefreshChange(event: Event): void { + const select = event.target as HTMLSelectElement; + const interval = parseInt(select.value); + + if (interval === 0) { + this.stopAutoRefresh(); + } else { + this.startAutoRefresh(interval); + } + } + + private startAutoRefresh(intervalMs: number): void { + this.stopAutoRefresh(); // Clear any existing timer + + if (intervalMs > 0) { + this.autoRefreshTimer = window.setInterval(() => { + if (this.autoRefreshEnabled) { + this.loadLogs(); + } + }, intervalMs); + } + } + + private stopAutoRefresh(): void { + if (this.autoRefreshTimer) { + clearInterval(this.autoRefreshTimer); + this.autoRefreshTimer = null; + } + } + + private showNewLogsIndicator(): void { + // Flash the refresh button to indicate new logs + const refreshBtn = document.getElementById('refresh-btn'); + if (refreshBtn) { + refreshBtn.style.background = '#28a745'; + refreshBtn.textContent = 'New logs!'; + + setTimeout(() => { + refreshBtn.style.background = '#007cba'; + refreshBtn.textContent = 'Refresh'; + }, 2000); + } + } + + private renderLogs(): void { + const logsList = document.getElementById('logs-list'); + if (!logsList) return; + + // Apply filters + const levelFilter = (document.getElementById('level-filter') as HTMLSelectElement).value; + const sourceFilter = (document.getElementById('source-filter') as HTMLSelectElement).value; + const searchQuery = (document.getElementById('search-box') as HTMLInputElement).value.toLowerCase(); + + let filteredLogs = this.logs.filter(log => { + if (levelFilter && log.level !== levelFilter) return false; + if (sourceFilter && log.source !== sourceFilter) return false; + if (searchQuery) { + const searchText = `${log.context} ${log.message}`.toLowerCase(); + if (!searchText.includes(searchQuery)) return false; + } + return true; + }); + + // Sort by timestamp (newest first) + filteredLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + if (filteredLogs.length === 0) { + logsList.innerHTML = '
No logs found
'; + return; + } + + // Render simple log entries + logsList.innerHTML = filteredLogs.map(log => this.renderLogItem(log)).join(''); + + // Add event listeners for expand buttons + this.setupExpandButtons(); + } + + private setupExpandButtons(): void { + const expandButtons = document.querySelectorAll('.expand-btn'); + expandButtons.forEach(button => { + button.addEventListener('click', (e) => { + const btn = e.target as HTMLButtonElement; + const logId = btn.getAttribute('data-log-id'); + if (!logId) return; + + const details = document.getElementById(`details-${logId}`); + if (!details) return; + + if (this.expandedLogs.has(logId)) { + // Collapse + details.style.display = 'none'; + btn.textContent = 'Expand'; + this.expandedLogs.delete(logId); + } else { + // Expand + details.style.display = 'block'; + btn.textContent = 'Collapse'; + this.expandedLogs.add(logId); + } + }); + }); + } + + private renderLogItem(log: LogEntry): string { + const timestamp = new Date(log.timestamp).toLocaleString(); + const message = this.escapeHtml(`[${log.context}] ${log.message}`); + + // Handle additional data + let details = ''; + if (log.args && log.args.length > 0) { + details += `
${JSON.stringify(log.args, null, 2)}
`; + } + if (log.error) { + details += `
Error: ${log.error.name}: ${log.error.message}
`; + } + + const needsExpand = message.length > 200 || details; + const displayMessage = needsExpand ? message.substring(0, 200) + '...' : message; + + // Check if this log is currently expanded + const isExpanded = this.expandedLogs.has(log.id); + const displayStyle = isExpanded ? 'block' : 'none'; + const buttonText = isExpanded ? 'Collapse' : 'Expand'; + + return ` +
+
+ ${timestamp} + ${log.level} + ${log.source} +
+
+ ${displayMessage} + ${needsExpand ? `` : ''} + ${needsExpand ? `
${message}${details}
` : ''} +
+
+ `; + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + private async exportLogs(): Promise { + try { + const logsJson = await CentralizedLogger.exportLogs(); + const blob = new Blob([logsJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `trilium-logs-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to export logs:', error); + } + } + + private async clearLogs(): Promise { + if (confirm('Are you sure you want to clear all logs?')) { + try { + await CentralizedLogger.clearLogs(); + this.logs = []; + this.expandedLogs.clear(); // Clear expanded state when clearing logs + this.renderLogs(); + } catch (error) { + console.error('Failed to clear logs:', error); + } + } + } + + private expandAllLogs(): void { + // Get all currently visible logs that can be expanded + const expandButtons = document.querySelectorAll('.expand-btn'); + expandButtons.forEach(button => { + const logId = button.getAttribute('data-log-id'); + if (logId) { + this.expandedLogs.add(logId); + } + }); + + // Re-render to apply the expanded state + this.renderLogs(); + } + + private collapseAllLogs(): void { + // Clear all expanded states + this.expandedLogs.clear(); + + // Re-render to apply the collapsed state + this.renderLogs(); + } + + private showError(message: string): void { + const logsList = document.getElementById('logs-list'); + if (logsList) { + logsList.innerHTML = `
${message}
`; + } + } +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new SimpleLogViewer(); +}); diff --git a/apps/web-clipper-manifestv3/src/shared/utils.ts b/apps/web-clipper-manifestv3/src/shared/utils.ts new file mode 100644 index 000000000..80aa2e734 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/shared/utils.ts @@ -0,0 +1,344 @@ +/** + * Log entry interface for centralized logging + */ +export interface LogEntry { + id: string; + timestamp: string; + level: 'debug' | 'info' | 'warn' | 'error'; + context: string; + message: string; + args?: unknown[]; + error?: { + name: string; + message: string; + stack?: string; + }; + source: 'background' | 'content' | 'popup' | 'options'; +} + +/** + * Centralized logging system for the extension + * Aggregates logs from all contexts and provides unified access + */ +export class CentralizedLogger { + private static readonly MAX_LOGS = 1000; + private static readonly STORAGE_KEY = 'extension_logs'; + + /** + * Add a log entry to centralized storage + */ + static async addLog(entry: Omit): Promise { + try { + const logEntry: LogEntry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + ...entry, + }; + + // Get existing logs + const result = await chrome.storage.local.get(this.STORAGE_KEY); + const logs: LogEntry[] = result[this.STORAGE_KEY] || []; + + // Add new log and maintain size limit + logs.push(logEntry); + if (logs.length > this.MAX_LOGS) { + logs.splice(0, logs.length - this.MAX_LOGS); + } + + // Store updated logs + await chrome.storage.local.set({ [this.STORAGE_KEY]: logs }); + } catch (error) { + console.error('Failed to store centralized log:', error); + } + } + + /** + * Get all logs from centralized storage + */ + static async getLogs(): Promise { + try { + const result = await chrome.storage.local.get(this.STORAGE_KEY); + return result[this.STORAGE_KEY] || []; + } catch (error) { + console.error('Failed to retrieve logs:', error); + return []; + } + } + + /** + * Clear all logs + */ + static async clearLogs(): Promise { + try { + await chrome.storage.local.remove(this.STORAGE_KEY); + } catch (error) { + console.error('Failed to clear logs:', error); + } + } + + /** + * Export logs as JSON string + */ + static async exportLogs(): Promise { + const logs = await this.getLogs(); + return JSON.stringify(logs, null, 2); + } + + /** + * Get logs filtered by level + */ + static async getLogsByLevel(level: LogEntry['level']): Promise { + const logs = await this.getLogs(); + return logs.filter(log => log.level === level); + } + + /** + * Get logs filtered by context + */ + static async getLogsByContext(context: string): Promise { + const logs = await this.getLogs(); + return logs.filter(log => log.context === context); + } + + /** + * Get logs filtered by source + */ + static async getLogsBySource(source: LogEntry['source']): Promise { + const logs = await this.getLogs(); + return logs.filter(log => log.source === source); + } +} + +/** + * Enhanced logging system for the extension with centralized storage + */ +export class Logger { + private context: string; + private source: LogEntry['source']; + private isDebugMode: boolean = process.env.NODE_ENV === 'development'; + + constructor(context: string, source: LogEntry['source'] = 'background') { + this.context = context; + this.source = source; + } + + static create(context: string, source: LogEntry['source'] = 'background'): Logger { + return new Logger(context, source); + } + + private async logToStorage(level: LogEntry['level'], message: string, args?: unknown[], error?: Error): Promise { + const logEntry: Omit = { + level, + context: this.context, + message, + source: this.source, + args: args && args.length > 0 ? args : undefined, + error: error ? { + name: error.name, + message: error.message, + stack: error.stack, + } : undefined, + }; + + await CentralizedLogger.addLog(logEntry); + } + + private formatMessage(level: string, message: string, ...args: unknown[]): void { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${this.source}:${this.context}] [${level.toUpperCase()}]`; + + if (this.isDebugMode || level === 'ERROR') { + const consoleMethod = console[level as keyof typeof console] as (...args: unknown[]) => void; + if (typeof consoleMethod === 'function') { + consoleMethod(prefix, message, ...args); + } + } + } + + debug(message: string, ...args: unknown[]): void { + this.formatMessage('debug', message, ...args); + this.logToStorage('debug', message, args).catch(console.error); + } + + info(message: string, ...args: unknown[]): void { + this.formatMessage('info', message, ...args); + this.logToStorage('info', message, args).catch(console.error); + } + + warn(message: string, ...args: unknown[]): void { + this.formatMessage('warn', message, ...args); + this.logToStorage('warn', message, args).catch(console.error); + } + + error(message: string, error?: Error, ...args: unknown[]): void { + this.formatMessage('error', message, error, ...args); + this.logToStorage('error', message, args, error).catch(console.error); + + // In production, you might want to send errors to a logging service + if (!this.isDebugMode && error) { + this.reportError(error, message); + } + } + + private async reportError(error: Error, context: string): Promise { + try { + // Store error details for debugging + await chrome.storage.local.set({ + [`error_${Date.now()}`]: { + message: error.message, + stack: error.stack, + context, + timestamp: new Date().toISOString() + } + }); + } catch (e) { + console.error('Failed to store error:', e); + } + } +} + +/** + * Utility functions + */ +export const Utils = { + /** + * Generate a random string of specified length + */ + randomString(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }, + + /** + * Get the base URL of the current page + */ + getBaseUrl(url: string = window.location.href): string { + try { + const urlObj = new URL(url); + return `${urlObj.protocol}//${urlObj.host}`; + } catch (error) { + return ''; + } + }, + + /** + * Convert a relative URL to absolute + */ + makeAbsoluteUrl(relativeUrl: string, baseUrl: string): string { + try { + return new URL(relativeUrl, baseUrl).href; + } catch (error) { + return relativeUrl; + } + }, + + /** + * Sanitize HTML content + */ + sanitizeHtml(html: string): string { + const div = document.createElement('div'); + div.textContent = html; + return div.innerHTML; + }, + + /** + * Debounce function calls + */ + debounce void>( + func: T, + wait: number + ): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + }, + + /** + * Sleep for specified milliseconds + */ + sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + }, + + /** + * Retry a function with exponential backoff + */ + async retry( + fn: () => Promise, + maxAttempts: number = 3, + baseDelay: number = 1000 + ): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxAttempts) { + throw lastError; + } + + const delay = baseDelay * Math.pow(2, attempt - 1); + await this.sleep(delay); + } + } + + throw lastError!; + } +}; + +/** + * Message handling utilities + */ +export const MessageUtils = { + /** + * Send a message with automatic retry and error handling + */ + async sendMessage(message: unknown, tabId?: number): Promise { + const logger = Logger.create('MessageUtils'); + + try { + const response = tabId + ? await chrome.tabs.sendMessage(tabId, message) + : await chrome.runtime.sendMessage(message); + + return response as T; + } catch (error) { + logger.error('Failed to send message', error as Error, { message, tabId }); + throw error; + } + }, + + /** + * Create a message response handler + */ + createResponseHandler( + handler: (message: unknown, sender: chrome.runtime.MessageSender) => Promise | T, + source: LogEntry['source'] = 'background' + ) { + return ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response: T) => void + ): boolean => { + const logger = Logger.create('MessageHandler', source); + + Promise.resolve(handler(message, sender)) + .then(sendResponse) + .catch(error => { + logger.error('Message handler failed', error as Error, { message, sender }); + sendResponse({ error: error.message } as T); + }); + + return true; // Indicates async response + }; + } +};