From ed4fe77cfec7b89df685ffec9b432dfb12dc53d4 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:07:16 +0300 Subject: [PATCH 01/17] Add modern package.json with build system and testing infrastructure --- package.json | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f5cb12 --- /dev/null +++ b/package.json @@ -0,0 +1,111 @@ +{ + "name": "genai-browser-tool", + "version": "4.0.1", + "description": "Advanced AI-powered browser extension with intelligent summarization, Q&A, translation, sentiment analysis, content extraction, and smart productivity features.", + "type": "module", + "scripts": { + "build": "npm run build:extension && npm run build:web", + "build:extension": "rollup -c rollup.extension.config.js", + "build:web": "vite build", + "dev": "vite dev", + "dev:extension": "rollup -c rollup.extension.config.js --watch", + "test": "vitest", + "test:e2e": "playwright test", + "test:coverage": "vitest --coverage", + "lint": "eslint . --ext .js,.ts,.json --fix", + "format": "prettier --write \"**/*.{js,ts,json,css,html,md}\"", + "typecheck": "tsc --noEmit", + "prepare": "husky install", + "deploy:vercel": "vercel --prod", + "deploy:railway": "railway deploy", + "deploy:render": "render-deploy" + }, + "keywords": [ + "ai", + "browser-extension", + "summarization", + "question-answering", + "translation", + "productivity", + "openai", + "claude", + "gemini" + ], + "author": "Aaron Sequeira ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/aaron-seq/GenAI-Browser-Tool.git" + }, + "bugs": { + "url": "https://github.com/aaron-seq/GenAI-Browser-Tool/issues" + }, + "homepage": "https://github.com/aaron-seq/GenAI-Browser-Tool#readme", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.40.1", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.0.1", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^11.1.5", + "@types/chrome": "^0.0.251", + "@types/node": "^20.10.4", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.0.4", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-security": "^1.7.1", + "husky": "^8.0.3", + "jsdom": "^23.0.1", + "lint-staged": "^15.2.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "rollup": "^4.7.0", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8", + "vite-plugin-pwa": "^0.17.4", + "vitest": "^1.0.4" + }, + "dependencies": { + "@ai-sdk/anthropic": "^0.0.11", + "@ai-sdk/openai": "^0.0.13", + "@google-ai/generativelanguage": "^2.5.0", + "ai": "^2.2.29", + "dompurify": "^3.0.7", + "idb": "^7.1.1", + "marked": "^11.1.1", + "validator": "^13.11.0", + "zod": "^3.22.4" + }, + "lint-staged": { + "*.{js,ts}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,css,html,md}": [ + "prettier --write" + ] + }, + "browserslist": [ + "Chrome >= 88", + "Firefox >= 78", + "Edge >= 88" + ], + "files": [ + "dist/**/*", + "src/**/*", + "manifest.json", + "README.md", + "LICENSE" + ] +} \ No newline at end of file From fe08ef03f2eab19647d5db81d0dc6a53021f8791 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:12:08 +0300 Subject: [PATCH 02/17] Add TypeScript configuration for modern development --- tsconfig.json | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tsconfig.json diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d48c04b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,61 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022", "WebWorker"], + "allowJs": true, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": true, + "verbatimModuleSyntax": false + }, + "include": [ + "src/**/*", + "types/**/*", + "tests/**/*", + "*.config.*" + ], + "exclude": [ + "node_modules", + "dist", + "build", + "coverage", + "*.min.js" + ], + "ts-node": { + "esm": true + } +} \ No newline at end of file From eb8e4f47de3dc3f87c60bf6762cd7223862b62d5 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:24:22 +0300 Subject: [PATCH 03/17] Add Vite configuration for web deployment --- vite.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 vite.config.js diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..5f2cb40 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,15 @@ +export default { + root: 'web', + base: '/', + build: { + outDir: '../dist/web', + emptyOutDir: true, + sourcemap: true, + minify: 'terser', + target: 'es2022' + }, + server: { + port: 3000, + open: true + } +}; \ No newline at end of file From ad445edbccf757cecfe5974e00c928cf5ba908ee Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:48:06 +0300 Subject: [PATCH 04/17] Add ESLint configuration with security and best practices --- .eslintrc.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .eslintrc.json diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..af2b427 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "env": { + "browser": true, + "es2022": true, + "node": true, + "webextensions": true + }, + "extends": [ + "eslint:recommended" + ], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-debugger": "error", + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "prefer-const": "error", + "no-var": "error", + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "node_modules/**", + "coverage/**" + ] +} \ No newline at end of file From 4e697d87a2e36841365a44d19572483b1b3a309f Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:48:29 +0300 Subject: [PATCH 05/17] Add Prettier configuration for consistent formatting --- .prettierrc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..213b74e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css" +} \ No newline at end of file From f4c0c14549effe187f7303a70b7e79cb678ceb27 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:49:38 +0300 Subject: [PATCH 06/17] Modernize popup.js with better architecture and performance --- src/popup/popup-manager.js | 437 +++++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 src/popup/popup-manager.js diff --git a/src/popup/popup-manager.js b/src/popup/popup-manager.js new file mode 100644 index 0000000..bfec651 --- /dev/null +++ b/src/popup/popup-manager.js @@ -0,0 +1,437 @@ +/** + * @fileoverview Main popup manager for GenAI Browser Tool extension + * @author Aaron Sequeira + * @version 4.0.1 + */ + +import { EventManager } from '../utils/event-manager.js'; +import { ErrorHandler } from '../utils/error-handler.js'; +import { UIRenderer } from '../ui/ui-renderer.js'; +import { ContentExtractor } from '../services/content-extractor.js'; +import { AIService } from '../services/ai-service.js'; +import { StorageManager } from '../services/storage-manager.js'; +import { ValidationService } from '../utils/validation-service.js'; + +/** + * Manages the popup interface and user interactions + * Provides a clean, performant interface for AI-powered browser features + */ +export class PopupManager { + constructor() { + this.eventManager = new EventManager(); + this.errorHandler = new ErrorHandler('PopupManager'); + this.uiRenderer = new UIRenderer(); + this.contentExtractor = new ContentExtractor(); + this.aiService = new AIService(); + this.storageManager = new StorageManager(); + this.validationService = new ValidationService(); + + this.currentPageContent = null; + this.isInitialized = false; + this.conversationHistory = []; + this.activeOperations = new Set(); + + this.initializePopup(); + } + + /** + * Initialize the popup with error handling and graceful degradation + */ + async initializePopup() { + try { + this.showLoadingState('Initializing GenAI Assistant...'); + + await this.setupEventListeners(); + await this.loadUserPreferences(); + await this.extractCurrentPageContent(); + await this.restoreSessionState(); + + this.isInitialized = true; + this.hideLoadingState(); + this.showSuccessMessage('Ready to assist!'); + } catch (error) { + this.errorHandler.handleError(error, 'Failed to initialize popup'); + this.showErrorState('Failed to initialize. Please refresh and try again.'); + } + } + + /** + * Set up all event listeners with proper error boundaries + */ + async setupEventListeners() { + const eventBindings = [ + { selector: '#summarize-page-button', event: 'click', handler: this.handlePageSummarization }, + { selector: '#ask-question-button', event: 'click', handler: this.handleQuestionSubmission }, + { selector: '#translate-content-button', event: 'click', handler: this.handleContentTranslation }, + { selector: '#analyze-sentiment-button', event: 'click', handler: this.handleSentimentAnalysis }, + { selector: '#export-results-button', event: 'click', handler: this.handleResultsExport }, + { selector: '#question-input', event: 'keypress', handler: this.handleQuestionInputKeypress }, + { selector: '.tab-navigation-button', event: 'click', handler: this.handleTabSwitch }, + { selector: '.settings-toggle', event: 'click', handler: this.handleSettingsToggle } + ]; + + eventBindings.forEach(({ selector, event, handler }) => { + this.eventManager.addEventListenerSafely( + selector, + event, + handler.bind(this), + { passive: false, once: false } + ); + }); + } + + /** + * Extract content from the current page with fallback handling + */ + async extractCurrentPageContent() { + try { + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: true + }); + + if (!this.validationService.isValidWebPage(activeTab)) { + this.showWarningMessage('Cannot access this page type'); + return; + } + + const contentResponse = await chrome.tabs.sendMessage(activeTab.id, { + actionType: 'EXTRACT_PAGE_CONTENT', + requestId: this.generateRequestId(), + options: { + includeMetadata: true, + includeImages: false, + maxContentLength: 50000 + } + }); + + if (contentResponse?.success) { + this.currentPageContent = contentResponse.data; + this.updatePageInfoDisplay(activeTab, this.currentPageContent); + } else { + throw new Error(contentResponse?.error || 'Content extraction failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Page content extraction failed'); + this.showWarningMessage('Limited functionality - could not access page content'); + } + } + + /** + * Handle page summarization with multiple summary types + */ + async handlePageSummarization(event) { + const operationId = 'page-summarization'; + + if (this.activeOperations.has(operationId)) { + this.showWarningMessage('Summarization already in progress'); + return; + } + + try { + this.activeOperations.add(operationId); + this.showLoadingState('Generating intelligent summary...'); + + if (!this.currentPageContent?.mainTextContent) { + throw new Error('No content available to summarize'); + } + + const summaryOptions = this.getSummaryOptions(); + const summaryResponse = await this.aiService.generateContentSummary({ + content: this.currentPageContent.mainTextContent, + title: this.currentPageContent.pageTitle, + url: this.currentPageContent.pageUrl, + ...summaryOptions + }); + + if (summaryResponse.success) { + this.displaySummaryResults(summaryResponse.data); + await this.storageManager.saveSummaryToHistory(summaryResponse.data); + } else { + throw new Error(summaryResponse.error || 'Summarization failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Page summarization failed'); + this.showErrorMessage('Failed to generate summary. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideLoadingState(); + } + } + + /** + * Handle question submission with conversation context + */ + async handleQuestionSubmission(event) { + const questionInput = document.getElementById('question-input'); + const userQuestion = questionInput.value.trim(); + + if (!this.validationService.isValidQuestion(userQuestion)) { + this.showWarningMessage('Please enter a valid question'); + return; + } + + const operationId = 'question-answering'; + + try { + this.activeOperations.add(operationId); + questionInput.value = ''; + this.addConversationMessage('user', userQuestion); + this.showTypingIndicator(); + + const questionResponse = await this.aiService.answerContextualQuestion({ + question: userQuestion, + pageContent: this.currentPageContent, + conversationHistory: this.conversationHistory, + includeSourceReferences: true + }); + + if (questionResponse.success) { + this.addConversationMessage('assistant', questionResponse.data.answer); + this.conversationHistory.push({ + question: userQuestion, + answer: questionResponse.data.answer, + timestamp: Date.now() + }); + } else { + throw new Error(questionResponse.error || 'Failed to get answer'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Question answering failed'); + this.addConversationMessage('assistant', 'I apologize, but I encountered an error while processing your question. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideTypingIndicator(); + } + } + + /** + * Handle keyboard shortcuts for question input + */ + handleQuestionInputKeypress(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.handleQuestionSubmission(event); + } + } + + /** + * Handle content translation with language detection + */ + async handleContentTranslation(event) { + const operationId = 'content-translation'; + + try { + this.activeOperations.add(operationId); + this.showLoadingState('Translating content...'); + + const selectedText = await this.getSelectedText(); + const contentToTranslate = selectedText || this.currentPageContent?.mainTextContent; + + if (!contentToTranslate) { + throw new Error('No content selected for translation'); + } + + const translationOptions = this.getTranslationOptions(); + const translationResponse = await this.aiService.translateContent({ + content: contentToTranslate, + ...translationOptions + }); + + if (translationResponse.success) { + this.displayTranslationResults(translationResponse.data); + } else { + throw new Error(translationResponse.error || 'Translation failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Content translation failed'); + this.showErrorMessage('Translation failed. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideLoadingState(); + } + } + + /** + * Get summary options from UI controls + */ + getSummaryOptions() { + const summaryTypeElement = document.getElementById('summary-type-select'); + const summaryLengthElement = document.querySelector('input[name="summary-length"]:checked'); + + return { + summaryType: summaryTypeElement?.value || 'key-points', + targetLength: summaryLengthElement?.value || 'medium', + includeKeyInsights: true, + includeSentiment: false + }; + } + + /** + * Get translation options from UI controls + */ + getTranslationOptions() { + const targetLanguageElement = document.getElementById('target-language-select'); + const preserveFormattingElement = document.getElementById('preserve-formatting-checkbox'); + + return { + targetLanguage: targetLanguageElement?.value || 'auto-detect', + preserveFormatting: preserveFormattingElement?.checked || true, + includeOriginal: true + }; + } + + /** + * Display summary results in the UI + */ + displaySummaryResults(summaryData) { + const summaryContainer = document.getElementById('summary-results-container'); + + summaryContainer.innerHTML = this.uiRenderer.renderSummaryCard({ + summary: summaryData.summary, + keyPoints: summaryData.keyPoints, + processingStats: summaryData.processingStats, + provider: summaryData.provider + }); + + this.animateElementAppearance(summaryContainer); + } + + /** + * Add a message to the conversation display + */ + addConversationMessage(role, content) { + const conversationContainer = document.getElementById('conversation-container'); + const messageElement = this.uiRenderer.createConversationMessage(role, content); + + conversationContainer.appendChild(messageElement); + this.scrollToBottom(conversationContainer); + this.animateElementAppearance(messageElement); + } + + /** + * Show loading state with custom message + */ + showLoadingState(message = 'Processing...') { + const loadingOverlay = document.getElementById('loading-overlay'); + const loadingText = document.getElementById('loading-text'); + + loadingText.textContent = message; + loadingOverlay.classList.add('active'); + } + + /** + * Hide loading state + */ + hideLoadingState() { + const loadingOverlay = document.getElementById('loading-overlay'); + loadingOverlay.classList.remove('active'); + } + + /** + * Show success message to user + */ + showSuccessMessage(message) { + this.showStatusMessage(message, 'success'); + } + + /** + * Show warning message to user + */ + showWarningMessage(message) { + this.showStatusMessage(message, 'warning'); + } + + /** + * Show error message to user + */ + showErrorMessage(message) { + this.showStatusMessage(message, 'error'); + } + + /** + * Show status message with type + */ + showStatusMessage(message, type = 'info') { + const statusContainer = document.getElementById('status-container'); + const statusMessage = this.uiRenderer.createStatusMessage(message, type); + + statusContainer.appendChild(statusMessage); + + setTimeout(() => { + statusMessage.classList.add('fade-out'); + setTimeout(() => statusMessage.remove(), 300); + }, 3000); + } + + /** + * Generate unique request ID for tracking + */ + generateRequestId() { + return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Animate element appearance + */ + animateElementAppearance(element) { + element.style.opacity = '0'; + element.style.transform = 'translateY(10px)'; + + requestAnimationFrame(() => { + element.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + element.style.opacity = '1'; + element.style.transform = 'translateY(0)'; + }); + } + + /** + * Scroll element to bottom smoothly + */ + scrollToBottom(element) { + element.scrollTo({ + top: element.scrollHeight, + behavior: 'smooth' + }); + } + + /** + * Load user preferences from storage + */ + async loadUserPreferences() { + try { + const preferences = await this.storageManager.getUserPreferences(); + this.applyUserPreferences(preferences); + } catch (error) { + this.errorHandler.handleError(error, 'Failed to load user preferences'); + } + } + + /** + * Apply user preferences to the UI + */ + applyUserPreferences(preferences) { + if (preferences.theme) { + document.body.setAttribute('data-theme', preferences.theme); + } + + if (preferences.language) { + document.documentElement.setAttribute('lang', preferences.language); + } + } + + /** + * Cleanup resources when popup closes + */ + cleanup() { + this.eventManager.removeAllListeners(); + this.activeOperations.clear(); + } +} + +// Initialize popup when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new PopupManager()); +} else { + new PopupManager(); +} \ No newline at end of file From fd0ce7dea29ec77e33c236ca6066234d6bfab861 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:50:15 +0300 Subject: [PATCH 07/17] Add EventManager utility for robust event handling --- src/utils/event-manager.js | 230 +++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/utils/event-manager.js diff --git a/src/utils/event-manager.js b/src/utils/event-manager.js new file mode 100644 index 0000000..f859452 --- /dev/null +++ b/src/utils/event-manager.js @@ -0,0 +1,230 @@ +/** + * @fileoverview Event management utility for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Manages event listeners with automatic cleanup and error handling + * Provides a centralized way to handle DOM events safely + */ +export class EventManager { + constructor() { + this.activeListeners = new Map(); + this.listenerIdCounter = 0; + } + + /** + * Add event listener with error boundary and cleanup tracking + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type (click, change, etc.) + * @param {Function} handler - Event handler function + * @param {Object} options - Event listener options + * @returns {string} Listener ID for removal + */ + addEventListenerSafely(selector, eventType, handler, options = {}) { + try { + const element = typeof selector === 'string' + ? document.querySelector(selector) + : selector; + + if (!element) { + console.warn(`Element not found for selector: ${selector}`); + return null; + } + + const listenerId = `listener_${++this.listenerIdCounter}`; + const safeHandler = this.createSafeHandler(handler, selector, eventType); + + element.addEventListener(eventType, safeHandler, options); + + this.activeListeners.set(listenerId, { + element, + eventType, + handler: safeHandler, + originalHandler: handler, + selector, + options + }); + + return listenerId; + } catch (error) { + console.error(`Failed to add event listener for ${selector}:${eventType}`, error); + return null; + } + } + + /** + * Remove specific event listener by ID + * @param {string} listenerId - ID returned by addEventListenerSafely + */ + removeEventListener(listenerId) { + const listenerInfo = this.activeListeners.get(listenerId); + + if (listenerInfo) { + try { + listenerInfo.element.removeEventListener( + listenerInfo.eventType, + listenerInfo.handler, + listenerInfo.options + ); + this.activeListeners.delete(listenerId); + } catch (error) { + console.error(`Failed to remove event listener ${listenerId}`, error); + } + } + } + + /** + * Remove all event listeners managed by this instance + */ + removeAllListeners() { + for (const [listenerId] of this.activeListeners) { + this.removeEventListener(listenerId); + } + } + + /** + * Create a safe event handler with error boundary + * @param {Function} handler - Original handler function + * @param {string} selector - Element selector for debugging + * @param {string} eventType - Event type for debugging + * @returns {Function} Safe handler function + */ + createSafeHandler(handler, selector, eventType) { + return function safeEventHandler(event) { + try { + return handler.call(this, event); + } catch (error) { + console.error(`Event handler error for ${selector}:${eventType}`, error); + + // Prevent event from bubbling if handler fails + if (event && typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + }; + } + + /** + * Add delegated event listener for dynamic content + * @param {string} parentSelector - Parent element selector + * @param {string} childSelector - Child element selector to match + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + */ + addDelegatedListener(parentSelector, childSelector, eventType, handler) { + const delegatedHandler = (event) => { + if (event.target.matches(childSelector)) { + handler.call(event.target, event); + } + }; + + return this.addEventListenerSafely(parentSelector, eventType, delegatedHandler); + } + + /** + * Add one-time event listener that removes itself after execution + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + */ + addOneTimeListener(selector, eventType, handler) { + const oneTimeHandler = (event) => { + handler.call(this, event); + this.removeEventListener(listenerId); + }; + + const listenerId = this.addEventListenerSafely( + selector, + eventType, + oneTimeHandler, + { once: true } + ); + + return listenerId; + } + + /** + * Add throttled event listener to limit execution frequency + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + * @param {number} throttleMs - Throttle delay in milliseconds + */ + addThrottledListener(selector, eventType, handler, throttleMs = 100) { + let lastExecutionTime = 0; + let timeoutId = null; + + const throttledHandler = (event) => { + const now = Date.now(); + const timeSinceLastExecution = now - lastExecutionTime; + + if (timeSinceLastExecution >= throttleMs) { + lastExecutionTime = now; + handler.call(this, event); + } else { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + lastExecutionTime = Date.now(); + handler.call(this, event); + }, throttleMs - timeSinceLastExecution); + } + }; + + return this.addEventListenerSafely(selector, eventType, throttledHandler); + } + + /** + * Add debounced event listener to delay execution until activity stops + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + * @param {number} debounceMs - Debounce delay in milliseconds + */ + addDebouncedListener(selector, eventType, handler, debounceMs = 300) { + let timeoutId = null; + + const debouncedHandler = (event) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + handler.call(this, event); + }, debounceMs); + }; + + return this.addEventListenerSafely(selector, eventType, debouncedHandler); + } + + /** + * Get information about active listeners (for debugging) + * @returns {Object} Summary of active listeners + */ + getListenerSummary() { + const summary = { + totalListeners: this.activeListeners.size, + listenersByType: new Map(), + listenersBySelector: new Map() + }; + + for (const [id, info] of this.activeListeners) { + // Count by event type + const typeCount = summary.listenersByType.get(info.eventType) || 0; + summary.listenersByType.set(info.eventType, typeCount + 1); + + // Count by selector + const selectorCount = summary.listenersBySelector.get(info.selector) || 0; + summary.listenersBySelector.set(info.selector, selectorCount + 1); + } + + return summary; + } + + /** + * Clean up all resources + */ + destroy() { + this.removeAllListeners(); + this.activeListeners.clear(); + this.listenerIdCounter = 0; + } +} \ No newline at end of file From 989a82792caf0bb832c8d021a4fd6d53c2149132 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:51:32 +0300 Subject: [PATCH 08/17] Add enhanced ErrorHandler utility with comprehensive error management --- src/utils/error-handler.js | 392 +++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 src/utils/error-handler.js diff --git a/src/utils/error-handler.js b/src/utils/error-handler.js new file mode 100644 index 0000000..f9b3623 --- /dev/null +++ b/src/utils/error-handler.js @@ -0,0 +1,392 @@ +/** + * @fileoverview Advanced error handling utility for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Enhanced error handler with logging, reporting, and recovery strategies + */ +export class ErrorHandler { + constructor(contextName = 'Unknown') { + this.contextName = contextName; + this.errorCount = 0; + this.errorHistory = []; + this.maxHistorySize = 50; + this.rateLimitMap = new Map(); + this.setupGlobalErrorHandling(); + } + + /** + * Handle errors with context and recovery options + * @param {Error} error - The error object + * @param {string} operationName - Name of the failed operation + * @param {Object} context - Additional context information + * @param {Object} options - Error handling options + */ + handleError(error, operationName = 'Unknown Operation', context = {}, options = {}) { + try { + const errorInfo = this.createErrorInfo(error, operationName, context); + + // Apply rate limiting to prevent spam + if (this.isRateLimited(errorInfo.signature)) { + return; + } + + this.logError(errorInfo); + this.updateErrorHistory(errorInfo); + + if (options.showToUser !== false) { + this.displayUserFriendlyError(errorInfo); + } + + if (options.reportError) { + this.reportError(errorInfo); + } + + // Attempt recovery if strategy provided + if (options.recoveryStrategy) { + this.attemptRecovery(options.recoveryStrategy, errorInfo); + } + + this.errorCount++; + } catch (handlerError) { + console.error('Error handler failed:', handlerError); + } + } + + /** + * Handle critical errors that may require immediate attention + * @param {Error} error - The critical error + * @param {string} operationName - Name of the failed operation + * @param {Object} context - Additional context + */ + handleCriticalError(error, operationName = 'Critical Operation', context = {}) { + const errorInfo = this.createErrorInfo(error, operationName, { + ...context, + severity: 'critical', + timestamp: Date.now() + }); + + console.error(`CRITICAL ERROR in ${this.contextName}:`, errorInfo); + + // Always show critical errors to user + this.displayCriticalErrorMessage(errorInfo); + + // Report critical errors immediately + this.reportError(errorInfo); + + // Store in persistent storage for debugging + this.storeCriticalError(errorInfo); + } + + /** + * Create comprehensive error information object + * @param {Error} error - The error object + * @param {string} operationName - Operation name + * @param {Object} context - Additional context + * @returns {Object} Error information object + */ + createErrorInfo(error, operationName, context) { + const errorInfo = { + message: error.message || 'Unknown error occurred', + name: error.name || 'UnknownError', + stack: error.stack, + operationName, + contextName: this.contextName, + timestamp: Date.now(), + errorId: this.generateErrorId(), + signature: this.generateErrorSignature(error, operationName), + severity: context.severity || 'error', + userAgent: navigator.userAgent, + url: window.location?.href || 'extension-popup', + additionalContext: context + }; + + return errorInfo; + } + + /** + * Generate unique error ID for tracking + * @returns {string} Unique error ID + */ + generateErrorId() { + return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Generate error signature for deduplication + * @param {Error} error - The error object + * @param {string} operationName - Operation name + * @returns {string} Error signature + */ + generateErrorSignature(error, operationName) { + const message = error.message || ''; + const name = error.name || ''; + return `${this.contextName}:${operationName}:${name}:${message.slice(0, 100)}`; + } + + /** + * Check if error is rate limited + * @param {string} signature - Error signature + * @returns {boolean} True if rate limited + */ + isRateLimited(signature) { + const now = Date.now(); + const rateLimitWindow = 60000; // 1 minute + const maxErrorsPerWindow = 5; + + if (!this.rateLimitMap.has(signature)) { + this.rateLimitMap.set(signature, []); + } + + const errorTimes = this.rateLimitMap.get(signature); + + // Remove old entries outside the window + while (errorTimes.length > 0 && errorTimes[0] < now - rateLimitWindow) { + errorTimes.shift(); + } + + if (errorTimes.length >= maxErrorsPerWindow) { + return true; + } + + errorTimes.push(now); + return false; + } + + /** + * Log error to console with proper formatting + * @param {Object} errorInfo - Error information object + */ + logError(errorInfo) { + const logLevel = errorInfo.severity === 'critical' ? 'error' : 'warn'; + + console[logLevel](`[${this.contextName}] ${errorInfo.operationName} failed:`, { + message: errorInfo.message, + errorId: errorInfo.errorId, + timestamp: new Date(errorInfo.timestamp).toISOString(), + context: errorInfo.additionalContext + }); + + if (errorInfo.stack) { + console.groupCollapsed('Stack trace'); + console.error(errorInfo.stack); + console.groupEnd(); + } + } + + /** + * Update error history with size limit + * @param {Object} errorInfo - Error information object + */ + updateErrorHistory(errorInfo) { + this.errorHistory.push(errorInfo); + + if (this.errorHistory.length > this.maxHistorySize) { + this.errorHistory.shift(); + } + } + + /** + * Display user-friendly error message + * @param {Object} errorInfo - Error information object + */ + displayUserFriendlyError(errorInfo) { + const userMessage = this.createUserFriendlyMessage(errorInfo); + this.showNotification(userMessage, 'error'); + } + + /** + * Display critical error message to user + * @param {Object} errorInfo - Error information object + */ + displayCriticalErrorMessage(errorInfo) { + const message = `A critical error occurred in ${errorInfo.operationName}. Please refresh the extension and try again.`; + this.showNotification(message, 'critical'); + } + + /** + * Create user-friendly error message + * @param {Object} errorInfo - Error information object + * @returns {string} User-friendly message + */ + createUserFriendlyMessage(errorInfo) { + const messageMap = { + 'NetworkError': 'Network connection issue. Please check your internet connection.', + 'TypeError': 'An unexpected error occurred. Please try again.', + 'SecurityError': 'Security restriction encountered. Please check permissions.', + 'QuotaExceededError': 'Storage limit exceeded. Please clear some data.', + 'TimeoutError': 'Operation timed out. Please try again.', + }; + + return messageMap[errorInfo.name] || + `An error occurred in ${errorInfo.operationName}. Please try again.`; + } + + /** + * Show notification to user + * @param {string} message - Message to show + * @param {string} type - Notification type + */ + showNotification(message, type = 'error') { + // Try to use the browser's notification system + if (typeof chrome !== 'undefined' && chrome.notifications) { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'GenAI Browser Tool', + message: message + }); + } else { + // Fallback to console for testing environments + console.warn(`Notification (${type}): ${message}`); + } + } + + /** + * Report error to monitoring service (if configured) + * @param {Object} errorInfo - Error information object + */ + reportError(errorInfo) { + try { + // Store error for potential reporting + const reportData = { + errorId: errorInfo.errorId, + message: errorInfo.message, + context: errorInfo.contextName, + operation: errorInfo.operationName, + severity: errorInfo.severity, + timestamp: errorInfo.timestamp, + userAgent: errorInfo.userAgent.substring(0, 200), // Limit size + url: errorInfo.url + }; + + // Store in local storage for potential batch reporting + this.storeErrorReport(reportData); + } catch (reportError) { + console.error('Failed to report error:', reportError); + } + } + + /** + * Store error report in local storage + * @param {Object} reportData - Error report data + */ + storeErrorReport(reportData) { + try { + const storageKey = 'genai_error_reports'; + const existingReports = JSON.parse(localStorage.getItem(storageKey) || '[]'); + + existingReports.push(reportData); + + // Keep only last 20 reports + if (existingReports.length > 20) { + existingReports.splice(0, existingReports.length - 20); + } + + localStorage.setItem(storageKey, JSON.stringify(existingReports)); + } catch (storageError) { + console.error('Failed to store error report:', storageError); + } + } + + /** + * Store critical error in persistent storage + * @param {Object} errorInfo - Error information object + */ + storeCriticalError(errorInfo) { + try { + const storageKey = 'genai_critical_errors'; + const criticalErrors = JSON.parse(localStorage.getItem(storageKey) || '[]'); + + criticalErrors.push({ + errorId: errorInfo.errorId, + message: errorInfo.message, + context: errorInfo.contextName, + operation: errorInfo.operationName, + timestamp: errorInfo.timestamp + }); + + // Keep only last 5 critical errors + if (criticalErrors.length > 5) { + criticalErrors.shift(); + } + + localStorage.setItem(storageKey, JSON.stringify(criticalErrors)); + } catch (storageError) { + console.error('Failed to store critical error:', storageError); + } + } + + /** + * Attempt to recover from error using provided strategy + * @param {Function} recoveryStrategy - Recovery function + * @param {Object} errorInfo - Error information + */ + async attemptRecovery(recoveryStrategy, errorInfo) { + try { + console.log(`Attempting recovery for ${errorInfo.operationName}...`); + await recoveryStrategy(errorInfo); + console.log('Recovery successful'); + } catch (recoveryError) { + console.error('Recovery failed:', recoveryError); + } + } + + /** + * Setup global error handling + */ + setupGlobalErrorHandling() { + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + this.handleError( + new Error(event.message), + 'Global Error Handler', + { + filename: event.filename, + lineno: event.lineno, + colno: event.colno + } + ); + }); + + window.addEventListener('unhandledrejection', (event) => { + this.handleError( + new Error(event.reason?.message || 'Unhandled Promise Rejection'), + 'Unhandled Promise', + { reason: event.reason } + ); + }); + } + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getErrorStats() { + return { + totalErrors: this.errorCount, + historySize: this.errorHistory.length, + recentErrors: this.errorHistory.slice(-5), + contextName: this.contextName + }; + } + + /** + * Clear error history + */ + clearErrorHistory() { + this.errorHistory = []; + this.errorCount = 0; + this.rateLimitMap.clear(); + } + + /** + * Clean up resources + */ + destroy() { + this.clearErrorHistory(); + } +} \ No newline at end of file From 0ff13e15ec6bfbabfc6a8e233eaf5560cae9781b Mon Sep 17 00:00:00 2001 From: Aaron Sequeira <96731649+aaron-seq@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:52:30 +0300 Subject: [PATCH 09/17] Add ValidationService utility for comprehensive input validation --- src/utils/validation-service.js | 444 ++++++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 src/utils/validation-service.js diff --git a/src/utils/validation-service.js b/src/utils/validation-service.js new file mode 100644 index 0000000..2937e08 --- /dev/null +++ b/src/utils/validation-service.js @@ -0,0 +1,444 @@ +/** + * @fileoverview Validation service for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Comprehensive validation service for user inputs and system data + * Provides security-focused validation with sanitization capabilities + */ +export class ValidationService { + constructor() { + this.urlPatterns = { + http: /^https?:\/\//i, + web: /^https?:\/\/[^\s/$.?#].[^\s]*$/i, + extension: /^(chrome-extension|moz-extension):\/\//i + }; + + this.securityPatterns = { + script: /]*>.*?<\/script>/gi, + html: /<[^>]*>/g, + javascript: /javascript:/gi, + dataUrl: /^data:/i + }; + + this.maxLengths = { + question: 1000, + summary: 10000, + title: 200, + description: 500 + }; + } + + /** + * Validate if a tab is accessible for content extraction + * @param {Object} tab - Chrome tab object + * @returns {boolean} True if tab is valid and accessible + */ + isValidWebPage(tab) { + if (!tab || !tab.url) { + return false; + } + + // Check for valid HTTP/HTTPS URLs + if (!this.urlPatterns.web.test(tab.url)) { + return false; + } + + // Exclude restricted pages + const restrictedDomains = [ + 'chrome://', + 'chrome-extension://', + 'moz-extension://', + 'edge://', + 'about:', + 'file://' + ]; + + return !restrictedDomains.some(domain => tab.url.startsWith(domain)); + } + + /** + * Validate user question input + * @param {string} question - User question + * @returns {boolean} True if question is valid + */ + isValidQuestion(question) { + if (!question || typeof question !== 'string') { + return false; + } + + const trimmedQuestion = question.trim(); + + // Check length constraints + if (trimmedQuestion.length === 0 || trimmedQuestion.length > this.maxLengths.question) { + return false; + } + + // Check for potential security issues + if (this.containsMaliciousContent(trimmedQuestion)) { + return false; + } + + return true; + } + + /** + * Validate content for summarization + * @param {string} content - Content to validate + * @returns {Object} Validation result with details + */ + validateContentForSummarization(content) { + const result = { + isValid: false, + sanitizedContent: '', + warnings: [], + errors: [] + }; + + if (!content || typeof content !== 'string') { + result.errors.push('Content must be a non-empty string'); + return result; + } + + const trimmedContent = content.trim(); + + if (trimmedContent.length === 0) { + result.errors.push('Content cannot be empty'); + return result; + } + + if (trimmedContent.length > this.maxLengths.summary) { + result.warnings.push(`Content is long (${trimmedContent.length} chars). Processing may be slower.`); + } + + // Sanitize content + result.sanitizedContent = this.sanitizeContent(trimmedContent); + + if (result.sanitizedContent.length < trimmedContent.length * 0.5) { + result.warnings.push('Significant content was removed during sanitization'); + } + + result.isValid = result.sanitizedContent.length > 0; + return result; + } + + /** + * Validate API key format and basic security + * @param {string} apiKey - API key to validate + * @param {string} provider - AI provider name + * @returns {Object} Validation result + */ + validateApiKey(apiKey, provider = 'unknown') { + const result = { + isValid: false, + errors: [], + warnings: [] + }; + + if (!apiKey || typeof apiKey !== 'string') { + result.errors.push('API key must be a non-empty string'); + return result; + } + + const trimmedKey = apiKey.trim(); + + if (trimmedKey.length < 10) { + result.errors.push('API key appears to be too short'); + return result; + } + + // Provider-specific validation + const providerPatterns = { + openai: /^sk-[a-zA-Z0-9]{48,}$/, + anthropic: /^sk-ant-[a-zA-Z0-9\-_]{40,}$/, + google: /^[a-zA-Z0-9\-_]{20,}$/ + }; + + const pattern = providerPatterns[provider.toLowerCase()]; + if (pattern && !pattern.test(trimmedKey)) { + result.warnings.push(`API key format doesn't match expected pattern for ${provider}`); + } + + // Security checks + if (this.containsMaliciousContent(trimmedKey)) { + result.errors.push('API key contains suspicious characters'); + return result; + } + + result.isValid = true; + return result; + } + + /** + * Validate URL for safety and accessibility + * @param {string} url - URL to validate + * @returns {Object} Validation result + */ + validateUrl(url) { + const result = { + isValid: false, + sanitizedUrl: '', + protocol: '', + domain: '', + errors: [] + }; + + if (!url || typeof url !== 'string') { + result.errors.push('URL must be a non-empty string'); + return result; + } + + const trimmedUrl = url.trim(); + + try { + const urlObj = new URL(trimmedUrl); + + result.protocol = urlObj.protocol; + result.domain = urlObj.hostname; + result.sanitizedUrl = urlObj.toString(); + + // Check for allowed protocols + if (!['http:', 'https:'].includes(urlObj.protocol)) { + result.errors.push('Only HTTP and HTTPS URLs are allowed'); + return result; + } + + // Check for suspicious domains or IPs + if (this.isSuspiciousDomain(urlObj.hostname)) { + result.errors.push('Suspicious or blocked domain detected'); + return result; + } + + result.isValid = true; + } catch (error) { + result.errors.push('Invalid URL format'); + } + + return result; + } + + /** + * Validate file upload (if applicable) + * @param {File} file - File object to validate + * @returns {Object} Validation result + */ + validateFileUpload(file) { + const result = { + isValid: false, + errors: [], + warnings: [] + }; + + if (!file) { + result.errors.push('No file provided'); + return result; + } + + const allowedTypes = ['text/plain', 'text/html', 'text/markdown', 'application/json']; + const maxSize = 5 * 1024 * 1024; // 5MB + + if (!allowedTypes.includes(file.type)) { + result.errors.push(`File type '${file.type}' is not allowed`); + return result; + } + + if (file.size > maxSize) { + result.errors.push('File size exceeds 5MB limit'); + return result; + } + + if (file.size === 0) { + result.errors.push('File appears to be empty'); + return result; + } + + result.isValid = true; + return result; + } + + /** + * Sanitize content by removing potentially harmful elements + * @param {string} content - Content to sanitize + * @returns {string} Sanitized content + */ + sanitizeContent(content) { + if (typeof content !== 'string') { + return ''; + } + + let sanitized = content; + + // Remove script tags and content + sanitized = sanitized.replace(this.securityPatterns.script, ''); + + // Remove other HTML tags but keep content + sanitized = sanitized.replace(this.securityPatterns.html, ''); + + // Remove javascript: URLs + sanitized = sanitized.replace(this.securityPatterns.javascript, ''); + + // Decode HTML entities + sanitized = this.decodeHtmlEntities(sanitized); + + // Normalize whitespace + sanitized = sanitized.replace(/\s+/g, ' ').trim(); + + return sanitized; + } + + /** + * Check if content contains malicious patterns + * @param {string} content - Content to check + * @returns {boolean} True if malicious content detected + */ + containsMaliciousContent(content) { + if (typeof content !== 'string') { + return false; + } + + const maliciousPatterns = [ + this.securityPatterns.script, + this.securityPatterns.javascript, + /eval\s*\(/gi, + /document\.write/gi, + /innerHTML\s*=/gi, + /on\w+\s*=/gi, // Event handlers like onclick, onload + /