diff --git a/.gitignore b/.gitignore index 27fadb2..2fff0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Build artifacts +dist/ *.xpi *.zip diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0772557 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/README.md b/README.md index fb6bf43..594ee84 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,13 @@ Choose from **5 leading cloud AI providers** or run a **local Ollama** model: ```bash git clone https://github.com/Nigel1992/AutoSort-Plus.git cd AutoSort-Plus -zip -r autosortplus.xpi manifest.json background.js options.js options.html styles.css content.js icons/ +npm install +npm run build +npm run xpi ``` +Then load `autosortplus.xpi` as described in Option 1. +
**[📥 Download Latest Release](https://github.com/Nigel1992/AutoSort-Plus/releases) • [📖 View Changelog](#-changelog)** @@ -562,9 +566,9 @@ You can also manually label emails without AI analysis: Status -🔴 High +🟢 Done Detailed Logging - Debug mode with console output -📋 Planned +✅ Completed 🔴 High @@ -620,6 +624,67 @@ If you encounter any issues, please [open an issue on GitHub](https://github.com --- +## Development + +### Prerequisites + +- Node.js 18+ +- npm + +### Setup + +```bash +git clone https://github.com/Nigel1992/AutoSort-Plus.git +cd AutoSort-Plus +npm install +``` + +### Architecture + +``` +src/ +├── background/ Background script (AI, batch, folders, auto-sort) +├── options/ Options page UI +├── shared/ Shared utilities (logger, storage, i18n, tab-fetch) +├── types/ TypeScript type definitions +├── content.ts Content script +├── ollama.ts Ollama API client +├── workers/ Web Workers +└── popup/ Web-accessible popups +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `npm run build` | Build extension to `dist/` | +| `npm run watch` | Build and watch for changes | +| `npm run test` | Run tests | +| `npm run test:watch` | Run tests in watch mode | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Lint source code | +| `npm run format` | Check formatting | +| `npm run format:fix` | Fix formatting | +| `npm run typecheck` | TypeScript type checking | +| `npm run check` | Full check (typecheck + lint + test) | +| `npm run xpi` | Build + package as `.xpi` | + +### Load in Thunderbird + +1. `npm run build` +2. In Thunderbird, go to `about:debugging` → This Thunderbird → Load Temporary Add-on +3. Select `dist/manifest.json` + +### Tech Stack + +- TypeScript (strict mode) +- esbuild (bundler) +- Vitest (test framework) +- ESLint + Prettier (code quality) +- Thunderbird WebExtension APIs + +--- + ## 🙏 Contributing We welcome contributions! Here's how to help: diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..fdf9d78 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,549 @@ +{ + "extensionName": { + "message": "AutoSort+", + "description": "Extension name" + }, + "extensionDescription": { + "message": "Automatically sort and label your emails with custom rules using AI", + "description": "Extension description" + }, + "extensionDefaultTitle": { + "message": "AutoSort+ Settings", + "description": "Default title shown in browser action" + }, + "pageTitle": { + "message": "AutoSort+ Settings", + "description": "HTML page title" + }, + "pageHeading": { + "message": "AutoSort+ Settings", + "description": "Main page heading" + }, + "batchProcessingTitle": { + "message": "Batch Processing In Progress", + "description": "Batch processing status panel title" + }, + "batchPreparing": { + "message": "Preparing…", + "description": "Batch processing preparing text" + }, + "batchPause": { + "message": "⏸ Pause", + "description": "Batch pause button text" + }, + "batchResume": { + "message": "▶ Resume", + "description": "Batch resume button text" + }, + "batchCancel": { + "message": "⏹ Cancel", + "description": "Batch cancel button text" + }, + "aiSettingsTitle": { + "message": "🤖 AI Settings", + "description": "AI settings section header" + }, + "providerSelectionTitle": { + "message": "Provider Selection", + "description": "Provider selection subsection header" + }, + "aiProviderLabel": { + "message": "AI Provider:", + "description": "AI provider select label" + }, + "providerGemini": { + "message": "Google Gemini (Recommended)", + "description": "Gemini provider option" + }, + "providerOpenAI": { + "message": "OpenAI (ChatGPT)", + "description": "OpenAI provider option" + }, + "providerAnthropic": { + "message": "Anthropic Claude", + "description": "Anthropic provider option" + }, + "providerGroq": { + "message": "Groq (Fast & Free)", + "description": "Groq provider option" + }, + "providerMistral": { + "message": "Mistral AI", + "description": "Mistral provider option" + }, + "providerOllama": { + "message": "Ollama (Local LLM)", + "description": "Ollama provider option" + }, + "providerOpenAICompatible": { + "message": "OpenAI-Compatible (Custom Endpoint)", + "description": "OpenAI-Compatible provider option" + }, + "rateLimitWarningTitle": { + "message": "⚠️ Rate Limit Warning:", + "description": "Rate limit warning header" + }, + "rateLimitWarningText": { + "message": "Free API tiers are severely limited when processing emails. You may only process 5-20 emails before hitting rate limits. Paid plans ($5-20/month) are recommended for daily email processing.", + "description": "Rate limit warning text" + }, + "ollamaConfigTitle": { + "message": "🏠 Local Ollama Configuration", + "description": "Ollama configuration subsection header" + }, + "ollamaUrlLabel": { + "message": "Ollama Server URL:", + "description": "Ollama URL label" + }, + "ollamaUrlPlaceholder": { + "message": "http://localhost:11434", + "description": "Ollama URL placeholder" + }, + "ollamaCpuOnly": { + "message": "Force CPU-only mode (disable GPU acceleration)", + "description": "Ollama CPU-only checkbox" + }, + "ollamaModelLabel": { + "message": "Ollama Model:", + "description": "Ollama model select label" + }, + "ollamaAuthTokenLabel": { + "message": "Ollama Auth Token (optional):", + "description": "Ollama auth token label" + }, + "ollamaAuthTokenPlaceholder": { + "message": "If your Ollama server requires a token, enter it here", + "description": "Ollama auth token placeholder" + }, + "ollamaAuthTokenHelp": { + "message": "Used for /api/chat and /api/pull requests when required.", + "description": "Ollama auth token help text" + }, + "ollamaCustomModelPlaceholder": { + "message": "Enter custom model name", + "description": "Ollama custom model placeholder" + }, + "ollamaDownloadModelLabel": { + "message": "Download Model:", + "description": "Ollama download model label" + }, + "ollamaDownloadModelPlaceholder": { + "message": "e.g., llama3.2, mistral, qwen2.5:7b", + "description": "Ollama download model placeholder" + }, + "ollamaDownloadButton": { + "message": "Download", + "description": "Download model button" + }, + "ollamaListModelsButton": { + "message": "List Installed Models", + "description": "List installed models button" + }, + "ollamaTestButton": { + "message": "Test Connection", + "description": "Test Ollama connection button" + }, + "ollamaDiagnoseButton": { + "message": "Run Diagnostics", + "description": "Run Ollama diagnostics button" + }, + "ollamaModelLlama2": { + "message": "Llama 2", + "description": "Ollama Llama 2 model option" + }, + "ollamaModelLlama32": { + "message": "Llama 3.2", + "description": "Ollama Llama 3.2 model option" + }, + "ollamaModelMistral": { + "message": "Mistral", + "description": "Ollama Mistral model option" + }, + "ollamaModelPhi": { + "message": "Phi", + "description": "Ollama Phi model option" + }, + "ollamaModelGemma": { + "message": "Gemma", + "description": "Ollama Gemma model option" + }, + "ollamaModelQwen25": { + "message": "Qwen 2.5", + "description": "Ollama Qwen 2.5 model option" + }, + "ollamaModelCustom": { + "message": "Custom (enter below)", + "description": "Ollama custom model option" + }, + "openaiCompatibleTitle": { + "message": "🔗 OpenAI-Compatible Endpoint", + "description": "OpenAI-Compatible subsection header" + }, + "openaiCompatibleBaseUrlLabel": { + "message": "Base URL:", + "description": "OpenAI-Compatible base URL label" + }, + "openaiCompatibleBaseUrlPlaceholder": { + "message": "http://localhost:1234/v1 or https://api.provider.com/v1", + "description": "OpenAI-Compatible base URL placeholder" + }, + "openaiCompatibleBaseUrlHelp": { + "message": "Enter base URL including /v1. Endpoint must use OpenAI format: /v1/chat/completions. Examples: LM Studio, LocalAI, vLLM, Together AI", + "description": "OpenAI-Compatible base URL help text" + }, + "openaiCompatibleModelLabel": { + "message": "Model:", + "description": "OpenAI-Compatible model select label" + }, + "openaiCompatibleModelSelect": { + "message": "-- Select model --", + "description": "OpenAI-Compatible model select default option" + }, + "openaiCompatibleModelCustom": { + "message": "Custom (enter below)", + "description": "OpenAI-Compatible custom model option" + }, + "openaiCompatibleModelCustomPlaceholder": { + "message": "Enter model name manually", + "description": "OpenAI-Compatible custom model placeholder" + }, + "openaiCompatibleApiKeyLabel": { + "message": "API Key (optional):", + "description": "OpenAI-Compatible API key label" + }, + "openaiCompatibleApiKeyPlaceholder": { + "message": "Leave empty for local endpoints without auth", + "description": "OpenAI-Compatible API key placeholder" + }, + "openaiCompatibleApiKeyHelp": { + "message": "Required for cloud providers, optional for local servers", + "description": "OpenAI-Compatible API key help text" + }, + "openaiCompatibleFetchModelsButton": { + "message": "Fetch Models", + "description": "Fetch models button" + }, + "openaiCompatibleTestButton": { + "message": "Test Connection", + "description": "Test OpenAI-Compatible connection button" + }, + "apiKeyTitle": { + "message": "🔑 API Key Configuration", + "description": "API key configuration subsection header" + }, + "apiKeyLabel": { + "message": "API Key:", + "description": "API key input label" + }, + "apiKeyPlaceholder": { + "message": "Enter your API key", + "description": "API key input placeholder" + }, + "testApiButton": { + "message": "Test API Connection", + "description": "Test API connection button" + }, + "getApiKeyButton": { + "message": "Get API Key", + "description": "Get API key button" + }, + "geminiMultiKeysTitle": { + "message": "🔄 Multiple Gemini API Keys", + "description": "Multiple Gemini keys subsection header" + }, + "geminiMultiKeysInfo": { + "message": "Add multiple API keys from different Google Cloud projects. The extension will automatically rotate between them when rate limits are reached.", + "description": "Multiple Gemini keys info text" + }, + "addGeminiKeyButton": { + "message": "+ Add Another Gemini Key", + "description": "Add Gemini key button" + }, + "geminiPaidPlan": { + "message": "I have a Gemini paid plan (removes rate limits)", + "description": "Gemini paid plan checkbox" + }, + "generalSettingsTitle": { + "message": "⚙️ General Settings", + "description": "General settings subsection header" + }, + "enableAiLabel": { + "message": "Enable AI-powered sorting", + "description": "Enable AI checkbox" + }, + "enableDebugLabel": { + "message": "Enable debug mode (console logging)", + "description": "Enable debug mode checkbox" + }, + "enableDebugHelp": { + "message": "Open Thunderbird Developer Tools (Ctrl+Shift+I) to view logs", + "description": "Debug mode help text" + }, + "batchChunkSizeLabel": { + "message": "Batch chunk size:", + "description": "Batch chunk size label" + }, + "batchChunkSizeHelp": { + "message": "Process N emails at once, wait for all responses, then continue (1-20)", + "description": "Batch chunk size help text" + }, + "enableAutoSortLabel": { + "message": "Auto-sort new emails in Inbox", + "description": "Auto-sort checkbox label" + }, + "enableAutoSortHelp": { + "message": "Automatically classify and move new Inbox emails using AI", + "description": "Auto-sort help text" + }, + "geminiUsageTitle": { + "message": "📊 Gemini API Usage", + "description": "Gemini API usage subsection header" + }, + "geminiDailyCount": { + "message": "Today's Usage: {count}/20 requests", + "description": "Gemini daily usage count", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "geminiLastRequest": { + "message": "Last Request:", + "description": "Gemini last request label" + }, + "geminiNever": { + "message": "Never", + "description": "Never used text" + }, + "geminiResetTime": { + "message": "Daily Limit Resets:", + "description": "Gemini daily limit reset time label" + }, + "geminiStatus": { + "message": "Status:", + "description": "Gemini status label" + }, + "geminiStatusReady": { + "message": "Ready", + "description": "Gemini ready status" + }, + "resetGeminiCounterButton": { + "message": "Reset Counter (New API Key)", + "description": "Reset Gemini counter button" + }, + "refreshUsageButton": { + "message": "Refresh Usage", + "description": "Refresh usage button" + }, + "refreshAllUsageButton": { + "message": "Refresh All Usage", + "description": "Refresh all usage button" + }, + "howAiSortingTitle": { + "message": "ℹ️ How AI Sorting Works", + "description": "How AI sorting works subsection header" + }, + "howAiSortingDesc": { + "message": "AutoSort+ uses AI to analyze your emails and automatically sort them into categories/folders based on their content. The AI will:", + "description": "AI sorting description" + }, + "howAiSortingPoint1": { + "message": "Read and understand email content", + "description": "AI sorting capability 1" + }, + "howAiSortingPoint2": { + "message": "Identify key topics and themes", + "description": "AI sorting capability 2" + }, + "howAiSortingPoint3": { + "message": "Match emails to appropriate categories/folders", + "description": "AI sorting capability 3" + }, + "howAiSortingPoint4": { + "message": "Learn from your manual corrections to improve accuracy", + "description": "AI sorting capability 4" + }, + "customPromptTitle": { + "message": "📝 Custom Prompt", + "description": "Custom prompt section header" + }, + "customPromptInfo": { + "message": "Customize the prompt sent to AI for email classification.", + "description": "Custom prompt info text" + }, + "customPromptPlaceholders": { + "message": "Available placeholders:", + "description": "Custom prompt placeholders label" + }, + "customPromptPlaceholderLabel": { + "message": "Your folder/label list", + "description": "Labels placeholder description" + }, + "customPromptSubjectLabel": { + "message": "Email subject line", + "description": "Subject placeholder description" + }, + "customPromptAuthorLabel": { + "message": "Sender email address/name", + "description": "Author placeholder description" + }, + "customPromptAttachmentsLabel": { + "message": "Attachment filenames (comma-separated)", + "description": "Attachments placeholder description" + }, + "customPromptBodyLabel": { + "message": "Email body content (recommended)", + "description": "Body placeholder description" + }, + "customPromptEmailLabel": { + "message": "Email content (legacy, same as {body})", + "description": "Email placeholder description", + "placeholders": { + "body": { + "content": "$1" + } + } + }, + "customPromptTip": { + "message": "Tip: Use {subject} and {attachments} for better classification accuracy.", + "description": "Custom prompt tip", + "placeholders": { + "subject": { + "content": "$1" + }, + "attachments": { + "content": "$2" + } + } + }, + "customPromptTextareaPlaceholder": { + "message": "Enter your custom prompt...", + "description": "Custom prompt textarea placeholder" + }, + "resetPromptButton": { + "message": "Reset to Default", + "description": "Reset prompt button" + }, + "customFoldersTitle": { + "message": "📁 Custom Categories/Folders", + "description": "Custom categories/folders section header" + }, + "folderSourceTitle": { + "message": "Folder Source", + "description": "Folder source subsection header" + }, + "loadImapFoldersButton": { + "message": "Load Folders from Mail Account", + "description": "Load IMAP folders button" + }, + "folderLoadingText": { + "message": "Loading folders...", + "description": "Folder loading indicator text" + }, + "folderFoundText": { + "message": "Found {count} folders in your mail account. Would you like to use these?", + "description": "Folder found text", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "useImapFoldersButton": { + "message": "Use These Folders", + "description": "Use IMAP folders button" + }, + "useCustomFoldersButton": { + "message": "Use Custom Folders Instead", + "description": "Use custom folders button" + }, + "bulkImportLabel": { + "message": "Import Categories/Folders (one per line):", + "description": "Bulk import textarea label" + }, + "bulkImportPlaceholder": { + "message": "Enter categories/folders, one per line", + "description": "Bulk import textarea placeholder" + }, + "importButton": { + "message": "Import", + "description": "Import button" + }, + "addButton": { + "message": "Add", + "description": "Add button" + }, + "labelInputPlaceholder": { + "message": "Enter category/folder name", + "description": "Label input placeholder" + }, + "moveHistoryTitle": { + "message": "📜 Move History", + "description": "Move history section header" + }, + "clearHistoryButton": { + "message": "Clear History", + "description": "Clear history button" + }, + "refreshHistoryButton": { + "message": "Refresh", + "description": "Refresh history button" + }, + "historyHeaderTimestamp": { + "message": "Timestamp", + "description": "History table timestamp header" + }, + "historyHeaderSubject": { + "message": "Subject", + "description": "History table subject header" + }, + "historyHeaderStatus": { + "message": "Status", + "description": "History table status header" + }, + "historyHeaderDestination": { + "message": "Destination", + "description": "History table destination header" + }, + "saveSettingsButton": { + "message": "Save Settings", + "description": "Save settings button" + }, + "providerInfoGemini": { + "message": "✓ Free tier: 5 requests/minute, 20/day per API key (enforced by addon)
✓ Tip: Create multiple API keys in different projects, switch keys when limit reached
✓ Check usage: AI Studio Usage
✓ Best for: General use, multilingual support
✓ Models: Gemini 2.5 Flash
✓ Check \"paid plan\" option to remove limits", + "description": "Gemini provider info HTML" + }, + "providerInfoOpenai": { + "message": "✓ Free trial: $5 credit
✓ Best for: High accuracy, English content
✓ Models: GPT-4o-mini ($0.15/1M tokens)", + "description": "OpenAI provider info HTML" + }, + "providerInfoAnthropic": { + "message": "✓ Free tier: Limited requests
✓ Best for: Long emails, detailed analysis
✓ Models: Claude 3 Haiku", + "description": "Anthropic provider info HTML" + }, + "providerInfoGroq": { + "message": "✓ Free tier: 30 requests/minute
✓ Best for: Speed (fastest)
✓ Models: Llama 3.3 (Mixtral deprecated)", + "description": "Groq provider info HTML" + }, + "providerInfoMistral": { + "message": "✓ Free tier: Limited requests
✓ Best for: European users, GDPR compliance
✓ Models: Mistral Small", + "description": "Mistral provider info HTML" + }, + "providerInfoOllama": { + "message": "✓ 100% Free: Runs locally on your machine
✓ Privacy: No data sent to external servers
✓ No rate limits: Process unlimited emails
✓ Models: Llama 2/3, Mistral, Phi, Gemma, Qwen, and more
✓ Requires: Ollama installed and running locally
✓ Setup: Install Ollama, run \"ollama pull llama3.2\" to download a model", + "description": "Ollama provider info HTML" + }, + "providerInfoOpenaiCompatible": { + "message": "✓ Compatible with: LocalAI, LM Studio, vLLM, Together AI, OpenRouter, DeepSeek, Fireworks, etc.
✓ Enter your endpoint base URL and model name
✓ API key optional for local servers
✓ Uses standard /v1/chat/completions format", + "description": "OpenAI-Compatible provider info HTML" + }, + "freeBadge": { + "message": "FREE", + "description": "Free provider badge text" + }, + "paidBadge": { + "message": "PAID", + "description": "Paid provider badge text" + } +} diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json new file mode 100644 index 0000000..6b31af4 --- /dev/null +++ b/_locales/zh_CN/messages.json @@ -0,0 +1,549 @@ +{ + "extensionName": { + "message": "AutoSort+", + "description": "扩展名称" + }, + "extensionDescription": { + "message": "使用 AI 自动分类和标记您的邮件", + "description": "扩展描述" + }, + "extensionDefaultTitle": { + "message": "AutoSort+ 设置", + "description": "浏览器操作默认标题" + }, + "pageTitle": { + "message": "AutoSort+ 设置", + "description": "HTML 页面标题" + }, + "pageHeading": { + "message": "AutoSort+ 设置", + "description": "页面主标题" + }, + "batchProcessingTitle": { + "message": "批量处理进行中", + "description": "批量处理状态面板标题" + }, + "batchPreparing": { + "message": "准备中…", + "description": "批量处理准备中文字" + }, + "batchPause": { + "message": "⏸ 暂停", + "description": "批量暂停按钮文字" + }, + "batchResume": { + "message": "▶ 继续", + "description": "批量继续按钮文字" + }, + "batchCancel": { + "message": "⏹ 取消", + "description": "批量取消按钮文字" + }, + "aiSettingsTitle": { + "message": "🤖 AI 设置", + "description": "AI 设置区段标题" + }, + "providerSelectionTitle": { + "message": "选择提供商", + "description": "提供商选择子区段标题" + }, + "aiProviderLabel": { + "message": "AI 提供商:", + "description": "AI 提供商选择框标签" + }, + "providerGemini": { + "message": "Google Gemini(推荐)", + "description": "Gemini 提供商选项" + }, + "providerOpenAI": { + "message": "OpenAI (ChatGPT)", + "description": "OpenAI 提供商选项" + }, + "providerAnthropic": { + "message": "Anthropic Claude", + "description": "Anthropic 提供商选项" + }, + "providerGroq": { + "message": "Groq(快速且免费)", + "description": "Groq 提供商选项" + }, + "providerMistral": { + "message": "Mistral AI", + "description": "Mistral 提供商选项" + }, + "providerOllama": { + "message": "Ollama(本地大模型)", + "description": "Ollama 提供商选项" + }, + "providerOpenAICompatible": { + "message": "OpenAI 兼容(自定义端点)", + "description": "OpenAI 兼容提供商选项" + }, + "rateLimitWarningTitle": { + "message": "⚠️ 频率限制警告:", + "description": "频率限制警告标题" + }, + "rateLimitWarningText": { + "message": "免费 API 层在处理邮件时受到严格限制。您可能在处理 5-20 封邮件后就达到频率限制。建议购买付费计划($5-20/月)以满足日常邮件处理需求。", + "description": "频率限制警告正文" + }, + "ollamaConfigTitle": { + "message": "🏠 本地 Ollama 配置", + "description": "Ollama 配置子区段标题" + }, + "ollamaUrlLabel": { + "message": "Ollama 服务器地址:", + "description": "Ollama 地址标签" + }, + "ollamaUrlPlaceholder": { + "message": "http://localhost:11434", + "description": "Ollama 地址占位符" + }, + "ollamaCpuOnly": { + "message": "强制仅使用 CPU(禁用 GPU 加速)", + "description": "Ollama CPU 模式复选框" + }, + "ollamaModelLabel": { + "message": "Ollama 模型:", + "description": "Ollama 模型选择框标签" + }, + "ollamaAuthTokenLabel": { + "message": "Ollama 认证令牌(可选):", + "description": "Ollama 认证令牌标签" + }, + "ollamaAuthTokenPlaceholder": { + "message": "如果您的 Ollama 服务器需要令牌,请在此输入", + "description": "Ollama 认证令牌占位符" + }, + "ollamaAuthTokenHelp": { + "message": "在需要时用于 /api/chat 和 /api/pull 请求。", + "description": "Ollama 认证令牌帮助文本" + }, + "ollamaCustomModelPlaceholder": { + "message": "输入自定义模型名称", + "description": "Ollama 自定义模型占位符" + }, + "ollamaDownloadModelLabel": { + "message": "下载模型:", + "description": "Ollama 下载模型标签" + }, + "ollamaDownloadModelPlaceholder": { + "message": "例如:llama3.2, mistral, qwen2.5:7b", + "description": "Ollama 下载模型占位符" + }, + "ollamaDownloadButton": { + "message": "下载", + "description": "下载模型按钮" + }, + "ollamaListModelsButton": { + "message": "列出已安装模型", + "description": "列出已安装模型按钮" + }, + "ollamaTestButton": { + "message": "测试连接", + "description": "测试 Ollama 连接按钮" + }, + "ollamaDiagnoseButton": { + "message": "运行诊断", + "description": "运行 Ollama 诊断按钮" + }, + "ollamaModelLlama2": { + "message": "Llama 2", + "description": "Ollama Llama 2 模型选项" + }, + "ollamaModelLlama32": { + "message": "Llama 3.2", + "description": "Ollama Llama 3.2 模型选项" + }, + "ollamaModelMistral": { + "message": "Mistral", + "description": "Ollama Mistral 模型选项" + }, + "ollamaModelPhi": { + "message": "Phi", + "description": "Ollama Phi 模型选项" + }, + "ollamaModelGemma": { + "message": "Gemma", + "description": "Ollama Gemma 模型选项" + }, + "ollamaModelQwen25": { + "message": "Qwen 2.5", + "description": "Ollama Qwen 2.5 模型选项" + }, + "ollamaModelCustom": { + "message": "自定义(下方输入)", + "description": "Ollama 自定义模型选项" + }, + "openaiCompatibleTitle": { + "message": "🔗 OpenAI 兼容端点", + "description": "OpenAI 兼容子区段标题" + }, + "openaiCompatibleBaseUrlLabel": { + "message": "基础地址:", + "description": "OpenAI 兼容基础地址标签" + }, + "openaiCompatibleBaseUrlPlaceholder": { + "message": "http://localhost:1234/v1 或 https://api.provider.com/v1", + "description": "OpenAI 兼容基础地址占位符" + }, + "openaiCompatibleBaseUrlHelp": { + "message": "请输入包含 /v1 的基础地址。端点必须使用 OpenAI 格式:/v1/chat/completions。例如:LM Studio, LocalAI, vLLM, Together AI", + "description": "OpenAI 兼容基础地址帮助文本" + }, + "openaiCompatibleModelLabel": { + "message": "模型:", + "description": "OpenAI 兼容模型选择框标签" + }, + "openaiCompatibleModelSelect": { + "message": "-- 选择模型 --", + "description": "OpenAI 兼容模型默认选项" + }, + "openaiCompatibleModelCustom": { + "message": "自定义(下方输入)", + "description": "OpenAI 兼容自定义模型选项" + }, + "openaiCompatibleModelCustomPlaceholder": { + "message": "手动输入模型名称", + "description": "OpenAI 兼容自定义模型占位符" + }, + "openaiCompatibleApiKeyLabel": { + "message": "API 密钥(可选):", + "description": "OpenAI 兼容 API 密钥标签" + }, + "openaiCompatibleApiKeyPlaceholder": { + "message": "本地无认证端点可留空", + "description": "OpenAI 兼容 API 密钥占位符" + }, + "openaiCompatibleApiKeyHelp": { + "message": "云端提供商必需,本地服务器可选", + "description": "OpenAI 兼容 API 密钥帮助文本" + }, + "openaiCompatibleFetchModelsButton": { + "message": "获取模型列表", + "description": "获取模型列表按钮" + }, + "openaiCompatibleTestButton": { + "message": "测试连接", + "description": "测试 OpenAI 兼容连接按钮" + }, + "apiKeyTitle": { + "message": "🔑 API 密钥配置", + "description": "API 密钥配置子区段标题" + }, + "apiKeyLabel": { + "message": "API 密钥:", + "description": "API 密钥输入框标签" + }, + "apiKeyPlaceholder": { + "message": "输入您的 API 密钥", + "description": "API 密钥输入框占位符" + }, + "testApiButton": { + "message": "测试 API 连接", + "description": "测试 API 连接按钮" + }, + "getApiKeyButton": { + "message": "获取 API 密钥", + "description": "获取 API 密钥按钮" + }, + "geminiMultiKeysTitle": { + "message": "🔄 多个 Gemini API 密钥", + "description": "多个 Gemini 密钥子区段标题" + }, + "geminiMultiKeysInfo": { + "message": "添加来自不同 Google Cloud 项目的多个 API 密钥。当达到频率限制时,扩展将自动在密钥间切换。", + "description": "多个 Gemini 密钥说明文本" + }, + "addGeminiKeyButton": { + "message": "+ 添加另一个 Gemini 密钥", + "description": "添加 Gemini 密钥按钮" + }, + "geminiPaidPlan": { + "message": "我已购买 Gemini 付费计划(解除频率限制)", + "description": "Gemini 付费计划复选框" + }, + "generalSettingsTitle": { + "message": "⚙️ 常规设置", + "description": "常规设置子区段标题" + }, + "enableAiLabel": { + "message": "启用 AI 邮件分类", + "description": "启用 AI 复选框" + }, + "enableDebugLabel": { + "message": "启用调试模式(控制台日志)", + "description": "启用调试模式复选框" + }, + "enableDebugHelp": { + "message": "打开 Thunderbird 开发者工具(Ctrl+Shift+I)查看日志", + "description": "调试模式帮助文本" + }, + "batchChunkSizeLabel": { + "message": "批量处理数量:", + "description": "批量处理数量标签" + }, + "batchChunkSizeHelp": { + "message": "每次处理 N 封邮件,等待所有响应后继续(1-20)", + "description": "批量处理数量帮助文本" + }, + "enableAutoSortLabel": { + "message": "自动分类收件箱新邮件", + "description": "自动分类复选框标签" + }, + "enableAutoSortHelp": { + "message": "使用 AI 自动分类和移动新收件箱邮件", + "description": "自动分类帮助文本" + }, + "geminiUsageTitle": { + "message": "📊 Gemini API 使用情况", + "description": "Gemini API 使用情况子区段标题" + }, + "geminiDailyCount": { + "message": "今日使用:{count}/20 次请求", + "description": "Gemini 每日使用统计", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "geminiLastRequest": { + "message": "最后请求:", + "description": "Gemini 最后请求标签" + }, + "geminiNever": { + "message": "从未", + "description": "从未使用文字" + }, + "geminiResetTime": { + "message": "每日限制重置:", + "description": "Gemini 每日限制重置时间标签" + }, + "geminiStatus": { + "message": "状态:", + "description": "Gemini 状态标签" + }, + "geminiStatusReady": { + "message": "就绪", + "description": "Gemini 就绪状态" + }, + "resetGeminiCounterButton": { + "message": "重置计数器(新 API 密钥)", + "description": "重置 Gemini 计数器按钮" + }, + "refreshUsageButton": { + "message": "刷新使用情况", + "description": "刷新使用情况按钮" + }, + "refreshAllUsageButton": { + "message": "刷新全部使用情况", + "description": "刷新全部使用情况按钮" + }, + "howAiSortingTitle": { + "message": "ℹ️ AI 分类工作原理", + "description": "AI 分类工作原理子区段标题" + }, + "howAiSortingDesc": { + "message": "AutoSort+ 使用 AI 分析您的邮件,并根据内容自动将其分类到类别/文件夹中。AI 将:", + "description": "AI 分类描述" + }, + "howAiSortingPoint1": { + "message": "阅读并理解邮件内容", + "description": "AI 分类能力 1" + }, + "howAiSortingPoint2": { + "message": "识别关键主题和主题线索", + "description": "AI 分类能力 2" + }, + "howAiSortingPoint3": { + "message": "将邮件匹配到合适的类别/文件夹", + "description": "AI 分类能力 3" + }, + "howAiSortingPoint4": { + "message": "从您的手动修正中学习,提高准确率", + "description": "AI 分类能力 4" + }, + "customPromptTitle": { + "message": "📝 自定义提示词", + "description": "自定义提示词区段标题" + }, + "customPromptInfo": { + "message": "自定义发送给 AI 的邮件分类提示词。", + "description": "自定义提示词说明" + }, + "customPromptPlaceholders": { + "message": "可用占位符:", + "description": "自定义提示词占位符标签" + }, + "customPromptPlaceholderLabel": { + "message": "您的文件夹/类别列表", + "description": "labels 占位符描述" + }, + "customPromptSubjectLabel": { + "message": "邮件主题行", + "description": "subject 占位符描述" + }, + "customPromptAuthorLabel": { + "message": "发件人邮箱地址/名称", + "description": "author 占位符描述" + }, + "customPromptAttachmentsLabel": { + "message": "附件文件名(逗号分隔)", + "description": "attachments 占位符描述" + }, + "customPromptBodyLabel": { + "message": "邮件正文内容(推荐)", + "description": "body 占位符描述" + }, + "customPromptEmailLabel": { + "message": "邮件内容(旧版,同 {body})", + "description": "email 占位符描述", + "placeholders": { + "body": { + "content": "$1" + } + } + }, + "customPromptTip": { + "message": "提示:使用 {subject} 和 {attachments} 可提高分类准确率。", + "description": "自定义提示词提示", + "placeholders": { + "subject": { + "content": "$1" + }, + "attachments": { + "content": "$2" + } + } + }, + "customPromptTextareaPlaceholder": { + "message": "输入您的自定义提示词...", + "description": "自定义提示词文本框占位符" + }, + "resetPromptButton": { + "message": "恢复默认", + "description": "重置提示词按钮" + }, + "customFoldersTitle": { + "message": "📁 自定义类别/文件夹", + "description": "自定义类别/文件夹区段标题" + }, + "folderSourceTitle": { + "message": "文件夹来源", + "description": "文件夹来源子区段标题" + }, + "loadImapFoldersButton": { + "message": "从邮件账户加载文件夹", + "description": "加载 IMAP 文件夹按钮" + }, + "folderLoadingText": { + "message": "正在加载文件夹...", + "description": "文件夹加载提示文字" + }, + "folderFoundText": { + "message": "在您的邮件账户中找到 {count} 个文件夹。要使用这些文件夹吗?", + "description": "找到文件夹提示文字", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "useImapFoldersButton": { + "message": "使用这些文件夹", + "description": "使用 IMAP 文件夹按钮" + }, + "useCustomFoldersButton": { + "message": "改用自定义文件夹", + "description": "使用自定义文件夹按钮" + }, + "bulkImportLabel": { + "message": "导入类别/文件夹(每行一个):", + "description": "批量导入文本框标签" + }, + "bulkImportPlaceholder": { + "message": "每行输入一个类别/文件夹", + "description": "批量导入文本框占位符" + }, + "importButton": { + "message": "导入", + "description": "导入按钮" + }, + "addButton": { + "message": "添加", + "description": "添加按钮" + }, + "labelInputPlaceholder": { + "message": "输入类别/文件夹名称", + "description": "标签输入框占位符" + }, + "moveHistoryTitle": { + "message": "📜 移动历史", + "description": "移动历史区段标题" + }, + "clearHistoryButton": { + "message": "清除历史", + "description": "清除历史按钮" + }, + "refreshHistoryButton": { + "message": "刷新", + "description": "刷新历史按钮" + }, + "historyHeaderTimestamp": { + "message": "时间", + "description": "历史表格时间列标题" + }, + "historyHeaderSubject": { + "message": "主题", + "description": "历史表格主题列标题" + }, + "historyHeaderStatus": { + "message": "状态", + "description": "历史表格状态列标题" + }, + "historyHeaderDestination": { + "message": "目标", + "description": "历史表格目标列标题" + }, + "saveSettingsButton": { + "message": "保存设置", + "description": "保存设置按钮" + }, + "providerInfoGemini": { + "message": "✓ 免费额度:每分钟 5 次请求,每天 20 次/API 密钥(扩展强制执行)
✓ 提示:在不同项目中创建多个 API 密钥,达到限制时切换密钥
✓ 查看使用情况:AI Studio 使用情况
✓ 适合:日常使用、多语言支持
✓ 模型:Gemini 2.5 Flash
✓ 勾选\"付费计划\"可解除限制", + "description": "Gemini 提供商信息 HTML" + }, + "providerInfoOpenai": { + "message": "✓ 免费试用:$5 额度
✓ 适合:高准确率、英文内容
✓ 模型:GPT-4o-mini($0.15/1M tokens)", + "description": "OpenAI 提供商信息 HTML" + }, + "providerInfoAnthropic": { + "message": "✓ 免费额度:有限请求
✓ 适合:长邮件、详细分析
✓ 模型:Claude 3 Haiku", + "description": "Anthropic 提供商信息 HTML" + }, + "providerInfoGroq": { + "message": "✓ 免费额度:每分钟 30 次请求
✓ 适合:速度(最快)
✓ 模型:Llama 3.3(Mixtral 已弃用)", + "description": "Groq 提供商信息 HTML" + }, + "providerInfoMistral": { + "message": "✓ 免费额度:有限请求
✓ 适合:欧洲用户、GDPR 合规
✓ 模型:Mistral Small", + "description": "Mistral 提供商信息 HTML" + }, + "providerInfoOllama": { + "message": "✓ 100% 免费:在本地运行
✓ 隐私:数据不发送到外部服务器
✓ 无频率限制:无限制处理邮件
✓ 模型:Llama 2/3、Mistral、Phi、Gemma、Qwen 等
✓ 需要:安装 Ollama 并本地运行
✓ 设置:安装 Ollama 后运行 \"ollama pull llama3.2\" 下载模型", + "description": "Ollama 提供商信息 HTML" + }, + "providerInfoOpenaiCompatible": { + "message": "✓ 兼容:LocalAI、LM Studio、vLLM、Together AI、OpenRouter、DeepSeek、Fireworks 等
✓ 输入端点基础地址和模型名称
✓ 本地服务器可选 API 密钥
✓ 使用标准 /v1/chat/completions 格式", + "description": "OpenAI 兼容提供商信息 HTML" + }, + "freeBadge": { + "message": "免费", + "description": "免费提供商徽章文本" + }, + "paidBadge": { + "message": "付费", + "description": "付费提供商徽章文本" + } +} diff --git a/api_ollama/index.html b/api_ollama/index.html index de9afef..61ab74c 100644 --- a/api_ollama/index.html +++ b/api_ollama/index.html @@ -45,6 +45,6 @@

AutoSort+ - Ollama Chat

Initializing...
- + diff --git a/api_ollama/ollama-popup.js b/api_ollama/ollama-popup.js deleted file mode 100644 index 7fe31da..0000000 --- a/api_ollama/ollama-popup.js +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Ollama Popup for AutoSort+ - * Makes direct POST requests to Ollama from browser context (no restrictions) - * Popup = browser context = POST works - * Background script = restricted context = POST fails with 403 - */ - -let statusEl = null; -let messagesEl = null; -let responseEl = null; -let analysisResult = null; - -// Get URL parameters -const urlParams = new URLSearchParams(window.location.search); -const callId = urlParams.get('call_id'); - -// Initialize UI -document.addEventListener('DOMContentLoaded', async () => { - statusEl = document.getElementById('status'); - messagesEl = document.getElementById('messages'); - responseEl = document.getElementById('response'); - - statusEl.textContent = 'Ready'; - - // Tell background script that we're ready - browser.runtime.sendMessage({ - command: "ollama_popup_ready_" + callId, - window_id: (await browser.windows.getCurrent()).id - }).catch(err => console.log('Ready message error (expected):', err.message)); -}); - -// Handle messages from background script -browser.runtime.onMessage.addListener((message, sender, sendResponse) => { - switch (message.command) { - case "ollama_analyze": - handleOllamaAnalyze(message); - break; - case 'ollama_error': - statusEl.textContent = 'Error: ' + message.error; - responseEl.textContent = message.error; - analysisResult = null; - sendResultToBackground(); - break; - default: - console.log('Unknown command:', message.command); - } -}); - -async function handleOllamaAnalyze(message) { - const { ollama_host, ollama_model, ollama_num_ctx, ollama_auth_token, prompt } = message; - - try { - statusEl.textContent = 'Connecting to Ollama...'; - responseEl.textContent = ''; - analysisResult = null; - - // Add user message to display - const userMsgEl = document.createElement('div'); - userMsgEl.className = 'message user-message'; - userMsgEl.textContent = 'Analyzing: ' + prompt.substring(0, 100) + '...'; - messagesEl.appendChild(userMsgEl); - - statusEl.textContent = 'Processing with Ollama...'; - - // Make direct POST request from browser context (no restrictions!) - const headers = { - 'Content-Type': 'application/json' - }; - if (ollama_auth_token) { - headers['Authorization'] = `Bearer ${ollama_auth_token}`; - } - - const requestBody = { - model: ollama_model, - messages: [{ role: 'user', content: prompt }], - stream: false - }; - - if (ollama_num_ctx > 0) { - requestBody.options = { num_ctx: parseInt(ollama_num_ctx) }; - } - - console.log('[Ollama Popup] Sending POST to:', ollama_host + '/api/chat'); - console.log('[Ollama Popup] Model:', ollama_model); - console.log('[Ollama Popup] Request body:', JSON.stringify(requestBody).substring(0, 200)); - - const response = await fetch(ollama_host + '/api/chat', { - method: 'POST', - headers, - body: JSON.stringify(requestBody), - mode: 'cors', - credentials: 'omit' - }); - - console.log('[Ollama Popup] Response status:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[Ollama Popup] Error response:', errorText); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - console.log('[Ollama Popup] Response data:', JSON.stringify(data).substring(0, 300)); - - // Extract the response content - if (data.message && data.message.content) { - analysisResult = data.message.content; - responseEl.textContent = analysisResult; - statusEl.textContent = 'Analysis complete ✓'; - } else { - throw new Error('Invalid response format: missing message.content'); - } - - // Send result back to background - setTimeout(sendResultToBackground, 1000); - - } catch (error) { - console.error('[Ollama Popup] Error:', error); - statusEl.textContent = 'Error: ' + error.message; - responseEl.textContent = 'Error: ' + error.message; - analysisResult = null; - sendResultToBackground(); - } -} - -function sendResultToBackground() { - // Send result back to background script - browser.runtime.sendMessage({ - command: 'ollama_analysis_result_' + callId, - result: analysisResult, - error: analysisResult === null ? 'Analysis failed' : null - }).catch(err => console.log('Result message error:', err.message)); -} diff --git a/autosortplus.xpi b/autosortplus.xpi deleted file mode 100644 index 18bc69d..0000000 Binary files a/autosortplus.xpi and /dev/null differ diff --git a/background.js b/background.js deleted file mode 100644 index 1228a21..0000000 --- a/background.js +++ /dev/null @@ -1,1289 +0,0 @@ -// Listen for messages from the options page -browser.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.action === "applyLabels") { - applyLabelsToMessages(message.messages, message.label); - } else if (message.action === "analyzeEmail") { - analyzeEmailContent(message.emailContent).then(label => { - sendResponse({ label: label }); - }); - return true; // Required for async response - } else if (message.action === 'startOllamaPull') { - (async () => { - try { - const { ollamaUrl, model, headers } = message; - const { response } = await callOllamaViaTab(ollamaUrl, { - action: 'ollamaFetch', - fetchAction: 'pull', - model, - headers - }); - sendResponse(response || { ok: true }); - } catch (e) { - sendResponse({ ok: false, error: e.message }); - } - })(); - return true; - } -}); - -// Click handler for browser action icon - opens settings -browser.browserAction.onClicked.addListener(() => { - browser.runtime.openOptionsPage(); -}); - -// Ollama handling using tab injection (runs fetch in browser context) - -async function ollamaChatViaTab(ollamaUrl, model, prompt, authToken) { - // Open a hidden tab at localhost to make the fetch (browser context, not restricted) - const tab = await browser.tabs.create({ url: ollamaUrl, active: false }); - - // Wait for tab to load - await new Promise(resolve => setTimeout(resolve, 500)); - - try { - // Build the request headers - const headers = { 'Content-Type': 'application/json' }; - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - - // Inject code to make the fetch and store result - const scriptCode = ` - (async () => { - try { - const headers = ${JSON.stringify(headers)}; - const response = await fetch(window.location.origin + '/api/chat', { - method: 'POST', - headers, - body: JSON.stringify({ - model: ${JSON.stringify(model)}, - messages: [{ role: 'user', content: ${JSON.stringify(prompt)} }], - stream: false - }) - }); - - if (!response.ok) { - throw new Error('HTTP ' + response.status + ': ' + response.statusText); - } - - const data = await response.json(); - window.__ollama_result = { ok: true, data }; - } catch (error) { - window.__ollama_result = { ok: false, error: error.message }; - } - })(); - `; - - await browser.tabs.executeScript(tab.id, { code: scriptCode }); - - // Wait for result (with polling to be safe) - let result = null; - for (let i = 0; i < 60; i++) { // 30 seconds max - await new Promise(resolve => setTimeout(resolve, 500)); - - try { - const results = await browser.tabs.executeScript(tab.id, { - code: 'window.__ollama_result || null' - }); - if (results && results[0]) { - result = results[0]; - break; - } - } catch (e) { - // Tab might be closing - break; - } - } - - if (!result) { - throw new Error('Ollama request timed out (30s) - no response from API'); - } - - if (!result.ok) { - throw new Error(result.error || 'Ollama API error'); - } - - return result.data; - - } finally { - // Close the tab - try { await browser.tabs.remove(tab.id); } catch (e) {} - } -} - -async function callOllamaViaTab(ollamaUrl, payload) { - // Deprecated function kept for backward compatibility - // Now routes to direct API call via fetch - const { fetchAction, model, prompt, headers } = payload; - - if (fetchAction === 'chat') { - // For direct chat, we make a simple fetch call - const ollamaHeaders = Object.assign({}, headers, { 'Content-Type': 'application/json' }); - - try { - const res = await fetch(`${ollamaUrl}/api/chat`, { - method: 'POST', - headers: ollamaHeaders, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - stream: false - }) - }); - - if (!res.ok) { - return { - correlationId: '', - response: { ok: false, error: `HTTP ${res.status}: ${res.statusText}` } - }; - } - - const data = await res.json(); - return { correlationId: '', response: { ok: true, data } }; - } catch (err) { - return { - correlationId: '', - response: { ok: false, error: err.message } - }; - } - } else if (fetchAction === 'pull') { - // For pull operations - const ollamaHeaders = Object.assign({}, headers, { 'Content-Type': 'application/json' }); - - try { - const res = await fetch(`${ollamaUrl}/api/pull`, { - method: 'POST', - headers: ollamaHeaders, - body: JSON.stringify({ name: model, stream: true }) - }); - - const text = await res.text(); - return { correlationId: '', response: { ok: true, data: text } }; - } catch (err) { - return { correlationId: '', response: { ok: false, error: err.message } }; - } - } -} - -// Gemini rate limiting functions (free tier: 5/min, 20/day per key) -async function checkGeminiRateLimit() { - const now = Date.now(); - const data = await browser.storage.local.get([ - 'geminiApiKeys', - 'geminiRateLimits', - 'currentGeminiKeyIndex', - 'geminiPaidPlan', - 'geminiRateLimit' // Legacy single-key support - ]); - - // Handle paid plan - no limits - if (data.geminiPaidPlan) { - return { allowed: true, waitTime: 0 }; - } - - // Multi-key mode - if (data.geminiApiKeys && data.geminiApiKeys.length > 0) { - const keys = data.geminiApiKeys; - const rateLimits = data.geminiRateLimits || keys.map(() => ({ - requests: [], - dailyCount: 0, - dailyResetTime: now + (24 * 60 * 60 * 1000) - })); - let currentIndex = data.currentGeminiKeyIndex || 0; - - // Try to find an available key - const startIndex = currentIndex; - let attempts = 0; - - while (attempts < keys.length) { - const rateLimit = rateLimits[currentIndex]; - - // Reset daily count if it's a new day - if (now > rateLimit.dailyResetTime) { - rateLimit.dailyCount = 0; - rateLimit.dailyResetTime = now + (24 * 60 * 60 * 1000); - rateLimit.requests = []; - } - - // Remove requests older than 1 minute - const oneMinuteAgo = now - 60000; - rateLimit.requests = rateLimit.requests.filter(time => time > oneMinuteAgo); - - // Check if this key is available - if (rateLimit.dailyCount < 20) { - // Check if we need to wait - if (rateLimit.requests.length > 0) { - const lastRequest = Math.max(...rateLimit.requests); - const timeSinceLastRequest = now - lastRequest; - const minInterval = 12000; // 12 seconds - - if (timeSinceLastRequest < minInterval) { - const waitTime = Math.ceil((minInterval - timeSinceLastRequest) / 1000); - return { - allowed: true, - waitTime: waitTime, - keyIndex: currentIndex - }; - } - } - - // This key is ready to use - await browser.storage.local.set({ - currentGeminiKeyIndex: currentIndex, - geminiRateLimits: rateLimits - }); - - return { - allowed: true, - waitTime: 0, - keyIndex: currentIndex - }; - } - - // This key has reached its limit, try next one - currentIndex = (currentIndex + 1) % keys.length; - attempts++; - } - - // All keys have reached their limits - return { - allowed: false, - message: `All ${keys.length} Gemini API keys have reached their daily limit (20/day each). Please wait for reset or add more API keys in settings.` - }; - } - - // Legacy single-key mode (backward compatibility) - const rateLimit = data.geminiRateLimit || { requests: [], dailyCount: 0, dailyResetTime: now }; - - // Reset daily count if it's a new day - if (now > rateLimit.dailyResetTime) { - rateLimit.dailyCount = 0; - rateLimit.dailyResetTime = now + (24 * 60 * 60 * 1000); - } - - // Check daily limit (20 per day) - if (rateLimit.dailyCount >= 20) { - const hoursUntilReset = Math.ceil((rateLimit.dailyResetTime - now) / (1000 * 60 * 60)); - return { - allowed: false, - message: `Gemini free tier daily limit reached (20/day). Resets in ${hoursUntilReset} hours. Upgrade to paid plan or add multiple API keys in settings to remove limits.` - }; - } - - // Remove requests older than 1 minute - const oneMinuteAgo = now - 60000; - rateLimit.requests = rateLimit.requests.filter(time => time > oneMinuteAgo); - - // Check if we need to wait (12 seconds between requests = 5 per minute) - if (rateLimit.requests.length > 0) { - const lastRequest = Math.max(...rateLimit.requests); - const timeSinceLastRequest = now - lastRequest; - const minInterval = 12000; // 12 seconds - - if (timeSinceLastRequest < minInterval) { - const waitTime = Math.ceil((minInterval - timeSinceLastRequest) / 1000); - return { - allowed: true, - waitTime: waitTime - }; - } - } - - return { - allowed: true, - waitTime: 0 - }; -} - -async function trackGeminiRequest(keyIndex = null) { - const now = Date.now(); - const data = await browser.storage.local.get([ - 'geminiApiKeys', - 'geminiRateLimits', - 'currentGeminiKeyIndex', - 'geminiRateLimit' // Legacy - ]); - - // Multi-key mode - if (data.geminiApiKeys && data.geminiApiKeys.length > 0 && keyIndex !== null) { - const rateLimits = data.geminiRateLimits || data.geminiApiKeys.map(() => ({ - requests: [], - dailyCount: 0, - dailyResetTime: now + (24 * 60 * 60 * 1000) - })); - - const rateLimit = rateLimits[keyIndex]; - - // Add current request - rateLimit.requests.push(now); - rateLimit.dailyCount += 1; - - // Clean old requests - const oneMinuteAgo = now - 60000; - rateLimit.requests = rateLimit.requests.filter(time => time > oneMinuteAgo); - - await browser.storage.local.set({ geminiRateLimits: rateLimits }); - - console.log(`Gemini Key #${keyIndex + 1}: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute`); - } else { - // Legacy single-key mode - const rateLimit = data.geminiRateLimit || { requests: [], dailyCount: 0, dailyResetTime: now + (24 * 60 * 60 * 1000) }; - - // Add current request - rateLimit.requests.push(now); - rateLimit.dailyCount += 1; - - // Clean old requests - const oneMinuteAgo = now - 60000; - rateLimit.requests = rateLimit.requests.filter(time => time > oneMinuteAgo); - - await browser.storage.local.set({ geminiRateLimit: rateLimit }); - - console.log(`Gemini requests: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute`); - } -} - -// Function to show notification -async function showNotification(title, message, type = "basic") { - // Log to console (Thunderbird doesn't support browser.notifications) - console.log(`[AutoSort+] ${title}: ${message}`); - - // Try to show notification if API is available - try { - if (browser.notifications && browser.notifications.create) { - const id = `autosort-${Date.now()}`; - await browser.notifications.create(id, { - type: type, - iconUrl: browser.runtime.getURL("icons/icon-48.png"), - title: title, - message: message, - eventTime: Date.now(), - priority: 2, - requireInteraction: true - }); - return id; - } - } catch (error) { - // Silently fail - notifications not supported - } - return null; -} - -// Function to update existing notification -async function updateNotification(id, title, message) { - // Log to console - console.log(`[AutoSort+] ${title}: ${message}`); - - // Try to update notification if API is available - try { - if (browser.notifications && browser.notifications.clear && id) { - await browser.notifications.clear(id); - } - } catch (error) { - // Silently fail - notifications not supported - } - return await showNotification(title, message); -} - -// Function to analyze email content using AI -async function analyzeEmailContent(emailContent) { - try { - const notificationId = await showNotification( - "AutoSort+ AI Analysis", - "Starting email analysis..." - ); - - const settings = await browser.storage.local.get([ - 'apiKey', - 'geminiApiKeys', - 'currentGeminiKeyIndex', - 'aiProvider', - 'labels', - 'enableAi', - 'geminiPaidPlan', - 'geminiRateLimit', - 'geminiRateLimits' - ]); - const provider = settings.aiProvider || 'gemini'; - - // Check Gemini rate limits (free tier only) - let keyIndexToUse = null; - if (provider === 'gemini' && !settings.geminiPaidPlan) { - const rateLimitCheck = await checkGeminiRateLimit(); - if (!rateLimitCheck.allowed) { - // Show persistent notification for limit reached - const isSingleKey = !settings.geminiApiKeys || settings.geminiApiKeys.length <= 1; - const notifTitle = isSingleKey ? "⛔ Gemini API Limit Reached" : "⛔ All Gemini Keys at Limit"; - - const notifId = await showNotification( - notifTitle, - rateLimitCheck.message, - "list" - ); - - // Also try to update the current notification - await updateNotification( - notificationId, - "AutoSort+ Rate Limit", - rateLimitCheck.message - ); - throw new Error(rateLimitCheck.message); - } - - if (rateLimitCheck.waitTime > 0) { - await updateNotification( - notificationId, - "AutoSort+ Rate Limit", - `Rate limit reached. Waiting ${rateLimitCheck.waitTime} seconds...` - ); await new Promise(resolve => setTimeout(resolve, rateLimitCheck.waitTime * 1000)); - } - - keyIndexToUse = rateLimitCheck.keyIndex; - } - - console.log("Settings retrieved:", { - hasApiKey: !!(settings.apiKey || (settings.geminiApiKeys && settings.geminiApiKeys.length > 0)), - provider: provider, - labels: settings.labels, - enableAi: settings.enableAi !== false - }); - - if (settings.enableAi === false) { - console.error("AI is disabled"); - await updateNotification( - notificationId, - "AutoSort+ Error", - "AI analysis is disabled in settings." - ); - return null; - } - - // Check API key availability based on provider - let apiKeyToUse = null; - if (provider === 'gemini') { - if (settings.geminiApiKeys && settings.geminiApiKeys.length > 0) { - const keyIndex = keyIndexToUse !== null ? keyIndexToUse : (settings.currentGeminiKeyIndex || 0); - apiKeyToUse = settings.geminiApiKeys[keyIndex]; - console.log(`Using Gemini API Key #${keyIndex + 1} of ${settings.geminiApiKeys.length}`); - } else if (settings.apiKey) { - // Legacy single key - apiKeyToUse = settings.apiKey; - } - } else if (provider !== 'ollama') { - // Ollama doesn't need an API key; other providers do - apiKeyToUse = settings.apiKey; - } - - if (!apiKeyToUse && provider !== 'ollama') { - console.error("Missing API key"); - await updateNotification( - notificationId, - "AutoSort+ Error", - `${provider.charAt(0).toUpperCase() + provider.slice(1)} API key not configured. Please add your API key in settings.` - ); - return null; - } - - if (!settings.labels || settings.labels.length === 0) { - console.error("No labels configured"); - await updateNotification( - notificationId, - "AutoSort+ Error", - "No folders/labels configured. Please go to settings and either load folders from your mail account or add custom labels." - ); - return null; - } - - const prompt = `You are an email classification assistant. Analyze this email content and choose the most appropriate label from this list: ${settings.labels.join(', ')}. - Consider the following: - 1. The main topic and purpose of the email - 2. The sender and recipient context - 3. The urgency and importance of the content - 4. The type of communication (e.g., notification, request, update) - - Only respond with the exact label name that best fits the content. If no label fits well, respond with "null". - - Email content: - ${emailContent}`; - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - `Sending request to ${provider.charAt(0).toUpperCase() + provider.slice(1)} AI...` - ); - - let response; - let data; - - if (provider === 'gemini') { - const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKeyToUse}`; - console.log("Making API request to Gemini..."); - - // Track request for rate limiting (free tier only) - if (!settings.geminiPaidPlan) { - await trackGeminiRequest(keyIndexToUse); - } - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with Gemini AI..." - ); - - const requestBody = { - contents: [{ - role: "user", - parts: [{ - text: prompt - }] - }], - generationConfig: { - temperature: 0.2, - topK: 1, - topP: 1, - maxOutputTokens: 50, - responseMimeType: "text/plain", - thinkingConfig: { - thinkingBudget: 0 - } - }, - safetySettings: [ - { - category: "HARM_CATEGORY_HARASSMENT", - threshold: "BLOCK_NONE" - }, - { - category: "HARM_CATEGORY_HATE_SPEECH", - threshold: "BLOCK_NONE" - }, - { - category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", - threshold: "BLOCK_NONE" - }, - { - category: "HARM_CATEGORY_DANGEROUS_CONTENT", - threshold: "BLOCK_NONE" - } - ] - }; - - response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestBody) - }); - - } else if (provider === 'openai') { - console.log("Making API request to OpenAI..."); - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with OpenAI..." - ); - - response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKeyToUse}` - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) - }); - - } else if (provider === 'anthropic') { - console.log("Making API request to Anthropic..."); - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with Claude..." - ); - - response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKeyToUse, - 'anthropic-version': '2023-06-01' - }, - body: JSON.stringify({ - model: 'claude-3-haiku-20240307', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50 - }) - }); - - } else if (provider === 'groq') { - console.log("Making API request to Groq..."); - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with Groq..." - ); - - response = await fetch('https://api.groq.com/openai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKeyToUse}` - }, - body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) - }); - - } else if (provider === 'mistral') { - console.log("Making API request to Mistral..."); - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with Mistral..." - ); - - response = await fetch('https://api.mistral.ai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKeyToUse}` - }, - body: JSON.stringify({ - model: 'mistral-small-latest', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) - }); - - } else if (provider === 'ollama') { - console.log("Making API request to Ollama (local)..."); - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Analyzing email content with local Ollama..." - ); - - // Get Ollama settings - const ollamaSettings = await browser.storage.local.get(['ollamaUrl', 'ollamaModel', 'ollamaCustomModel', 'ollamaCpuOnly', 'ollamaAuthToken', 'ollamaNumCtx']); - const ollamaUrl = ollamaSettings.ollamaUrl || 'http://localhost:11434'; - let ollamaModel = ollamaSettings.ollamaModel || 'llama3.2'; - const ollamaNumCtx = ollamaSettings.ollamaNumCtx || 0; - const cpuOnly = ollamaSettings.ollamaCpuOnly === true; - const ollamaAuthToken = ollamaSettings.ollamaAuthToken || ''; - - // Use custom model if selected - if (ollamaModel === 'custom' && ollamaSettings.ollamaCustomModel) { - ollamaModel = ollamaSettings.ollamaCustomModel; - } - - console.log(`Using Ollama at ${ollamaUrl} with model ${ollamaModel}${cpuOnly ? ' (CPU-only)' : ''}`); - - // Use tab injection to make the fetch (browser context, no restrictions) - try { - const ollamaResponse = await ollamaChatViaTab(ollamaUrl, ollamaModel, prompt, ollamaAuthToken); - - if (!ollamaResponse.message || !ollamaResponse.message.content) { - throw new Error('Invalid Ollama response format'); - } - - data = ollamaResponse; - response = null; // Mark as handled - - } catch (ollamaError) { - console.error('[Ollama] Tab injection chat failed:', ollamaError.message); - throw ollamaError; - } - - } else { - throw new Error(`Unknown provider: ${provider}`); - } - - if (response) { - console.log("API response status:", response.status); - - if (!response.ok) { - let errorMessage = `HTTP ${response.status}: ${response.statusText}`; - - // Try to parse error response body - try { - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const error = await response.json(); - errorMessage = error.error?.message || error.message || errorMessage; - } else { - const text = await response.text(); - if (text) errorMessage = text.substring(0, 200); - } - } catch (parseErr) { - console.warn('Could not parse error response:', parseErr.message); - } - - console.error("API Error details:", errorMessage); - - // Handle quota errors specifically - if (response.status === 429 || errorMessage.includes('quota') || errorMessage.includes('rate limit')) { - errorMessage = "API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key."; - } - - // Handle Ollama auth errors - if (response.status === 403) { - errorMessage = "Ollama authentication failed (403). Check your API key/token if Ollama requires authentication."; - } - - await updateNotification( - notificationId, - "AutoSort+ Error", - `API Error: ${errorMessage}` - ); - return null; - } - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Processing AI response..." - ); - - data = await response.json(); - console.log("Full API response data:", JSON.stringify(data, null, 2)); - } else if (data) { - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Processing AI response..." - ); - console.log("Using response from native helper."); - } else { - await updateNotification( - notificationId, - "AutoSort+ Error", - "No response received from provider." - ); - return null; - } - - // Parse the response based on provider - let label = null; - - const tryTrim = v => { - try { - return (v || '').toString().trim(); - } catch (e) { - return null; - } - }; - - if (provider === 'gemini') { - if (data.candidates && data.candidates.length > 0) { - const candidate = data.candidates[0]; - if (candidate.finishReason === "MAX_TOKENS") { - console.error("Response truncated"); - await updateNotification(notificationId, "AutoSort+ Error", "AI response was cut off"); - return null; - } - if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) { - label = tryTrim(candidate.content.parts[0].text); - } - } - } else if (provider === 'openai' || provider === 'groq' || provider === 'mistral') { - if (data.choices && data.choices.length > 0) { - label = tryTrim(data.choices[0].message?.content || data.choices[0].text); - } - } else if (provider === 'anthropic') { - if (data.content && data.content.length > 0) { - label = tryTrim(data.content[0].text); - } - } else if (provider === 'ollama') { - // Ollama responses may vary in shape: string, object, array of parts, etc. - try { - const msg = data.message; - if (!msg) { - // Some older/local versions may return data as string or have different keys - label = tryTrim(data.result || data.text || data.response); - } else { - const content = msg.content; - if (typeof content === 'string') { - label = tryTrim(content); - } else if (Array.isArray(content)) { - // Find first element that's a string or has text fields - const first = content.find(c => typeof c === 'string' || (c && (c.text || c.content))); - if (typeof first === 'string') label = tryTrim(first); - else if (first && first.text) label = tryTrim(first.text); - else if (first && first.content) { - if (typeof first.content === 'string') label = tryTrim(first.content); - else if (Array.isArray(first.content)) label = tryTrim(first.content.map(x => x.text || x).join(' ')); - } - } else if (content && typeof content === 'object') { - // Content might be an object with text or parts - label = tryTrim(content.text || content.content || content[0]); - if (!label && content.parts && content.parts.length > 0) { - label = tryTrim(content.parts[0].text || content.parts[0]); - } - } else if (typeof msg === 'string') { - label = tryTrim(msg); - } - } - } catch (e) { - console.warn('Failed to parse Ollama response shape:', e.message); - label = null; - } - } - - if (!label) { - console.error("No label extracted from response:", data); - await updateNotification(notificationId, "AutoSort+ Error", "No response from AI"); - return null; - } - - console.log("Raw generated label:", label); - - // Normalize and try to match configured labels more forgivingly - const normalize = s => s.toString().trim().replace(/^['"`]+|['"`]+$/g, ''); - const lower = normalize(label).toLowerCase(); - - // Exact match first - if (settings.labels.includes(label)) { - await updateNotification(notificationId, "AutoSort+ Success", `AI analysis complete. Selected label: ${label}`); - return label; - } - - // Try to find a label that matches case-insensitively or is contained within the AI output - let matched = settings.labels.find(l => l.toLowerCase() === lower); - if (!matched) { - matched = settings.labels.find(l => lower.includes(l.toLowerCase()) || l.toLowerCase().includes(lower)); - } - - if (matched) { - console.log('Mapped AI output to configured label:', matched); - await updateNotification(notificationId, "AutoSort+ Success", `AI analysis complete. Selected label: ${matched}`); - return matched; - } - - console.log("Label not found in configured labels. Generated:", label); - await updateNotification(notificationId, "AutoSort+ Warning", `AI suggested: "${label}" but it's not in your configured labels.`); - return null; - } catch (error) { - console.error("Error analyzing email:", error); - await showNotification( - "AutoSort+ Error", - `Error analyzing email: ${error.message}` - ); - return null; - } -} - -// Function to store move history -async function storeMoveHistory(result) { - try { - const data = await browser.storage.local.get('moveHistory'); - const history = data.moveHistory || []; - history.unshift({ - timestamp: new Date().toISOString(), - ...result - }); - // Keep only the last 100 entries - if (history.length > 100) { - history.pop(); - } - await browser.storage.local.set({ moveHistory: history }); - } catch (error) { - console.error("Error storing move history:", error); - } -} - -// Function to apply labels to selected messages -async function applyLabelsToMessages(messages, label) { - try { - const messageCount = messages.length; - const notificationId = await showNotification( - "AutoSort+ Processing", - `Starting to process ${messageCount} message(s)...` - ); - - let successCount = 0; - let errorCount = 0; - const moveResults = []; - - for (const message of messages) { - console.log("Processing message:", message.id); - console.log("Target label/folder:", label); - - // Get all folders to find the destination folder - const account = await browser.accounts.get(message.folder.accountId); - console.log("Account info:", account); - - await updateNotification( - notificationId, - "AutoSort+ Processing", - `Finding destination folder for message ${successCount + errorCount + 1}/${messageCount}...` - ); - - // Find the folder with matching name - const findFolder = (folders, targetName) => { - for (const folder of folders) { - console.log("Checking folder:", folder.name); - if (folder.name === targetName) { - return folder; - } - if (folder.subFolders) { - const found = findFolder(folder.subFolders, targetName); - if (found) return found; - } - } - return null; - }; - - // First try to find the category folder - const categories = [ - "Financiën", - "Werk en Carrière", - "Persoonlijke Communicatie en Sociale Leven", - "Gezondheid en Welzijn", - "Online Activiteiten en E-commerce", - "Reizen en Evenementen", - "Informatie en Media", - "Beveiliging en IT", - "Klantensupport en Acties", - "Overheid en Gemeenschap" - ]; - - let categoryFolder = null; - let targetFolder = null; - - // Find the category and target folder - for (const category of categories) { - if (label.startsWith(category)) { - console.log("Found matching category:", category); - categoryFolder = findFolder(account.folders, category); - if (categoryFolder) { - console.log("Found category folder:", categoryFolder.name); - // Try to find the subfolder - const subfolderName = label.replace(category + "/", ""); - console.log("Looking for subfolder:", subfolderName); - targetFolder = findFolder(categoryFolder.subFolders || [], subfolderName); - break; - } else { - console.log("Category folder not found:", category, "- skipping to next category"); - continue; - } - } - } - - // If no target folder found, try direct match - if (!targetFolder) { - console.log("No category match found, trying direct folder match"); - targetFolder = findFolder(account.folders, label); - } - - // Auto-create missing folder when it's a custom label (skip imported/structured labels) - if (!targetFolder) { - const looksImported = label.includes('/') || label.includes('\\'); - if (looksImported) { - console.warn(`Folder "${label}" looks imported/structured; skipping auto-create.`); - } else { - try { - const parentFolder = account.folders && account.folders.length > 0 ? account.folders[0] : null; - if (parentFolder && browser.folders && browser.folders.create) { - console.log(`Creating missing folder "${label}" under ${parentFolder.name || 'root'}`); - const created = await browser.folders.create(parentFolder, label); - if (created) { - targetFolder = created; - console.log("Created folder:", created); - } - } - } catch (createError) { - console.error(`Failed to create folder "${label}":`, createError); - } - } - } - - console.log("Moving message to folder:", targetFolder ? targetFolder.name : "not found"); - - try { - if (!targetFolder) { - console.error(`Folder "${label}" not found in account ${account.name}`); - await updateNotification( - notificationId, - "AutoSort+ Error", - `Folder "${label}" not found. Please create it first in Thunderbird.` - ); - errorCount++; - const result = { - subject: message.subject || "(No subject)", - status: "Error", - destination: "Folder not found", - timestamp: new Date().toISOString() - }; - moveResults.push(result); - await storeMoveHistory(result); - continue; - } - - await updateNotification( - notificationId, - "AutoSort+ Processing", - `Moving message ${successCount + errorCount + 1}/${messageCount} to ${targetFolder.name}...` - ); - - // Move the message using the folder ID - await browser.messages.move( - [message.id], - targetFolder.id - ); - - successCount++; - const result = { - subject: message.subject || "(No subject)", - status: "Success", - destination: targetFolder.name, - timestamp: new Date().toISOString() - }; - moveResults.push(result); - await storeMoveHistory(result); - } catch (moveError) { - console.error("Error moving message:", moveError); - errorCount++; - const result = { - subject: message.subject || "(No subject)", - status: "Error", - destination: moveError.message, - timestamp: new Date().toISOString() - }; - moveResults.push(result); - await storeMoveHistory(result); - await updateNotification( - notificationId, - "AutoSort+ Error", - `Error moving message: ${moveError.message}` - ); - } - } - - // Show final status - if (errorCount === 0) { - await updateNotification( - notificationId, - "AutoSort+ Success", - `Successfully moved ${successCount} message(s) to ${label}` - ); - } else { - await updateNotification( - notificationId, - "AutoSort+ Completed with Errors", - `Processed ${messageCount} message(s): ${successCount} successful, ${errorCount} failed` - ); - } - - // Create and show the results popup - await showMoveResultsPopup(moveResults); - } catch (error) { - console.error("Error applying labels:", error); - await showNotification( - "AutoSort+ Error", - `Error processing messages: ${error.message}` - ); - } -} - -// Function to create and show the move results popup -async function showMoveResultsPopup(results) { - try { - const successCount = results.filter(r => r.status === "Success").length; - const errorCount = results.filter(r => r.status === "Error").length; - - // Create a detailed message - let message = `Processed ${results.length} messages:\n`; - message += `✅ Successfully moved: ${successCount}\n`; - message += `❌ Failed to move: ${errorCount}\n\n`; - - // Add details for each message - results.forEach((result, index) => { - message += `${index + 1}. ${result.subject}\n`; - message += ` Status: ${result.status}\n`; - message += ` Destination: ${result.destination}\n`; - message += ` Timestamp: ${result.timestamp}\n\n`; - }); - - // Show the notification with higher priority and require interaction - await showNotification( - "AutoSort+ Results", - message, - "basic" - ); - - // Also log to console for debugging - console.log("[AutoSort+] Results:", message); - } catch (error) { - console.error("Error showing results:", error); - await showNotification( - "AutoSort+ Error", - "Failed to show detailed results. Check console for more information." - ); - } -} - -// Create context menu items -browser.menus.create({ - id: "autosort-label", - title: "AutoSort+ Label", - contexts: ["message_list"] -}); - -// Add submenu items for labels -browser.storage.local.get(['labels']).then(result => { - if (result.labels) { - result.labels.forEach(label => { - browser.menus.create({ - id: `label-${label}`, - parentId: "autosort-label", - title: label, - contexts: ["message_list"] - }); - }); - } -}); - -// Add AI analysis option -browser.menus.create({ - id: "autosort-analyze", - title: "AutoSort+ Analyze with AI", - contexts: ["message_list"] -}); - -// Listen for menu clicks -browser.menus.onClicked.addListener(async (info, tab) => { - if (info.parentMenuItemId === "autosort-label") { - const label = info.menuItemId.replace("label-", ""); - console.log(`Manual label selected: ${label}`); - await showNotification("AutoSort+", `Applying label: ${label}`); - try { - // Get the current mail tab for processing - const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); - if (mailTabs && mailTabs.length > 0) { - // Get full message objects - const messages = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); - if (messages && messages.messages && messages.messages.length > 0) { - await applyLabelsToMessages(messages.messages, label); - } else { - await showNotification("AutoSort+ Error", "No messages selected for labeling."); - } - } else { - await showNotification("AutoSort+ Error", "No active mail tab found."); - } - } catch (error) { - console.error("Error applying manual label:", error); - await showNotification("AutoSort+ Error", `Error applying label: ${error.message}`); - } - } else if (info.menuItemId === "autosort-analyze") { - console.log("AI analysis selected - starting process"); - await showNotification("AutoSort+", "Starting AI analysis of selected messages..."); - - try { - // Get the current mail tab - const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); - if (!mailTabs || mailTabs.length === 0) { - console.error("No active mail tab found"); - await showNotification("AutoSort+ Error", "No active mail tab found"); - return; - } - console.log("Current mail tab:", mailTabs[0]); - - // Get selected messages using mailTabs API - const selectedMessageList = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); - console.log("Selected message list:", selectedMessageList); - - if (!selectedMessageList || !selectedMessageList.messages || selectedMessageList.messages.length === 0) { - console.error("No messages selected"); - await showNotification("AutoSort+ Error", "No messages selected for analysis"); - return; - } - - console.log(`Analyzing ${selectedMessageList.messages.length} selected messages`); - - for (const message of selectedMessageList.messages) { - // Get the full message with body - const fullMessage = await browser.messages.getFull(message.id); - console.log("Got full message:", fullMessage ? "yes" : "no"); - console.log("Message content:", fullMessage); - - if (!fullMessage) { - console.error("Could not get message content"); - continue; - } - - // Function to recursively extract text from message parts - function extractTextFromParts(parts) { - let text = ""; - if (!parts) return text; - - for (const part of parts) { - console.log("Processing part:", { - contentType: part.contentType, - partName: part.partName, - size: part.size - }); - - if (part.parts) { - // Recursively process nested parts - text += extractTextFromParts(part.parts); - } - - if (part.contentType === "text/plain") { - text += part.body + "\n"; - } else if (part.contentType === "text/html" && !text) { - // Only use HTML if we haven't found plain text - text = browser.messengerUtilities.convertToPlainText(part.body); - } else if (part.contentType === "message/rfc822" && part.body) { - // Handle message/rfc822 parts - text += part.body + "\n"; - } - } - return text; - } - - // Extract email content from the message - let emailContent = ""; - if (fullMessage.parts) { - emailContent = await extractTextFromParts(fullMessage.parts); - } else if (fullMessage.body) { - emailContent = fullMessage.body; - } - - console.log("Extracted email content:", emailContent || ""); - - if (!emailContent) { - console.error("No readable content found in message"); - await showNotification("AutoSort+ Error", "Could not extract email content"); - continue; - } - - console.log("Analyzing message content"); - const label = await analyzeEmailContent(emailContent); - - // Skip if AI returned null/no label - if (!label || String(label).trim().toLowerCase() === "null") { - console.log("Skipping message because generated label was null/empty"); - continue; - } - - console.log("Applying label:", label); - await applyLabelsToMessages([message], label); - await showNotification("AutoSort+", `Successfully applied label: ${label}`); - } - } catch (error) { - console.error("Error during AI analysis:", error); - await showNotification("AutoSort+ Error", `Error: ${error.message}`); - } - } -}); \ No newline at end of file diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs new file mode 100644 index 0000000..2b2b9df --- /dev/null +++ b/build/esbuild.config.mjs @@ -0,0 +1,131 @@ +import * as esbuild from "esbuild"; +import { readFileSync, writeFileSync, cpSync, existsSync, mkdirSync, rmSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); +const dist = resolve(root, "dist"); +const src = resolve(root, "src"); + +const watch = process.argv.includes("--watch"); + +// Clean dist (with retry for Windows file locking) +if (existsSync(dist)) { + try { rmSync(dist, { recursive: true, maxRetries: 5, retryDelay: 200 }); } catch { /* fall through */ } +} +mkdirSync(dist, { recursive: true }); +mkdirSync(resolve(dist, "js/workers"), { recursive: true }); + +async function build() { + // ── Standalone shared modules (for options page) + content script ── + await esbuild.build({ + entryPoints: [ + resolve(src, "shared/logger.ts"), + resolve(src, "shared/i18n.ts"), + resolve(src, "content.ts"), + resolve(src, "ollama.ts"), + resolve(src, "workers/ollama-worker.ts"), + ], + bundle: true, + format: "iife", + target: "es2020", + platform: "browser", + outdir: resolve(dist, "js"), + entryNames: "[name]", + logLevel: "info", + absWorkingDir: root, + external: ["browser", "messenger"], + }); + console.log(" → dist/js/ollama.js"); + console.log(" → dist/js/logger.js"); + console.log(" → dist/js/i18n.js"); + console.log(" → dist/js/content.js"); + console.log(" → dist/js/ollama-worker.js"); + + // Copy content script to dist root (manifest expects it there) + cpSync(resolve(dist, "js/content.js"), resolve(dist, "content.js")); + console.log(" → dist/content.js (from TS)"); + + // Copy worker to dist/js/workers/ + cpSync( + resolve(dist, "js/ollama-worker.js"), + resolve(dist, "js/workers/ollama-worker.js") + ); + console.log(" → dist/js/workers/ollama-worker.js (from TS)"); + + // ── Background script ────────────────────────── + // Single TypeScript entry — no more JS concatenation + await esbuild.build({ + entryPoints: [resolve(src, "background/index.ts")], + bundle: true, + format: "iife", + target: "es2020", + platform: "browser", + outfile: resolve(dist, "background.js"), + logLevel: "info", + absWorkingDir: root, + external: ["browser", "messenger"], + }); + console.log(" → dist/background.js (from TS)"); + + // ── Options page: compile TypeScript with esbuild ── + await esbuild.build({ + entryPoints: [resolve(src, "options/app.ts")], + bundle: true, + format: "iife", + target: "es2020", + platform: "browser", + outfile: resolve(dist, "options.js"), + logLevel: "info", + absWorkingDir: root, + external: ["browser", "messenger"], + }); + console.log(" → dist/options.js (from TS)"); + + // ── Copy static files ────────────────────────── + + cpSync(resolve(root, "options.html"), resolve(dist, "options.html")); + console.log(" → dist/options.html"); + + cpSync(resolve(root, "styles.css"), resolve(dist, "styles.css")); + console.log(" → dist/styles.css"); + + for (const dir of ["icons", "_locales", "api_ollama"]) { + cpSync(resolve(root, dir), resolve(dist, dir), { recursive: true }); + console.log(` → dist/${dir}/`); + } + + // ── Ollama popup (web_accessible_resource) ── + await esbuild.build({ + entryPoints: [resolve(src, "popup/ollama-popup.ts")], + bundle: true, + format: "iife", + target: "es2020", + platform: "browser", + outfile: resolve(dist, "api_ollama/ollama-popup.js"), + logLevel: "info", + absWorkingDir: root, + external: ["browser", "messenger"], + }); + console.log(" → dist/api_ollama/ollama-popup.js (from TS)"); + + // ── Generate dist manifest ───────────────────── + const manifest = JSON.parse( + readFileSync(resolve(root, "manifest.json"), "utf-8") + ); + manifest.background = { scripts: ["background.js"] }; + writeFileSync( + resolve(dist, "manifest.json"), + JSON.stringify(manifest, null, 2) + "\n", + "utf-8" + ); + console.log(" → dist/manifest.json"); + + console.log("\n✅ Build complete. Load dist/ as temporary extension in Thunderbird."); +} + +build().catch((err) => { + console.error("Build failed:", err); + process.exit(1); +}); diff --git a/build/package-xpi.mjs b/build/package-xpi.mjs new file mode 100644 index 0000000..e71a2c7 --- /dev/null +++ b/build/package-xpi.mjs @@ -0,0 +1,27 @@ +import { execSync } from 'child_process'; +import { renameSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const dist = resolve(root, 'dist'); + +// Try zip first (Linux/macOS/Git Bash), fall back to PowerShell (Windows) +try { + execSync('zip -r ../autosortplus.xpi *', { cwd: dist, stdio: 'inherit' }); + console.log('✅ autosortplus.xpi created (zip)'); +} catch { + console.log('zip not available, trying PowerShell...'); + try { + execSync( + 'powershell Compress-Archive -Path * -DestinationPath ../autosortplus.zip -Force', + { cwd: dist, stdio: 'inherit' } + ); + renameSync(resolve(root, 'autosortplus.zip'), resolve(root, 'autosortplus.xpi')); + console.log('✅ autosortplus.xpi created (PowerShell)'); + } catch (err) { + console.error('Failed to create XPI:', err.message); + process.exit(1); + } +} diff --git a/content.js b/content.js deleted file mode 100644 index 0be87c2..0000000 --- a/content.js +++ /dev/null @@ -1,109 +0,0 @@ -// Listen for messages from the background script -browser.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.action === "getSelectedMessages") { - try { - // Get the message list container - const messageList = document.querySelector('#threadTree'); - if (!messageList) { - console.error("Could not find message list"); - sendResponse([]); - return true; - } - - // Get selected rows - const selectedRows = messageList.querySelectorAll('tr.selected'); - if (!selectedRows || selectedRows.length === 0) { - console.log("No messages selected"); - sendResponse([]); - return true; - } - - // Extract message IDs - const selectedMessages = Array.from(selectedRows).map(row => { - // Try different possible ID attributes - const messageId = row.getAttribute('data-message-id') || - row.getAttribute('data-id') || - row.getAttribute('id'); - - if (!messageId) { - console.warn("Row missing message ID:", row); - return null; - } - - // Clean up the ID if needed - const cleanId = messageId.replace(/^msg-/i, ''); - return { id: cleanId }; - }).filter(msg => msg !== null); - - console.log("Found selected messages:", selectedMessages); - sendResponse(selectedMessages); - } catch (error) { - console.error("Error getting selected messages:", error); - sendResponse([]); - } - } else if (message.action === 'ollamaFetch') { - // Runs inside a tab at http://localhost:11434 to avoid CORS - (async () => { - try { - const { fetchAction, model, prompt, headers, correlationId } = message; - const base = window.location.origin; - - if (fetchAction === 'pull') { - const res = await fetch(`${base}/api/pull`, { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, headers || {}), - body: JSON.stringify({ name: model, stream: true }) - }); - if (!res.ok) { - const t = await res.text(); - try { - const j = JSON.parse(t); - throw new Error(j.error || t); - } catch (e) { - throw new Error(t || `HTTP ${res.status}`); - } - } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); - for (const line of lines) { - if (!line.trim()) continue; - try { - const data = JSON.parse(line); - const payload = { action: 'ollamaPullProgress', correlationId, status: data.status || '' }; - if (data.completed && data.total) { - payload.percent = Math.round((data.completed / data.total) * 100); - } - browser.runtime.sendMessage(payload).catch(() => {}); - } catch (e) { - // ignore parse errors for partial lines - } - } - } - browser.runtime.sendMessage({ action: 'ollamaPullComplete', correlationId, ok: true }).catch(() => {}); - sendResponse({ ok: true }); - } else if (fetchAction === 'chat') { - const res = await fetch(`${base}/api/chat`, { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, headers || {}), - body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: false }) - }); - const data = await res.json(); - sendResponse({ ok: true, data }); - } else { - sendResponse({ ok: false, error: 'unknown fetchAction' }); - } - } catch (err) { - sendResponse({ ok: false, error: err.message || String(err) }); - } - })(); - return true; - } - return true; -}); \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ca55557 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + { + files: ["src/**/*.js", "src/**/*.ts"], + rules: { + "no-unused-vars": "warn", + "no-undef": "error", + "no-var": "error", + "prefer-const": "error", + "eqeqeq": ["error", "always"], + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "no-console": "off", + }, + }, + { + ignores: ["dist/**", "node_modules/**", "*.xpi", "*.zip"], + }, +]); diff --git a/js/ollama.js b/js/ollama.js deleted file mode 100644 index 1175a5f..0000000 --- a/js/ollama.js +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Ollama API Client - * Adapted from ThunderAI extension - * Handles communication with local Ollama instance - */ - -export class Ollama { - host = ''; - model = ''; - stream = false; - num_ctx = 0; - authToken = ''; - - constructor({ - host = '', - model = '', - stream = false, - num_ctx = 0, - authToken = '', - } = {}) { - this.host = (host || '').trim().replace(/\/+$/, ""); - this.model = model; - this.stream = stream; - this.num_ctx = num_ctx; - this.authToken = authToken || ''; - } - - getHeaders = () => { - const headers = { - "Content-Type": "application/json" - }; - if (this.authToken) { - headers['Authorization'] = `Bearer ${this.authToken}`; - } - return headers; - } - - fetchModels = async () => { - try{ - const response = await fetch(this.host + "/api/tags", { - method: "GET", - headers: this.getHeaders(), - }); - - if (!response.ok) { - const errorDetail = await response.text(); - let err_msg = "[AutoSort+] Ollama API request failed: " + response.status + " " + response.statusText + ", Detail: " + errorDetail; - console.error(err_msg); - let output = {}; - output.ok = false; - output.error = errorDetail; - return output; - } - - let output = {}; - output.ok = true; - let output_response = await response.json(); - output.response = output_response; - - return output; - }catch (error) { - console.error("[AutoSort+] Ollama API request failed: " + error); - let output = {}; - output.is_exception = true; - output.ok = false; - output.error = "Ollama API request failed: " + error; - return output; - } - } - - fetchResponse = async (messages) => { - try { - const response = await fetch(this.host + "/api/chat", { - method: "POST", - headers: this.getHeaders(), - body: JSON.stringify({ - model: this.model, - messages: messages, - stream: this.stream, - ...(this.num_ctx > 0 ? { options: { num_ctx: parseInt(this.num_ctx) } } : {}), - }), - }); - return response; - }catch (error) { - console.error("[AutoSort+] Ollama API request failed: " + error); - let output = {}; - output.is_exception = true; - output.ok = false; - output.error = "Ollama API request failed: " + error; - return output; - } - } -} diff --git a/js/workers/ollama-worker.js b/js/workers/ollama-worker.js deleted file mode 100644 index af8e4bb..0000000 --- a/js/workers/ollama-worker.js +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Ollama Web Worker for AutoSort+ - * Handles streaming chat responses from local Ollama instance - * Adapted from ThunderAI extension - */ - -import { Ollama } from '../ollama.js'; - -let ollama_host = null; -let ollama_model = ''; -let ollama_num_ctx = 0; -let ollama_auth_token = ''; -let ollama = null; -let stopStreaming = false; -let conversationHistory = []; -let assistantResponseAccumulator = ''; - -self.onmessage = async function(event) { - switch (event.data.type) { - case 'init': - ollama_host = event.data.ollama_host; - ollama_model = event.data.ollama_model; - ollama_num_ctx = event.data.ollama_num_ctx; - ollama_auth_token = event.data.ollama_auth_token || ''; - ollama = new Ollama({ - host: ollama_host, - model: ollama_model, - stream: true, - num_ctx: ollama_num_ctx, - authToken: ollama_auth_token - }); - console.log("[Ollama Worker] Initialized with host: " + ollama_host + ", model: " + ollama_model); - break; // init - - case 'chatMessage': - conversationHistory.push({ role: 'user', content: event.data.message }); - console.log("[Ollama Worker] Chat message received: " + event.data.message); - - const response = await ollama.fetchResponse(conversationHistory); - postMessage({ type: 'messageSent' }); - - if (!response.ok) { - let error_message = ''; - if(response.is_exception === true){ - error_message = response.error; - }else{ - try{ - const errorJSON = await response.json(); - error_message = errorJSON.error?.message || response.statusText; - }catch(e){ - error_message = response.statusText; - } - } - console.error("[Ollama Worker] API Error: " + error_message); - postMessage({ type: 'error', payload: "Ollama API Error: " + response.status + " " + error_message }); - break; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder("utf-8"); - let buffer = ''; - - try { - while (true) { - if (stopStreaming) { - stopStreaming = false; - reader.cancel(); - conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); - assistantResponseAccumulator = ''; - postMessage({ type: 'tokensDone' }); - break; - } - - const { done, value } = await reader.read(); - if (done) { - conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); - assistantResponseAccumulator = ''; - postMessage({ type: 'tokensDone' }); - break; - } - - const chunk = decoder.decode(value); - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop(); - - let parsedLines = []; - try{ - parsedLines = lines - .map((line) => line.trim()) - .filter((line) => line !== "") - .map((line) => { - try { - return JSON.parse(line); - } catch (e) { - console.warn("[Ollama Worker] JSON parse warning, skipped: " + line); - return null; - } - }) - .filter((parsed) => parsed !== null); - }catch(e){ - console.error("[Ollama Worker] Error parsing lines: " + e); - } - - for (const parsedLine of parsedLines) { - const { message } = parsedLine; - const { content } = message; - - if (content) { - assistantResponseAccumulator += content; - postMessage({ type: 'newToken', payload: { token: content } }); - } - } - } - } catch (error) { - console.error('[Ollama Worker] Stream error: ' + error); - postMessage({ type: 'error', payload: "Connection error: " + error.message }); - } - break; - - case 'stop': - stopStreaming = true; - break; - - default: - console.error('[Ollama Worker] Unknown message type:', event.data.type); - } -}; diff --git a/manifest.json b/manifest.json index bb170ee..6408e77 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 2, "name": "AutoSort+", - "version": "1.2.3.3", - "description": "Automatically sort and label your emails with custom rules using AI", + "version": "1.2.3.4", + "description": "__MSG_extensionDescription__", "author": "Nigel Hagen", "applications": { "gecko": { @@ -10,26 +10,31 @@ "strict_min_version": "78.0" } }, + "default_locale": "en", "permissions": [ "messagesRead", "messagesModify", "accountsRead", "storage", "menus", - "tabs", "messagesMove", - "messagesRead", - "activeTab", "https://generativelanguage.googleapis.com/*", - "http://localhost/*", - "http://127.0.0.1/*" + "http://localhost:11434/*", + "http://127.0.0.1:11434/*", + "http://localhost:1234/*", + "http://127.0.0.1:1234/*" ], "background": { "scripts": ["background.js"] }, "content_scripts": [ { - "matches": ["*://*/*"], + "matches": [ + "http://localhost:11434/*", + "http://127.0.0.1:11434/*", + "http://localhost:1234/*", + "http://127.0.0.1:1234/*" + ], "js": ["content.js"] } ], @@ -39,7 +44,7 @@ }, "browser_action": { "default_icon": "icons/icon-48.png", - "default_title": "AutoSort+ Settings" + "default_title": "__MSG_extensionDefaultTitle__" }, "icons": { "48": "icons/icon-48.png", @@ -48,7 +53,6 @@ "web_accessible_resources": [ "api_ollama/index.html", "api_ollama/ollama-popup.js", - "js/ollama.js", - "js/workers/ollama-worker.js" + "js/ollama.js" ] } \ No newline at end of file diff --git a/options.html b/options.html index 1762fc3..e19049d 100644 --- a/options.html +++ b/options.html @@ -2,214 +2,309 @@ - AutoSort+ Settings + AutoSort+ Settings -
-

AutoSort+ Settings

- -
-
-

🤖 AI Settings

- -
-
- +
+

AutoSort+ Settings

+ + + + + +
+
+

🤖 AI Settings

+ +
+
+ +
-

Provider Selection

+

Provider Selection

- +
-
-
- + + - + + + + +
-

🔑 API Key Configuration

+

🔑 API Key Configuration

- - + +
- - + +
-
- + + - -
-

⚙️ General Settings

- - + + + - - + +
-

ℹ️ How AI Sorting Works

+

ℹ️ How AI Sorting Works

-

AutoSort+ uses AI to analyze your emails and automatically sort them into categories/folders based on their content. The AI will:

+

AutoSort+ uses AI to analyze your emails and automatically sort them into categories/folders based on their content. The AI will:

    -
  • Read and understand email content
  • -
  • Identify key topics and themes
  • -
  • Match emails to appropriate categories/folders
  • -
  • Learn from your manual corrections to improve accuracy
  • +
  • Read and understand email content
  • +
  • Identify key topics and themes
  • +
  • Match emails to appropriate categories/folders
  • +
  • Learn from your manual corrections to improve accuracy
-
-
+
+
-
-
-

📁 Custom Categories/Folders

- -
-
+ + + + +
+
+

📁 Custom Categories/Folders

+ +
+
+
-

Folder Source

- - +

Folder Source

+ +
+
- - - + + +
+
- +
- -
+
+
- + - \ No newline at end of file + \ No newline at end of file diff --git a/options.js b/options.js deleted file mode 100644 index c62672a..0000000 --- a/options.js +++ /dev/null @@ -1,1345 +0,0 @@ -document.addEventListener('DOMContentLoaded', async function() { - // Initialize collapsible sections - const sectionHeaders = document.querySelectorAll('.section-header'); - sectionHeaders.forEach(header => { - header.addEventListener('click', function() { - const sectionId = this.getAttribute('data-section'); - const content = document.getElementById(sectionId); - const section = this.parentElement; - const icon = this.querySelector('.collapse-icon'); - - if (section.classList.contains('collapsed')) { - // Expand - section.classList.remove('collapsed'); - content.style.display = 'block'; - icon.textContent = '▼'; - // Trigger animation - setTimeout(() => { - content.style.animation = 'slideDown 0.3s ease-out'; - }, 0); - } else { - // Collapse - section.classList.add('collapsed'); - content.style.display = 'none'; - icon.textContent = '▶'; - } - }); - }); - - const labelsContainer = document.getElementById('labels-container'); - const addLabelButton = document.getElementById('add-label'); - const saveButton = document.getElementById('save-settings'); - const apiKeyInput = document.getElementById('api-key'); - const aiProviderSelect = document.getElementById('ai-provider'); - const providerInfo = document.getElementById('provider-info'); - const getApiKeyButton = document.getElementById('get-api-key'); - const testApiButton = document.getElementById('test-api'); - const apiTestResult = document.getElementById('api-test-result'); - const geminiPaidContainer = document.getElementById('gemini-paid-container'); - const geminiPaidCheckbox = document.getElementById('gemini-paid-plan'); - const importLabelsButton = document.getElementById('import-labels'); - const bulkImportTextarea = document.getElementById('bulk-import-text'); - const loadImapFoldersButton = document.getElementById('load-imap-folders'); - const folderLoadingIndicator = document.getElementById('folder-loading'); - const folderSelection = document.getElementById('folder-selection'); - const foldersPreview = document.getElementById('folders-preview'); - const folderCount = document.getElementById('folder-count'); - const useImapFoldersButton = document.getElementById('use-imap-folders'); - const useCustomFoldersButton = document.getElementById('use-custom-folders'); - const geminiMultiKeysContainer = document.getElementById('gemini-multi-keys-container'); - const geminiKeysList = document.getElementById('gemini-keys-list'); - const addGeminiKeyButton = document.getElementById('add-gemini-key'); - - // Ollama-specific elements - const ollamaModelSelect = document.getElementById('ollama-model'); - const ollamaCustomModelInput = document.getElementById('ollama-custom-model'); - const ollamaUrlInput = document.getElementById('ollama-url'); - const ollamaAuthTokenInput = document.getElementById('ollama-auth-token'); - const ollamaCpuOnlyCheckbox = document.getElementById('ollama-cpu-only'); - const testOllamaButton = document.getElementById('test-ollama'); - const listOllamaModelsButton = document.getElementById('list-ollama-models'); - const downloadOllamaModelButton = document.getElementById('download-ollama-model'); - const ollamaDownloadModelInput = document.getElementById('ollama-download-model'); - const ollamaDownloadStatus = document.getElementById('ollama-download-status'); - const ollamaTestResult = document.getElementById('ollama-test-result'); - const diagnoseOllamaButton = document.getElementById('diagnose-ollama'); - const ollamaDiagnostics = document.getElementById('ollama-diagnostics'); - - // Update endpoint URLs when Ollama URL changes - if (ollamaUrlInput) { - ollamaUrlInput.addEventListener('input', () => { - const url = ollamaUrlInput.value.trim() || 'http://localhost:11434'; - const chatEndpoint = document.getElementById('ollama-chat-endpoint'); - const pullEndpoint = document.getElementById('ollama-pull-endpoint'); - const tagsEndpoint = document.getElementById('ollama-tags-endpoint'); - - if (chatEndpoint) chatEndpoint.textContent = `${url}/api/chat`; - if (pullEndpoint) pullEndpoint.textContent = `${url}/api/pull`; - if (tagsEndpoint) tagsEndpoint.textContent = `${url}/api/tags`; - }); - } - - let loadedFolders = []; - let geminiKeys = []; // Array to store multiple Gemini API keys - - // AI Provider configurations - const aiProviders = { - gemini: { - name: 'Google Gemini', - signupUrl: 'https://aistudio.google.com/app/apikey', - info: '✓ Free tier: 5 requests/minute, 20/day per API key (enforced by addon)
✓ Tip: Create multiple API keys in different projects, switch keys when limit reached
✓ Check usage: AI Studio Usage
✓ Best for: General use, multilingual support
✓ Models: Gemini 2.5 Flash
✓ Check "paid plan" option to remove limits', - isFree: true - }, - openai: { - name: 'OpenAI', - signupUrl: 'https://platform.openai.com/signup', - info: '✓ Free trial: $5 credit
✓ Best for: High accuracy, English content
✓ Models: GPT-4o-mini ($0.15/1M tokens)', - isFree: false - }, - anthropic: { - name: 'Anthropic Claude', - signupUrl: 'https://console.anthropic.com/', - info: '✓ Free tier: Limited requests
✓ Best for: Long emails, detailed analysis
✓ Models: Claude 3 Haiku', - isFree: true - }, - groq: { - name: 'Groq', - signupUrl: 'https://console.groq.com/', - info: '✓ Free tier: 30 requests/minute
✓ Best for: Speed (fastest)
✓ Models: Llama 3.3 (Mixtral deprecated)', - isFree: true - }, - mistral: { - name: 'Mistral AI', - signupUrl: 'https://console.mistral.ai/', - info: '✓ Free tier: Limited requests
✓ Best for: European users, GDPR compliance
✓ Models: Mistral Small', - isFree: true - }, - ollama: { - name: 'Ollama (Local LLM)', - signupUrl: 'https://ollama.ai/', - info: '✓ 100% Free: Runs locally on your machine
✓ Privacy: No data sent to external servers
✓ No rate limits: Process unlimited emails
✓ Models: Llama 2/3, Mistral, Phi, Gemma, Qwen, and more
✓ Requires: Ollama installed and running locally
✓ Setup: Install Ollama, run "ollama pull llama3.2" to download a model', - isFree: true - } - }; - - // Update provider info when selection changes - function updateProviderInfo() { - const provider = aiProviderSelect.value; - const config = aiProviders[provider]; - - // Get subsection elements - const ollamaSubsection = document.getElementById('ollama-settings-subsection'); - const apiKeySubsection = document.getElementById('api-key-subsection'); - const geminiMultiKeysSubsection = document.getElementById('gemini-multi-keys-subsection'); - const geminiUsageSubsection = document.getElementById('gemini-usage-subsection'); - const rateLimitWarning = document.getElementById('rate-limit-warning'); - - // Show/hide rate limit warning (not for Ollama) - if (rateLimitWarning) { - rateLimitWarning.style.display = provider === 'ollama' ? 'none' : 'block'; - } - - // Show/hide Gemini-specific elements - if (provider === 'gemini') { - geminiPaidContainer.style.display = 'block'; - if (geminiMultiKeysSubsection) geminiMultiKeysSubsection.style.display = 'block'; - if (geminiUsageSubsection) geminiUsageSubsection.style.display = 'block'; - if (apiKeySubsection) apiKeySubsection.style.display = 'none'; - if (ollamaSubsection) ollamaSubsection.style.display = 'none'; - updateGeminiUsageDisplay(); - } else if (provider === 'ollama') { - // Show Ollama settings, hide API key and Gemini sections - geminiPaidContainer.style.display = 'none'; - if (geminiMultiKeysSubsection) geminiMultiKeysSubsection.style.display = 'none'; - if (geminiUsageSubsection) geminiUsageSubsection.style.display = 'none'; - if (apiKeySubsection) apiKeySubsection.style.display = 'none'; - if (ollamaSubsection) ollamaSubsection.style.display = 'block'; - } else { - geminiPaidContainer.style.display = 'none'; - if (geminiMultiKeysSubsection) geminiMultiKeysSubsection.style.display = 'none'; - if (geminiUsageSubsection) geminiUsageSubsection.style.display = 'none'; - if (apiKeySubsection) apiKeySubsection.style.display = 'block'; - if (ollamaSubsection) ollamaSubsection.style.display = 'none'; - } - - providerInfo.innerHTML = ` -
- ${config.name} ${config.isFree ? 'FREE' : ''} -

${config.info}

-
- `; - - if (provider !== 'ollama') { - apiKeyInput.placeholder = `Enter your ${config.name} API key`; - } - } - - // Update Gemini usage display - async function updateGeminiUsageDisplay() { - const data = await browser.storage.local.get(['geminiRateLimits', 'currentGeminiKeyIndex', 'geminiApiKeys', 'geminiRateLimit']); - const currentIndex = data.currentGeminiKeyIndex || 0; - const keys = data.geminiApiKeys || geminiKeys; - - if (keys.length > 1) { - // Multi-key mode - document.getElementById('single-key-usage').style.display = 'none'; - document.getElementById('multi-key-usage').style.display = 'block'; - const rateLimits = data.geminiRateLimits || []; - updateMultiKeyUsageDisplay(keys, rateLimits, currentIndex); - } else if (keys.length === 1) { - // Single-key mode but stored in new format - document.getElementById('single-key-usage').style.display = 'block'; - document.getElementById('multi-key-usage').style.display = 'none'; - const rateLimits = data.geminiRateLimits || [{ requests: [], dailyCount: 0, dailyResetTime: Date.now() }]; - updateSingleKeyUsageDisplay(rateLimits[0]); - } else { - // Legacy single-key mode (backward compatibility) - document.getElementById('single-key-usage').style.display = 'block'; - document.getElementById('multi-key-usage').style.display = 'none'; - const rateLimit = data.geminiRateLimit || { requests: [], dailyCount: 0, dailyResetTime: Date.now() }; - updateSingleKeyUsageDisplay(rateLimit); - } - } - - // Update single key usage display (backward compatibility) - async function updateSingleKeyUsageDisplay(rateLimit) { - const now = Date.now(); - - // Update daily count - document.getElementById('gemini-daily-count').textContent = rateLimit.dailyCount; - - // Update last request time - if (rateLimit.requests && rateLimit.requests.length > 0) { - const lastRequest = Math.max(...rateLimit.requests); - const minutesAgo = Math.floor((now - lastRequest) / 60000); - if (minutesAgo < 1) { - document.getElementById('gemini-last-request').textContent = 'Just now'; - } else if (minutesAgo < 60) { - document.getElementById('gemini-last-request').textContent = `${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago`; - } else { - const hoursAgo = Math.floor(minutesAgo / 60); - document.getElementById('gemini-last-request').textContent = `${hoursAgo} hour${hoursAgo > 1 ? 's' : ''} ago`; - } - } else { - document.getElementById('gemini-last-request').textContent = 'Never'; - } - - // Update reset time - if (rateLimit.dailyResetTime > now) { - const hoursUntil = Math.ceil((rateLimit.dailyResetTime - now) / (1000 * 60 * 60)); - document.getElementById('gemini-reset-time').textContent = `In ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''}`; - } else { - document.getElementById('gemini-reset-time').textContent = 'Expired (will reset on next request)'; - } - - // Update status and show warnings - const usageMessage = document.getElementById('usage-message'); - const statusSpan = document.getElementById('gemini-status'); - - if (rateLimit.dailyCount >= 20) { - statusSpan.textContent = '🔴 Limit Reached'; - statusSpan.style.color = '#dc3545'; - usageMessage.className = 'usage-message warning'; - usageMessage.textContent = '⚠️ Daily limit reached! Create a new API key in a different project and update it above to continue processing emails.'; - } else if (rateLimit.dailyCount >= 15) { - statusSpan.textContent = '🟡 Nearly Full'; - statusSpan.style.color = '#ffc107'; - usageMessage.className = 'usage-message warning'; - usageMessage.textContent = `⚠️ Only ${20 - rateLimit.dailyCount} requests remaining today. Consider switching to a new API key soon.`; - } else { - statusSpan.textContent = '🟢 Ready'; - statusSpan.style.color = '#28a745'; - usageMessage.style.display = 'none'; - } - } - - // Update multi-key usage display - function updateMultiKeyUsageDisplay(keys, rateLimits, currentIndex) { - const container = document.getElementById('all-keys-usage-stats'); - const now = Date.now(); - container.innerHTML = ''; - - keys.forEach((key, index) => { - const rateLimit = rateLimits[index] || { requests: [], dailyCount: 0, dailyResetTime: now }; - const isActive = index === currentIndex; - - const card = document.createElement('div'); - card.className = `key-usage-card${isActive ? ' active' : ''}`; - - // Determine status - let statusBadge = ''; - if (isActive) { - statusBadge = '🔵 ACTIVE'; - } else if (rateLimit.dailyCount >= 20) { - statusBadge = '🔴 LIMIT'; - } else if (rateLimit.dailyCount >= 15) { - statusBadge = '🟡 NEAR LIMIT'; - } else { - statusBadge = '🟢 READY'; - } - - // Calculate reset time - let resetText = '--'; - if (rateLimit.dailyResetTime > now) { - const hoursUntil = Math.ceil((rateLimit.dailyResetTime - now) / (1000 * 60 * 60)); - resetText = `${hoursUntil}h`; - } - - // Last request time - let lastRequestText = 'Never'; - if (rateLimit.requests && rateLimit.requests.length > 0) { - const lastRequest = Math.max(...rateLimit.requests); - const minutesAgo = Math.floor((now - lastRequest) / 60000); - if (minutesAgo < 1) { - lastRequestText = 'Just now'; - } else if (minutesAgo < 60) { - lastRequestText = `${minutesAgo}m ago`; - } else { - lastRequestText = `${Math.floor(minutesAgo / 60)}h ago`; - } - } - - // Mask key for display - const maskedKey = key ? `...${key.slice(-8)}` : 'Not set'; - - card.innerHTML = ` -
- Key ${index + 1}: ${maskedKey} - ${statusBadge} -
-
-
- Usage: - ${rateLimit.dailyCount}/20 -
-
- Last: - ${lastRequestText} -
-
- Resets: - ${resetText} -
-
- Available: - ${20 - rateLimit.dailyCount} -
-
- `; - - container.appendChild(card); - }); - } - - // Add Gemini key input field - function addGeminiKeyInput(value = '', index = -1) { - if (index === -1) { - index = geminiKeys.length; - geminiKeys.push(value); - } - - const keyItem = document.createElement('div'); - keyItem.className = 'gemini-key-item'; - keyItem.dataset.index = index; - - const keyIndex = document.createElement('span'); - keyIndex.className = 'key-index'; - keyIndex.textContent = `#${index + 1}`; - - const input = document.createElement('input'); - input.type = 'password'; - input.className = 'gemini-api-key-input'; - input.placeholder = 'Enter Gemini API key from another project'; - input.value = value; - input.dataset.index = index; - input.addEventListener('input', (e) => { - const newKey = e.target.value.trim(); - geminiKeys[index] = newKey; - - // Check for duplicates in real-time - if (newKey) { - const isDuplicate = geminiKeys.some((key, i) => i !== index && key.trim() === newKey); - if (isDuplicate) { - input.style.borderColor = '#dc3545'; - input.title = '⚠️ This key is already added!'; - } else { - input.style.borderColor = ''; - input.title = ''; - } - } else { - input.style.borderColor = ''; - input.title = ''; - } - }); - - const testButton = document.createElement('button'); - testButton.className = 'button'; - testButton.textContent = 'Test'; - testButton.addEventListener('click', () => { - const keyValue = input.value.trim(); - if (!keyValue) { - statusSpan.textContent = '⚠️ Enter key first'; - statusSpan.className = 'key-test-result error'; - return; - } - - // Check for duplicates before testing - const isDuplicate = geminiKeys.some((key, i) => i !== index && key.trim() === keyValue); - if (isDuplicate) { - statusSpan.textContent = '⚠️ Duplicate key'; - statusSpan.className = 'key-test-result error'; - statusSpan.title = 'This key is already added in the list'; - return; - } - - testGeminiKey(keyValue, index, keyItem); - }); - - const removeButton = document.createElement('button'); - removeButton.className = 'button'; - removeButton.textContent = '×'; - removeButton.addEventListener('click', () => removeGeminiKey(index)); - - const statusSpan = document.createElement('span'); - statusSpan.className = 'key-test-result'; - statusSpan.dataset.index = index; - - keyItem.appendChild(keyIndex); - keyItem.appendChild(input); - keyItem.appendChild(testButton); - keyItem.appendChild(removeButton); - keyItem.appendChild(statusSpan); - geminiKeysList.appendChild(keyItem); - } - - // Remove Gemini key - function removeGeminiKey(index) { - if (geminiKeys.length <= 1) { - alert('You must have at least one API key configured.'); - return; - } - - if (confirm(`Remove API key #${index + 1}?`)) { - geminiKeys.splice(index, 1); - refreshGeminiKeysList(); - } - } - - // Refresh Gemini keys list display - function refreshGeminiKeysList() { - geminiKeysList.innerHTML = ''; - geminiKeys.forEach((key, index) => { - addGeminiKeyInput(key, index); - }); - } - - // Test individual Gemini key - async function testGeminiKey(apiKey, index, keyItemElement) { - const statusSpan = keyItemElement.querySelector('.key-test-result'); - - if (!apiKey) { - statusSpan.textContent = '⚠️ Enter key first'; - statusSpan.className = 'key-test-result error'; - return; - } - - try { - statusSpan.textContent = 'Testing...'; - statusSpan.className = 'key-test-result testing'; - - const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: "Test" }] }], - generationConfig: { maxOutputTokens: 10 } - }) - }); - - if (response.ok) { - statusSpan.textContent = '✓ Valid'; - statusSpan.className = 'key-test-result success'; - } else if (response.status === 429) { - statusSpan.textContent = '⚠️ Limit reached'; - statusSpan.className = 'key-test-result error'; - statusSpan.title = 'This key has reached its daily rate limit (20/day). Will reset in ~24 hours.'; - console.error(`Key #${index + 1} has reached rate limit (429)`); - } else if (response.status === 401 || response.status === 403) { - statusSpan.textContent = '✗ Invalid key'; - statusSpan.className = 'key-test-result error'; - statusSpan.title = 'API key is invalid or expired. Check your key in Google AI Studio.'; - console.error(`Key #${index + 1} test failed: ${response.status}`); - } else { - statusSpan.textContent = `✗ Failed (${response.status})`; - statusSpan.className = 'key-test-result error'; - console.error(`Key #${index + 1} test failed:`, response.status); - } - } catch (error) { - statusSpan.textContent = `✗ Error`; - statusSpan.className = 'key-test-result error'; - console.error(`Key #${index + 1} test error:`, error); - } - } - - // Initialize provider info - updateProviderInfo(); - aiProviderSelect.addEventListener('change', updateProviderInfo); - - // Add Gemini key button - addGeminiKeyButton.addEventListener('click', () => { - addGeminiKeyInput(''); - }); - - // Reset Gemini counter button - document.getElementById('reset-gemini-counter').addEventListener('click', async () => { - if (confirm('Reset usage counter? Do this only after switching to a new API key.')) { - await browser.storage.local.set({ - geminiRateLimit: { - requests: [], - dailyCount: 0, - dailyResetTime: Date.now() + (24 * 60 * 60 * 1000) - } - }); - await updateGeminiUsageDisplay(); - const usageMessage = document.getElementById('usage-message'); - usageMessage.className = 'usage-message info'; - usageMessage.textContent = '✓ Usage counter reset. You can now process up to 20 more emails today with your new API key.'; - } - }); - - // Refresh usage button (single key) - document.getElementById('refresh-usage').addEventListener('click', async () => { - await updateGeminiUsageDisplay(); - const usageMessage = document.getElementById('usage-message'); - usageMessage.className = 'usage-message info'; - usageMessage.textContent = '✓ Usage information refreshed.'; - setTimeout(() => { - if (usageMessage.classList.contains('info')) { - usageMessage.style.display = 'none'; - } - }, 3000); - }); - - // Refresh all usage button (multi key) - document.getElementById('refresh-all-usage').addEventListener('click', async () => { - await updateGeminiUsageDisplay(); - showMessage('✓ All usage information refreshed.', true); - }); - - // Get API Key button - getApiKeyButton.addEventListener('click', async () => { - const provider = aiProviderSelect.value; - const config = aiProviders[provider]; - - try { - // Try to open in new tab - await browser.tabs.create({ url: config.signupUrl }); - } catch (error) { - console.error('Failed to open tab:', error); - // Fallback: show URL and copy to clipboard - const url = config.signupUrl; - try { - await navigator.clipboard.writeText(url); - showMessage(`URL copied to clipboard:\n${url}`, true); - } catch (e) { - // Last resort: show alert with URL - alert(`Please visit:\n${url}`); - } - } - }); - - // Function to validate and update save button state - function updateSaveButtonState() { - const labels = Array.from(document.querySelectorAll('.label-input')) - .map(input => input.value.trim()) - .filter(label => label !== ''); - - const provider = aiProviderSelect.value; - let hasValidApiKey = true; // Default to true for Ollama and other providers - - if (provider === 'gemini') { - const validGeminiKeys = geminiKeys.filter(key => key && key.trim() !== ''); - hasValidApiKey = validGeminiKeys.length > 0; - } else if (provider !== 'ollama') { - // Non-Ollama providers (OpenAI, Anthropic, Groq, Mistral) require API key - const apiKey = apiKeyInput.value.trim(); - hasValidApiKey = !!apiKey; - } - // Ollama doesn't require an API key, so hasValidApiKey stays true - - if (labels.length === 0 || !hasValidApiKey) { - saveButton.disabled = true; - saveButton.classList.add('disabled'); - - let missingItems = []; - if (labels.length === 0) missingItems.push('folders/labels'); - if (!hasValidApiKey) missingItems.push('API key'); - - saveButton.title = `Please configure: ${missingItems.join(' and ')}`; - } else { - saveButton.disabled = false; - saveButton.classList.remove('disabled'); - saveButton.title = ''; - } - } - - // Load saved settings - browser.storage.local.get(['labels', 'apiKey', 'geminiApiKeys', 'aiProvider', 'enableAi', 'geminiPaidPlan', 'ollamaUrl', 'ollamaModel', 'ollamaCustomModel', 'ollamaCpuOnly']).then(result => { - if (result.labels && result.labels.length > 0) { - result.labels.forEach(label => { - addLabelInput(label); - }); - } else { - // Show instruction if no labels - labelsContainer.innerHTML = '
No folders/labels configured. Click "Load Folders from Mail Account" above or add custom labels below.
'; - } - - // Load API keys - if (result.geminiApiKeys && result.geminiApiKeys.length > 0) { - // Multi-key mode - geminiKeys = result.geminiApiKeys; - geminiKeys.forEach((key, index) => { - addGeminiKeyInput(key, index); - }); - } else if (result.apiKey) { - // Migrate from single key to multi-key - geminiKeys = [result.apiKey]; - addGeminiKeyInput(result.apiKey, 0); - apiKeyInput.value = result.apiKey; - } else { - // No keys configured yet - add one empty field - addGeminiKeyInput('', 0); - } - - // Load Ollama settings - if (result.ollamaUrl && ollamaUrlInput) { - ollamaUrlInput.value = result.ollamaUrl; - } - if (result.ollamaAuthToken && ollamaAuthTokenInput) { - ollamaAuthTokenInput.value = result.ollamaAuthToken; - } - if (result.ollamaModel && ollamaModelSelect) { - ollamaModelSelect.value = result.ollamaModel; - if (result.ollamaModel === 'custom' && result.ollamaCustomModel && ollamaCustomModelInput) { - ollamaCustomModelInput.value = result.ollamaCustomModel; - ollamaCustomModelInput.style.display = 'block'; - } - } - if (ollamaCpuOnlyCheckbox) { - ollamaCpuOnlyCheckbox.checked = result.ollamaCpuOnly === true; - } - - if (result.aiProvider) { - aiProviderSelect.value = result.aiProvider; - updateProviderInfo(); - } - // Set enableAi to true by default if not set - document.getElementById('enable-ai').checked = result.enableAi !== false; - - // Set gemini paid plan checkbox - geminiPaidCheckbox.checked = result.geminiPaidPlan === true; - - updateSaveButtonState(); - }); - - // Add input listeners for validation - apiKeyInput.addEventListener('input', updateSaveButtonState); - labelsContainer.addEventListener('input', updateSaveButtonState); - - // Test API connection - testApiButton.addEventListener('click', async () => { - const apiKey = apiKeyInput.value.trim(); - const provider = aiProviderSelect.value; - - // Skip for Ollama as it has its own test button - if (provider === 'ollama') { - showApiTestResult('Please use the "Test Ollama Connection" button below', false); - return; - } - - if (!apiKey) { - showApiTestResult('Please enter an API key', false); - return; - } - - try { - showApiTestResult('Testing connection...', false); - - let response; - if (provider === 'gemini') { - response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-goog-api-key': apiKey - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: "Test" }] }], - generationConfig: { maxOutputTokens: 10 } - }) - }); - } else if (provider === 'openai') { - response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [{ role: 'user', content: 'Test' }], - max_tokens: 10 - }) - }); - } else if (provider === 'anthropic') { - response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01' - }, - body: JSON.stringify({ - model: 'claude-3-haiku-20240307', - messages: [{ role: 'user', content: 'Test' }], - max_tokens: 10 - }) - }); - } else if (provider === 'groq') { - response = await fetch('https://api.groq.com/openai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', - messages: [{ role: 'user', content: 'Test' }], - max_tokens: 10 - }) - }); - } else if (provider === 'mistral') { - response = await fetch('https://api.mistral.ai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: 'mistral-small-latest', - messages: [{ role: 'user', content: 'Test' }], - max_tokens: 10 - }) - }); - } - - if (response.ok) { - showApiTestResult('✓ API connection successful!', true); - } else { - const error = await response.json(); - showApiTestResult(`API Error: ${error.error?.message || error.message || 'Unknown error'}`, false); - } - } catch (error) { - showApiTestResult(`Connection Error: ${error.message}`, false); - } - }); - - // Load IMAP folders - loadImapFoldersButton.addEventListener('click', async () => { - folderLoadingIndicator.style.display = 'block'; - folderSelection.style.display = 'none'; - - try { - const accounts = await browser.accounts.list(); - const allFolders = []; - - for (const account of accounts) { - const folders = await getAllFolders(account); - allFolders.push(...folders); - } - - // Filter out system folders and duplicates - loadedFolders = [...new Set(allFolders - .filter(f => !['Inbox', 'Trash', 'Drafts', 'Sent', 'Spam', 'Junk', 'Templates', 'Outbox', 'Archives'].includes(f)) - .map(f => f.replace(/^INBOX\./i, '').trim()) - )].sort(); - - if (loadedFolders.length === 0) { - showMessage('No folders found. You can create custom folders instead.', false); - folderLoadingIndicator.style.display = 'none'; - return; - } - - // Show folder preview - folderCount.textContent = loadedFolders.length; - foldersPreview.innerHTML = loadedFolders - .slice(0, 10) - .map(f => `
${f}
`) - .join('') + (loadedFolders.length > 10 ? `
...and ${loadedFolders.length - 10} more
` : ''); - - folderSelection.style.display = 'block'; - } catch (error) { - showMessage(`Error loading folders: ${error.message}`, false); - console.error('Error loading folders:', error); - } finally { - folderLoadingIndicator.style.display = 'none'; - } - }); - - // Use IMAP folders - useImapFoldersButton.addEventListener('click', () => { - if (confirm(`This will replace any existing folders/labels with ${loadedFolders.length} folders from your mail account. Continue?`)) { - labelsContainer.innerHTML = ''; - loadedFolders.forEach(folder => { - addLabelInput(folder); - }); - folderSelection.style.display = 'none'; - updateSaveButtonState(); - showMessage(`Loaded ${loadedFolders.length} folders from your mail account. Don't forget to save!`, true); - } - }); - - // Use custom folders - useCustomFoldersButton.addEventListener('click', () => { - folderSelection.style.display = 'none'; - showMessage('You can now add custom folders below', true); - }); - - // Helper function to recursively get all folders - async function getAllFolders(account) { - const folders = []; - - async function processFolder(folder) { - if (folder.type !== 'inbox' && folder.type !== 'trash' && folder.type !== 'sent' && - folder.type !== 'drafts' && folder.type !== 'junk' && folder.type !== 'templates' && - folder.type !== 'outbox' && folder.type !== 'archives') { - folders.push(folder.name); - } - - if (folder.subFolders) { - for (const subFolder of folder.subFolders) { - await processFolder(subFolder); - } - } - } - - for (const folder of account.folders) { - await processFolder(folder); - } - - return folders; - } - - // Import categories/folders in bulk - importLabelsButton.addEventListener('click', () => { - const bulkText = bulkImportTextarea.value.trim(); - const labels = bulkText.split('\n').map(l => l.trim()).filter(l => l !== ''); - - // Validation - if (labels.length === 0) { - showMessage('Please add at least one folder/label before importing. Enter labels one per line.', false); - return; - } - - // Confirm if there are existing labels - const existingLabels = Array.from(document.querySelectorAll('.label-input')) - .map(input => input.value.trim()) - .filter(label => label !== ''); - - if (existingLabels.length > 0) { - if (!confirm(`This will replace your ${existingLabels.length} existing folders/labels with ${labels.length} new ones. Continue?`)) { - return; - } - } - - // Clear existing categories/folders - labelsContainer.innerHTML = ''; - - // Add each category/folder - labels.forEach(label => { - addLabelInput(label); - }); - - updateSaveButtonState(); - showMessage(`Imported ${labels.length} categories/folders. Don't forget to save!`, true); - bulkImportTextarea.value = ''; // Clear the textarea - }); - - // Add new label input - - // Show/hide custom model input based on selection - if (ollamaModelSelect) { - ollamaModelSelect.addEventListener('change', () => { - if (ollamaModelSelect.value === 'custom') { - ollamaCustomModelInput.style.display = 'block'; - } else { - ollamaCustomModelInput.style.display = 'none'; - } - }); - } - - // Test Ollama connection - if (testOllamaButton) { - testOllamaButton.addEventListener('click', async () => { - const ollamaUrl = ollamaUrlInput.value.trim() || 'http://localhost:11434'; - let selectedModel = ollamaModelSelect.value; - - if (selectedModel === 'custom') { - selectedModel = ollamaCustomModelInput.value.trim(); - if (!selectedModel) { - ollamaTestResult.textContent = '⚠️ Please enter a custom model name first'; - ollamaTestResult.className = 'api-test-result error'; - return; - } - } - - try { - ollamaTestResult.textContent = 'Testing connection and checking model...'; - ollamaTestResult.className = 'api-test-result'; - - const testUrl = `${ollamaUrl}/api/tags`; - console.log('[Ollama Test] Connecting to:', testUrl); - - const headers = {}; - if (ollamaAuthTokenInput && ollamaAuthTokenInput.value.trim()) { - headers['Authorization'] = `Bearer ${ollamaAuthTokenInput.value.trim()}`; - } - - const response = await fetch(testUrl, { - method: 'GET', - headers - }); - - console.log('[Ollama Test] Response status:', response.status); - - if (response.ok) { - const data = await response.json(); - console.log('[Ollama Test] Success:', data); - const installedModels = data.models && data.models.length > 0 - ? data.models.map(m => m.name) - : []; - - if (installedModels.length === 0) { - ollamaTestResult.textContent = `⚠️ Ollama is running but no models installed. Enter a model name in "Download Model" and click "Download" to get started.`; - ollamaTestResult.className = 'api-test-result error'; - } else { - // Extract base model name (before colon) for regex matching - const selectedBase = selectedModel.split(':')[0].toLowerCase(); - const installedBases = installedModels.map(m => m.split(':')[0].toLowerCase()); - - const modelFound = installedBases.some(base => base === selectedBase); - if (modelFound) { - ollamaTestResult.textContent = `✓ Connected! Model "${selectedModel}" is installed and ready. Available: ${installedModels.join(', ')}`; - ollamaTestResult.className = 'api-test-result success'; - } else { - ollamaTestResult.textContent = `✗ Model "${selectedModel}" not installed. Available models: ${installedModels.join(', ')}. Use "Download Model" to install it.`; - ollamaTestResult.className = 'api-test-result error'; - } - } - } else { - const errorText = await response.text(); - console.error('[Ollama Test] Error response:', errorText); - let errorMsg = 'Connection failed'; - if (response.status === 403) { - errorMsg = 'Access denied (403). Check if Ollama is running and the URL is correct.'; - } else if (response.status === 404) { - errorMsg = 'Ollama not found (404). Check the server URL.'; - } else { - try { - const errorData = JSON.parse(errorText); - errorMsg = errorData.error || errorText; - } catch (e) { - errorMsg = errorText || `HTTP ${response.status}`; - } - } - ollamaTestResult.textContent = `✗ Error: ${errorMsg}`; - ollamaTestResult.className = 'api-test-result error'; - } - } catch (error) { - console.error('[Ollama Test] Exception:', error); - ollamaTestResult.textContent = `✗ Connection failed: ${error.message}. Make sure Ollama is running (try: ollama serve)`; - ollamaTestResult.className = 'api-test-result error'; - } - }); - } - - // Run comprehensive Ollama diagnostics - if (diagnoseOllamaButton) { - diagnoseOllamaButton.addEventListener('click', async () => { - const ollamaUrl = ollamaUrlInput.value.trim() || 'http://localhost:11434'; - let diagnosticOutput = '🔍 OLLAMA DIAGNOSTICS\n' + '='.repeat(50) + '\n\n'; - - ollamaDiagnostics.style.display = 'block'; - ollamaDiagnostics.className = 'diagnostics-result'; - ollamaDiagnostics.textContent = diagnosticOutput + 'Running tests...\n'; - - try { - // Test 1: Check /api/tags endpoint - diagnosticOutput += '📋 Test 1: List Models Endpoint\n'; - diagnosticOutput += ` URL: ${ollamaUrl}/api/tags\n`; - try { - const tagsResponse = await fetch(`${ollamaUrl}/api/tags`); - diagnosticOutput += ` Status: ${tagsResponse.status} ${tagsResponse.statusText}\n`; - - if (tagsResponse.ok) { - const data = await tagsResponse.json(); - diagnosticOutput += ` ✓ SUCCESS - Found ${data.models?.length || 0} models\n`; - if (data.models && data.models.length > 0) { - diagnosticOutput += ' Installed models: ' + data.models.map(m => m.name).join(', ') + '\n'; - } else { - diagnosticOutput += ' ⚠️ No models installed\n'; - } - } else { - diagnosticOutput += ` ✗ FAILED\n`; - } - } catch (error) { - diagnosticOutput += ` ✗ ERROR: ${error.message}\n`; - } - - // Test 2: Check /api/version endpoint - diagnosticOutput += '\n🔢 Test 2: Version Endpoint\n'; - diagnosticOutput += ` URL: ${ollamaUrl}/api/version\n`; - try { - const versionResponse = await fetch(`${ollamaUrl}/api/version`); - diagnosticOutput += ` Status: ${versionResponse.status} ${versionResponse.statusText}\n`; - - if (versionResponse.ok) { - const data = await versionResponse.json(); - diagnosticOutput += ` ✓ SUCCESS - Ollama version: ${data.version || 'unknown'}\n`; - } else { - diagnosticOutput += ` ⚠️ Endpoint not available (older Ollama version)\n`; - } - } catch (error) { - diagnosticOutput += ` ✗ ERROR: ${error.message}\n`; - } - - // Test 3: Test pull endpoint (without actually downloading) - diagnosticOutput += '\n⬇️ Test 3: Pull Endpoint Check\n'; - diagnosticOutput += ` URL: ${ollamaUrl}/api/pull\n`; - diagnosticOutput += ` Note: This endpoint is used for downloading models\n`; - - // Summary - diagnosticOutput += '\n' + '='.repeat(50) + '\n'; - diagnosticOutput += '📊 SUMMARY:\n\n'; - - if (diagnosticOutput.includes('✓ SUCCESS - Found')) { - diagnosticOutput += '✓ Ollama is running and accessible\n'; - diagnosticOutput += `✓ API base URL: ${ollamaUrl}\n`; - ollamaDiagnostics.className = 'diagnostics-result success'; - } else { - diagnosticOutput += '✗ Cannot connect to Ollama\n'; - diagnosticOutput += '\nTroubleshooting:\n'; - diagnosticOutput += '1. Check if Ollama is running: ps aux | grep ollama\n'; - diagnosticOutput += '2. Start Ollama: ollama serve\n'; - diagnosticOutput += `3. Test manually: curl ${ollamaUrl}/api/tags\n`; - diagnosticOutput += '4. Check if port 11434 is in use: lsof -i :11434\n'; - ollamaDiagnostics.className = 'diagnostics-result error'; - } - - } catch (error) { - diagnosticOutput += '\n❌ CRITICAL ERROR:\n'; - diagnosticOutput += error.message + '\n'; - ollamaDiagnostics.className = 'diagnostics-result error'; - } - - ollamaDiagnostics.textContent = diagnosticOutput; - }); - } - - // List Ollama models - if (listOllamaModelsButton) { - listOllamaModelsButton.addEventListener('click', async () => { - const ollamaUrl = ollamaUrlInput.value.trim() || 'http://localhost:11434'; - - try { - ollamaTestResult.textContent = 'Fetching models...'; - ollamaTestResult.className = 'api-test-result'; - - const response = await fetch(`${ollamaUrl}/api/tags`); - - if (response.ok) { - const data = await response.json(); - if (data.models && data.models.length > 0) { - const modelNames = data.models.map(m => m.name).join(', '); - ollamaTestResult.textContent = `✓ Available models: ${modelNames}`; - ollamaTestResult.className = 'api-test-result success'; - } else { - ollamaTestResult.textContent = '⚠️ No models installed. Run "ollama pull llama3.2" to download one.'; - ollamaTestResult.className = 'api-test-result error'; - } - } else { - ollamaTestResult.textContent = '✗ Failed to fetch models'; - ollamaTestResult.className = 'api-test-result error'; - } - } catch (error) { - ollamaTestResult.textContent = `✗ Connection failed: ${error.message}. Is Ollama running?`; - ollamaTestResult.className = 'api-test-result error'; - } - }); - } - - // Download Ollama model via tab proxy - if (downloadOllamaModelButton) { - downloadOllamaModelButton.addEventListener('click', async () => { - const ollamaUrl = (ollamaUrlInput.value.trim() || 'http://localhost:11434').replace(/\/$/, ''); - const modelName = ollamaDownloadModelInput.value.trim(); - const token = ollamaAuthTokenInput && ollamaAuthTokenInput.value.trim(); - if (!modelName) { - ollamaDownloadStatus.textContent = '⚠️ Please enter a model name to download'; - ollamaDownloadStatus.className = 'api-test-result error'; - ollamaDownloadStatus.style.display = 'block'; - return; - } - try { - downloadOllamaModelButton.disabled = true; - ollamaDownloadStatus.textContent = `Starting download of ${modelName}...`; - ollamaDownloadStatus.className = 'api-test-result'; - ollamaDownloadStatus.style.display = 'block'; - - // Ask background to open a hidden tab and start pull - const headers = token ? { Authorization: `Bearer ${token}` } : {}; - await browser.runtime.sendMessage({ - action: 'startOllamaPull', - ollamaUrl, - model: modelName, - headers - }); - } catch (e) { - ollamaDownloadStatus.textContent = `✗ Failed to start: ${e.message}`; - ollamaDownloadStatus.className = 'api-test-result error'; - } finally { - downloadOllamaModelButton.disabled = false; - } - }); - // Listen for progress events from content.js - browser.runtime.onMessage.addListener((msg) => { - if (msg.action === 'ollamaPullProgress') { - const parts = []; - if (msg.status) parts.push(msg.status); - if (typeof msg.percent === 'number') parts.push(`${msg.percent}%`); - ollamaDownloadStatus.textContent = parts.join(' — '); - ollamaDownloadStatus.className = 'api-test-result'; - ollamaDownloadStatus.style.display = 'block'; - } else if (msg.action === 'ollamaPullComplete') { - if (msg.ok) { - ollamaDownloadStatus.textContent = '✓ Download complete'; - ollamaDownloadStatus.className = 'api-test-result success'; - } else { - ollamaDownloadStatus.textContent = `✗ Download failed: ${msg.error || 'unknown error'}`; - ollamaDownloadStatus.className = 'api-test-result error'; - } - ollamaDownloadStatus.style.display = 'block'; - } - }); - } - - addLabelButton.addEventListener('click', () => { - // Clear instruction message if present - const instructionMsg = labelsContainer.querySelector('.instruction-message'); - if (instructionMsg) { - labelsContainer.innerHTML = ''; - } - addLabelInput(''); - updateSaveButtonState(); - }); - - // Save settings - saveButton.addEventListener('click', () => { - const labels = Array.from(document.querySelectorAll('.label-input')) - .map(input => input.value.trim()) - .filter(label => label !== ''); - - const apiKey = apiKeyInput.value.trim(); - const provider = aiProviderSelect.value; - - // Validation - if (labels.length === 0) { - showMessage('Please add at least one folder/label before saving. Use "Load Folders from Mail Account" or add custom labels.', false); - return; - } - - // Validate API keys based on provider - if (provider === 'gemini') { - // Filter out empty Gemini keys - const validGeminiKeys = geminiKeys.filter(key => key && key.trim() !== ''); - - if (validGeminiKeys.length === 0) { - showMessage('Please add at least one Gemini API key before saving.', false); - return; - } - - // Check for duplicate keys - const uniqueKeys = new Set(validGeminiKeys.map(key => key.trim().toLowerCase())); - if (uniqueKeys.size !== validGeminiKeys.length) { - showMessage('⚠️ Duplicate API keys detected! Each key must be unique. Please remove duplicates before saving.', false); - return; - } - - const settings = { - labels: labels, - geminiApiKeys: validGeminiKeys, - currentGeminiKeyIndex: 0, // Start with first key - aiProvider: provider, - enableAi: document.getElementById('enable-ai').checked, - geminiPaidPlan: geminiPaidCheckbox.checked - }; - - // Initialize rate limits array for all keys if not exists - browser.storage.local.get(['geminiRateLimits']).then(result => { - if (!result.geminiRateLimits || result.geminiRateLimits.length !== validGeminiKeys.length) { - settings.geminiRateLimits = validGeminiKeys.map(() => ({ - requests: [], - dailyCount: 0, - dailyResetTime: Date.now() + (24 * 60 * 60 * 1000) - })); - } - - browser.storage.local.set(settings).then(() => { - showMessage('✓ Settings saved successfully! Multiple Gemini API keys configured for automatic rotation.', true); - updateSaveButtonState(); - }).catch(error => { - showMessage('Error saving settings: ' + error, false); - }); - }); - } else if (provider === 'ollama') { - // Ollama doesn't need API key, just save URL and model - let ollamaModel = ollamaModelSelect.value; - if (ollamaModel === 'custom') { - ollamaModel = ollamaCustomModelInput.value.trim(); - if (!ollamaModel) { - showMessage('Please enter a custom model name for Ollama.', false); - return; - } - } - - const settings = { - labels: labels, - aiProvider: provider, - enableAi: document.getElementById('enable-ai').checked, - ollamaUrl: ollamaUrlInput.value.trim() || 'http://localhost:11434', - ollamaModel: ollamaModel, - ollamaCustomModel: ollamaCustomModelInput.value.trim(), - ollamaAuthToken: ollamaAuthTokenInput ? ollamaAuthTokenInput.value.trim() : '', - ollamaCpuOnly: ollamaCpuOnlyCheckbox.checked - }; - - browser.storage.local.set(settings).then(() => { - const cpuMode = ollamaCpuOnlyCheckbox.checked ? ' (CPU-only mode)' : ''; - showMessage(`✓ Settings saved successfully! Ollama is configured for local email processing${cpuMode}.`, true); - updateSaveButtonState(); - }).catch(error => { - showMessage('Error saving settings: ' + error, false); - }); - } else { - // Other providers use single key - if (!apiKey) { - showMessage('Please enter your API key before saving. Click "Get API Key" to obtain one.', false); - return; - } - - const settings = { - labels: labels, - apiKey: apiKey, - aiProvider: provider, - enableAi: document.getElementById('enable-ai').checked, - geminiPaidPlan: geminiPaidCheckbox.checked - }; - - browser.storage.local.set(settings).then(() => { - showMessage('✓ Settings saved successfully! You can now use AutoSort+ to analyze emails.', true); - updateSaveButtonState(); - }).catch(error => { - showMessage('Error saving settings: ' + error, false); - }); - } - }); - - // Add category/folder input field - function addLabelInput(value = '') { - const labelItem = document.createElement('div'); - labelItem.className = 'label-item'; - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'label-input'; - input.placeholder = 'Enter category/folder name'; - input.value = value; - input.addEventListener('input', updateSaveButtonState); - - const removeButton = document.createElement('button'); - removeButton.className = 'remove-label'; - removeButton.textContent = '×'; - removeButton.addEventListener('click', () => { - labelItem.remove(); - updateSaveButtonState(); - - // Show instruction if no labels left - const remainingLabels = document.querySelectorAll('.label-input'); - if (remainingLabels.length === 0) { - labelsContainer.innerHTML = '
No folders/labels configured. Click "Load Folders from Mail Account" above or add custom labels below.
'; - } - }); - - labelItem.appendChild(input); - labelItem.appendChild(removeButton); - labelsContainer.appendChild(labelItem); - } - - // Show API test result - function showApiTestResult(message, isSuccess) { - apiTestResult.textContent = message; - apiTestResult.className = `api-test-result ${isSuccess ? 'success' : 'error'}`; - } - - // Show message to user - function showMessage(message, isSuccess = true) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message'; - messageDiv.textContent = message; - messageDiv.style.backgroundColor = isSuccess ? 'var(--success-color)' : 'var(--error-color)'; - document.body.appendChild(messageDiv); - - setTimeout(() => { - messageDiv.remove(); - }, 3000); - } - - // Function to format timestamp - function formatTimestamp(timestamp) { - const date = new Date(timestamp); - return date.toLocaleString(); - } - - // Function to update history table - async function updateHistoryTable() { - const historyBody = document.getElementById('history-body'); - const data = await browser.storage.local.get('moveHistory'); - const history = data.moveHistory || []; - - historyBody.innerHTML = history.map(entry => ` - - ${formatTimestamp(entry.timestamp)} - ${entry.subject} - ${entry.status} - ${entry.destination} - - `).join(''); - } - - // Function to clear history - async function clearHistory() { - if (confirm('Are you sure you want to clear the move history?')) { - await browser.storage.local.set({ moveHistory: [] }); - await updateHistoryTable(); - } - } - - // Initialize the page - await updateHistoryTable(); - - // Add event listeners for history controls - document.getElementById('clear-history').addEventListener('click', clearHistory); - document.getElementById('refresh-history').addEventListener('click', updateHistoryTable); -}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c22c2cd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2896 @@ +{ + "name": "autosort-plus", + "version": "1.2.3.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autosort-plus", + "version": "1.2.3.3", + "license": "MIT", + "devDependencies": { + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^4.1.5", + "esbuild": "^0.28.0", + "eslint": "^10.2.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb05edc --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "autosort-plus", + "version": "1.2.3.3", + "description": "AI-powered email sorting Thunderbird extension", + "private": true, + "scripts": { + "build": "node build/esbuild.config.mjs", + "watch": "node build/esbuild.config.mjs --watch", + "lint": "eslint .", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "npm run typecheck && npm run lint && npm run test", + "xpi": "npm run build && node build/package-xpi.mjs" + }, + "keywords": [ + "thunderbird", + "extension", + "email", + "ai", + "sorting" + ], + "author": "Nigel Hagen", + "license": "MIT", + "devDependencies": { + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^4.1.5", + "esbuild": "^0.28.0", + "eslint": "^10.2.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/src/background/ai/index.ts b/src/background/ai/index.ts new file mode 100644 index 0000000..91b28cb --- /dev/null +++ b/src/background/ai/index.ts @@ -0,0 +1,63 @@ +import type { AnalyzeRequest, AnalyzeResult } from './types'; +import { injectPlaceholders, DEFAULT_PROMPT } from './prompt'; +import { analyze as geminiAnalyze } from './providers/gemini'; +import { analyze as openaiAnalyze } from './providers/openai'; +import { analyze as anthropicAnalyze } from './providers/anthropic'; +import { analyze as groqAnalyze } from './providers/groq'; +import { analyze as mistralAnalyze } from './providers/mistral'; +import { analyze as ollamaAnalyze } from './providers/ollama'; +import { analyze as openaiCompatAnalyze } from './providers/openai-compat'; + +export type { AnalyzeRequest, AnalyzeResult } from './types'; +export { DEFAULT_PROMPT, injectPlaceholders, stripCodeFences, matchLabelFromResponse } from './prompt'; + +const providerMap: Record) => Promise> = { + gemini: geminiAnalyze, + openai: openaiAnalyze, + anthropic: anthropicAnalyze, + groq: groqAnalyze, + mistral: mistralAnalyze, + ollama: ollamaAnalyze, + 'openai-compatible': openaiCompatAnalyze, +}; + +export async function analyzeEmail( + request: AnalyzeRequest, + provider: string, + settings: Record +): Promise { + const analyzeFn = providerMap[provider]; + if (!analyzeFn) { + throw new Error(`Unknown AI provider: ${provider}`); + } + + const customPrompt = request.customPrompt || (settings.customPrompt as string) || DEFAULT_PROMPT; + const emailContext = request.emailContext; + + // Build prompt from template if context is available + const prompt = emailContext + ? injectPlaceholders(customPrompt, emailContext, request.labels) + : request.emailContent; + + const debugLogger = (window as any).debugLogger; + if (debugLogger) { + debugLogger.apiRequest(provider, provider, { prompt, labels: request.labels }); + } + + try { + const result = await analyzeFn({ ...request, emailContent: prompt }, settings); + + if (debugLogger) { + debugLogger.apiResponse(provider, 200, { + label: result.suggestedLabel, + rawResponse: result.rawResponse, + }); + } + return result; + } catch (err: any) { + if (debugLogger) { + debugLogger.apiResponse(provider, 0, { error: err.message }); + } + throw err; + } +} diff --git a/src/background/ai/prompt.ts b/src/background/ai/prompt.ts new file mode 100644 index 0000000..c9f5dcd --- /dev/null +++ b/src/background/ai/prompt.ts @@ -0,0 +1,64 @@ +import type { EmailContext } from '../../types/email'; + +export const DEFAULT_PROMPT = `You are an email classification assistant. Analyze this email and choose the most appropriate label from: {labels}. + +**Email Metadata:** +- Subject: {subject} +- From: {author} +- Attachments: {attachments} + +**Email Body:** +{body} + +Consider the subject line, sender context, attachment filenames, and body content to determine the most appropriate category. Respond with only the exact label name, or "null" if no label fits well.`; + +export function injectPlaceholders( + template: string, + context: EmailContext, + labels: string[] +): string { + const labelsStr = labels.join(', '); + const attachmentsStr = + context.attachments.length > 0 + ? context.attachments.map((a) => a.name).join(', ') + : 'None'; + + return template + .replace(/{labels}/g, labelsStr) + .replace(/{subject}/g, context.subject || '(No subject)') + .replace(/{author}/g, context.author || '') + .replace(/{attachments}/g, attachmentsStr) + .replace(/{body}/g, context.body || ''); +} + +export function stripCodeFences(text: string): string { + return text + .replace(/^```[a-z]*\n?/i, '') + .replace(/\n?```$/i, '') + .trim(); +} + +export function matchLabelFromResponse( + responseText: string, + labels: string[] +): string { + const cleaned = stripCodeFences(responseText).trim(); + + // Exact match first + for (const label of labels) { + if (cleaned === label) return label; + } + + // Case-insensitive match + const lowerCleaned = cleaned.toLowerCase(); + for (const label of labels) { + if (label.toLowerCase() === lowerCleaned) return label; + } + + // Substring match (label appears anywhere in response) + for (const label of labels) { + if (lowerCleaned.includes(label.toLowerCase())) return label; + } + + return cleaned || 'null'; +} diff --git a/src/background/ai/providers/anthropic.ts b/src/background/ai/providers/anthropic.ts new file mode 100644 index 0000000..3734c54 --- /dev/null +++ b/src/background/ai/providers/anthropic.ts @@ -0,0 +1,101 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + options: RequestInit; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const apiKey = settings.apiKey as string; + + const body = { + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: request.emailContent }], + max_tokens: 50, + }; + + return { + url: 'https://api.anthropic.com/v1/messages', + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.content || !Array.isArray(d.content) || d.content.length === 0) { + return null; + } + + const first = d.content[0] as Record; + return first.text ? String(first.text).trim() : null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, options } = buildRequest(request, settings); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + + if ( + response.status === 429 || + errorMessage.includes('quota') || + errorMessage.includes('rate limit') + ) { + errorMessage = + 'API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key.'; + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from Anthropic response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/gemini.ts b/src/background/ai/providers/gemini.ts new file mode 100644 index 0000000..3d13934 --- /dev/null +++ b/src/background/ai/providers/gemini.ts @@ -0,0 +1,125 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { stripCodeFences, matchLabelFromResponse } from '../prompt'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + options: RequestInit; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const apiKey = settings.apiKey as string; + const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; + + const body = { + contents: [ + { + role: 'user', + parts: [{ text: request.emailContent }], + }, + ], + generationConfig: { + temperature: 0.6, + topK: 20, + topP: 0.95, + maxOutputTokens: 50, + responseMimeType: 'text/plain', + thinkingConfig: { + thinkingBudget: 0, + }, + }, + safetySettings: [ + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, + { + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'BLOCK_NONE', + }, + { + category: 'HARM_CATEGORY_DANGEROUS_CONTENT', + threshold: 'BLOCK_NONE', + }, + ], + }; + + return { + url: apiUrl, + options: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.candidates || !Array.isArray(d.candidates) || d.candidates.length === 0) { + return null; + } + + const candidate = d.candidates[0] as Record; + + if (candidate.finishReason === 'MAX_TOKENS') { + return null; + } + + const content = candidate.content as Record | undefined; + if (!content?.parts || !Array.isArray(content.parts) || content.parts.length === 0) { + return null; + } + + const part = content.parts[0] as Record; + return part.text ? String(part.text).trim() : null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, options } = buildRequest(request, settings); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from Gemini response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/groq.ts b/src/background/ai/providers/groq.ts new file mode 100644 index 0000000..43de9a8 --- /dev/null +++ b/src/background/ai/providers/groq.ts @@ -0,0 +1,119 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + options: RequestInit; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const apiKey = settings.apiKey as string; + + const body = { + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: request.emailContent }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95, + }; + + return { + url: 'https://api.groq.com/openai/v1/chat/completions', + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.choices || !Array.isArray(d.choices) || d.choices.length === 0) { + return null; + } + + const choice = d.choices[0] as Record; + const message = choice.message as Record | undefined; + + // Try multiple possible content locations + const content = + message?.content || + choice.text || + (choice.delta as Record)?.content; + + if (typeof content === 'string') { + return content.trim(); + } + + // Some models return reasoning in separate field + if (message?.reasoning_content && typeof message.reasoning_content === 'string') { + return message.reasoning_content.trim(); + } + + return null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, options } = buildRequest(request, settings); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + + if ( + response.status === 429 || + errorMessage.includes('quota') || + errorMessage.includes('rate limit') + ) { + errorMessage = + 'API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key.'; + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from Groq response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/mistral.ts b/src/background/ai/providers/mistral.ts new file mode 100644 index 0000000..852f4aa --- /dev/null +++ b/src/background/ai/providers/mistral.ts @@ -0,0 +1,119 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + options: RequestInit; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const apiKey = settings.apiKey as string; + + const body = { + model: 'mistral-small-latest', + messages: [{ role: 'user', content: request.emailContent }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95, + }; + + return { + url: 'https://api.mistral.ai/v1/chat/completions', + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.choices || !Array.isArray(d.choices) || d.choices.length === 0) { + return null; + } + + const choice = d.choices[0] as Record; + const message = choice.message as Record | undefined; + + // Try multiple possible content locations + const content = + message?.content || + choice.text || + (choice.delta as Record)?.content; + + if (typeof content === 'string') { + return content.trim(); + } + + // Some models return reasoning in separate field + if (message?.reasoning_content && typeof message.reasoning_content === 'string') { + return message.reasoning_content.trim(); + } + + return null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, options } = buildRequest(request, settings); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + + if ( + response.status === 429 || + errorMessage.includes('quota') || + errorMessage.includes('rate limit') + ) { + errorMessage = + 'API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key.'; + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from Mistral response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/ollama.ts b/src/background/ai/providers/ollama.ts new file mode 100644 index 0000000..3fbbfdb --- /dev/null +++ b/src/background/ai/providers/ollama.ts @@ -0,0 +1,188 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; +import { fetchViaTab } from '../../../shared/tab-fetch'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + body: Record; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const ollamaUrl = (settings.ollamaUrl as string) || 'http://localhost:11434'; + let ollamaModel = (settings.ollamaModel as string) || 'llama3.2'; + const ollamaCustomModel = settings.ollamaCustomModel as string | undefined; + + // Use custom model if selected + if (ollamaModel === 'custom' && ollamaCustomModel) { + ollamaModel = ollamaCustomModel; + } + + const body: Record = { + model: ollamaModel, + messages: [{ role: 'user', content: request.emailContent }], + stream: false, + }; + + // Add num_ctx if configured + const ollamaNumCtx = settings.ollamaNumCtx as number; + if (ollamaNumCtx > 0) { + body.options = { num_ctx: Number(ollamaNumCtx) }; + } + + return { + url: ollamaUrl, + body, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + const msg = d.message as Record | string | undefined; + + if (!msg) { + // Some older/local versions may return data as string or have different keys + const fallback = + (d.result as string) || (d.text as string) || (d.response as string); + return fallback ? fallback.trim() : null; + } + + if (typeof msg === 'string') { + return msg.trim(); + } + + const content = msg.content; + + if (typeof content === 'string') { + return content.trim(); + } + + if (Array.isArray(content)) { + // Find first element that's a string or has text/content fields + const first = content.find( + (c) => typeof c === 'string' || (c && (c.text || c.content)) + ); + if (typeof first === 'string') return first.trim(); + if (first && (first as Record).text) + return String((first as Record).text).trim(); + if (first && (first as Record).content) { + const inner = (first as Record).content; + if (typeof inner === 'string') return inner.trim(); + if (Array.isArray(inner)) + return inner.map((x) => (x as Record).text || x).join(' ').trim(); + } + } + + if (content && typeof content === 'object' && !Array.isArray(content)) { + const c = content as Record; + const textVal = (c.text || c.content || c[0]) as string | undefined; + if (textVal) return String(textVal).trim(); + if (c.parts && Array.isArray(c.parts) && c.parts.length > 0) { + const part = c.parts[0] as Record; + return part.text ? String(part.text).trim() : null; + } + } + + // If no content but msg has text/response/result + if (!content && msg) { + const m = msg as Record; + const fallbackText = (m.text || m.response || m.result) as string | undefined; + return fallbackText ? fallbackText.trim() : null; + } + + return null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, body } = buildRequest(request, settings); + + const ollamaAuthToken = (settings.ollamaAuthToken as string) || ''; + const headers: Record = {}; + if (ollamaAuthToken) headers['Authorization'] = `Bearer ${ollamaAuthToken}`; + + let data: unknown; + + // Try fetchViaTab first (avoids CORS by running fetch inside a same-origin tab). + // If it fails, retry once after a short delay — tab creation can be throttled + // when triggered from non-user-initiated events (e.g. onNewMailReceived). + let lastTabError: Error | null = null; + for (let attempt = 0; attempt < 2; attempt++) { + try { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 1200)); + } + data = await fetchViaTab(url, { + endpoint: '/api/chat', + body, + headers, + resultKey: '__ollama_result', + timeoutMs: attempt === 0 ? 30000 : 45000, + }); + lastTabError = null; + break; + } catch (e) { + lastTabError = e as Error; + console.warn( + `[Ollama] fetchViaTab attempt ${attempt + 1}/2 failed:`, + lastTabError.message + ); + } + } + + if (lastTabError) { + // Fallback: XHR from background page. + // XHR in Firefox extensions does not trigger CORS preflight for permitted + // hosts, unlike fetch() which may send OPTIONS when Content-Type is set. + console.warn( + '[Ollama] fetchViaTab exhausted, falling back to XHR:', + lastTabError.message + ); + data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${url}/api/chat`); + xhr.setRequestHeader('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(headers)) { + xhr.setRequestHeader(key, value); + } + xhr.timeout = 30000; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error(`Failed to parse Ollama response: ${xhr.responseText.substring(0, 200)}`)); + } + } else { + reject( + new Error(`Ollama API error: HTTP ${xhr.status}${xhr.responseText ? ` - ${xhr.responseText.substring(0, 300)}` : ''}`) + ); + } + }; + xhr.onerror = () => { + reject(new Error('Ollama XHR network error — check that Ollama is running and CORS is not blocking')); + }; + xhr.ontimeout = () => { + reject(new Error('Ollama XHR timeout (30s)')); + }; + xhr.send(JSON.stringify(body)); + }); + } + + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from Ollama response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/openai-compat.ts b/src/background/ai/providers/openai-compat.ts new file mode 100644 index 0000000..b390e6a --- /dev/null +++ b/src/background/ai/providers/openai-compat.ts @@ -0,0 +1,155 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; +import { fetchViaTab } from '../../../shared/tab-fetch'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + body: Record; + apiKey: string; + isLocalhost: boolean; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const rawUrl = ((settings.customBaseUrl as string) || '').replace(/\/$/, ''); + const model = (settings.customModel as string) || ''; + const apiKey = (settings.apiKey as string) || ''; + + // Strip /v1 suffix if present — we always append /v1/chat/completions + const baseUrl = rawUrl.replace(/\/v1\/?$/, ''); + + const body: Record = { + model, + messages: [{ role: 'user', content: request.emailContent }], + max_tokens: 8192, + temperature: 0.6, + top_p: 0.95, + }; + + const isLocalhost = + baseUrl.startsWith('http://localhost') || + baseUrl.startsWith('http://127.0.0.1'); + + return { + url: baseUrl, + body, + apiKey, + isLocalhost, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.choices || !Array.isArray(d.choices) || d.choices.length === 0) { + return null; + } + + const choice = d.choices[0] as Record; + const message = choice.message as Record | undefined; + + // Try multiple possible content locations + const content = + message?.content || + choice.text || + (choice.delta as Record)?.content; + + if (typeof content === 'string') { + return content.trim(); + } + + // Some models return reasoning in separate field + if (message?.reasoning_content && typeof message.reasoning_content === 'string') { + return message.reasoning_content.trim(); + } + + return null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, body, apiKey, isLocalhost } = buildRequest(request, settings); + + let data: unknown; + + if (isLocalhost) { + // Use tab injection for localhost (browser context, no CORS restrictions) + const headers: Record = {}; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + + data = await fetchViaTab(url, { + endpoint: '/v1/chat/completions', + body, + headers, + resultKey: '__openai_compat_result', + }); + + const d = data as Record; + if (!d.choices || !Array.isArray(d.choices) || d.choices.length === 0) { + throw new Error('Invalid OpenAI-compatible response format'); + } + } else { + // Direct fetch for non-localhost endpoints + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url + '/v1/chat/completions', { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + + if ( + response.status === 429 || + errorMessage.includes('quota') || + errorMessage.includes('rate limit') + ) { + errorMessage = + 'API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key.'; + } + throw new Error(errorMessage); + } + + data = await response.json(); + } + + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from OpenAI-compatible response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/providers/openai.ts b/src/background/ai/providers/openai.ts new file mode 100644 index 0000000..f72c3d0 --- /dev/null +++ b/src/background/ai/providers/openai.ts @@ -0,0 +1,119 @@ +import type { AnalyzeRequest, AnalyzeResult } from '../types'; +import { matchLabelFromResponse } from '../prompt'; + +declare const browser: any; + +export interface ProviderRequest { + url: string; + options: RequestInit; +} + +export function buildRequest( + request: AnalyzeRequest, + settings: Record +): ProviderRequest { + const apiKey = settings.apiKey as string; + + const body = { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: request.emailContent }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95, + }; + + return { + url: 'https://api.openai.com/v1/chat/completions', + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }, + }; +} + +export function parseResponse(data: unknown): string | null { + const d = data as Record; + if (!d.choices || !Array.isArray(d.choices) || d.choices.length === 0) { + return null; + } + + const choice = d.choices[0] as Record; + const message = choice.message as Record | undefined; + + // Try multiple possible content locations + const content = + message?.content || + choice.text || + (choice.delta as Record)?.content; + + if (typeof content === 'string') { + return content.trim(); + } + + // Some models return reasoning in separate field + if (message?.reasoning_content && typeof message.reasoning_content === 'string') { + return message.reasoning_content.trim(); + } + + return null; +} + +export async function analyze( + request: AnalyzeRequest, + settings: Record +): Promise { + const { url, options } = buildRequest(request, settings); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + let response: Response; + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + const err = errorData as Record; + errorMessage = + (err.error as Record)?.message || + (err.message as string) || + errorMessage; + } catch { + // ignore parse errors + } + + if ( + response.status === 429 || + errorMessage.includes('quota') || + errorMessage.includes('rate limit') + ) { + errorMessage = + 'API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key.'; + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const rawText = parseResponse(data); + + if (!rawText) { + throw new Error('No text extracted from OpenAI response'); + } + + return { + suggestedLabel: matchLabelFromResponse(rawText, request.labels), + rawResponse: rawText, + }; +} diff --git a/src/background/ai/rate-limiter.ts b/src/background/ai/rate-limiter.ts new file mode 100644 index 0000000..ee937fc --- /dev/null +++ b/src/background/ai/rate-limiter.ts @@ -0,0 +1,244 @@ +declare const browser: any; + +export interface RateLimitResult { + allowed: boolean; + waitTime: number; + keyIndex: number | null; + message?: string; +} + +// Mutex for atomic rate limit operations +let geminiRateLimitMutex: Promise = Promise.resolve({ + allowed: true, + waitTime: 0, + keyIndex: 0, +}); + +export async function checkAndTrackGeminiRateLimit( + keyIndex: number | null = null +): Promise { + // Chain onto mutex for atomic operation + return (geminiRateLimitMutex = geminiRateLimitMutex.then(async () => { + const now = Date.now(); + const data = await browser.storage.local.get([ + 'geminiApiKeys', + 'geminiRateLimits', + 'currentGeminiKeyIndex', + 'geminiPaidPlan', + 'geminiRateLimit', // Legacy single-key + ]); + + // Skip for paid plan + if (data.geminiPaidPlan) { + return { allowed: true, waitTime: 0, keyIndex: keyIndex ?? 0 }; + } + + // Multi-key mode + if (data.geminiApiKeys?.length > 0) { + const keys: string[] = data.geminiApiKeys; + const rateLimits: Array<{ + requests: number[]; + dailyCount: number; + dailyResetTime: number; + }> = + data.geminiRateLimits || + keys.map(() => ({ + requests: [], + dailyCount: 0, + dailyResetTime: now + 24 * 60 * 60 * 1000, + })); + let currentIndex = keyIndex ?? (data.currentGeminiKeyIndex || 0); + + const startIndex = currentIndex; + let attempts = 0; + + while (attempts < keys.length) { + const rateLimit = rateLimits[currentIndex]; + + // Reset daily if expired + if (now > rateLimit.dailyResetTime) { + rateLimit.dailyCount = 0; + rateLimit.dailyResetTime = now + 24 * 60 * 60 * 1000; + rateLimit.requests = []; + } + + // Clean old requests + const oneMinuteAgo = now - 60000; + rateLimit.requests = rateLimit.requests.filter( + (t: number) => t > oneMinuteAgo + ); + + // Check availability + if (rateLimit.dailyCount < 20) { + // Check if we need to wait + if (rateLimit.requests.length > 0) { + const lastRequest = Math.max(...rateLimit.requests); + const timeSinceLastRequest = now - lastRequest; + const minInterval = 12000; // 12 seconds + + if (timeSinceLastRequest < minInterval) { + const waitTime = Math.ceil( + (minInterval - timeSinceLastRequest) / 1000 + ); + // Track request now (with wait) + rateLimit.requests.push(now); + rateLimit.dailyCount += 1; + + await browser.storage.local.set({ + currentGeminiKeyIndex: currentIndex, + geminiRateLimits: rateLimits, + }); + + if ((window as any).debugLogger) { + (window as any).debugLogger.info( + '[RateLimit]', + `Gemini Key #${currentIndex + 1}: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute` + ); + } + + return { allowed: true, waitTime, keyIndex: currentIndex }; + } + } + + // Track request immediately + rateLimit.requests.push(now); + rateLimit.dailyCount += 1; + + await browser.storage.local.set({ + currentGeminiKeyIndex: currentIndex, + geminiRateLimits: rateLimits, + }); + + if ((window as any).debugLogger) { + (window as any).debugLogger.info( + '[RateLimit]', + `Gemini Key #${currentIndex + 1}: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute` + ); + } + + return { allowed: true, waitTime: 0, keyIndex: currentIndex }; + } + + currentIndex = (currentIndex + 1) % keys.length; + attempts++; + } + + return { + allowed: false, + waitTime: 0, + keyIndex: currentIndex, + message: `All ${keys.length} Gemini API keys have reached their daily limit (20/day each). Please wait for reset or add more API keys in settings.`, + }; + } + + // Legacy single-key mode + const rateLimit: { + requests: number[]; + dailyCount: number; + dailyResetTime: number; + } = data.geminiRateLimit || { + requests: [], + dailyCount: 0, + dailyResetTime: now + 24 * 60 * 60 * 1000, + }; + + // Reset daily if expired + if (now > rateLimit.dailyResetTime) { + rateLimit.dailyCount = 0; + rateLimit.dailyResetTime = now + 24 * 60 * 60 * 1000; + rateLimit.requests = []; + } + + // Check daily limit + if (rateLimit.dailyCount >= 20) { + const hoursUntilReset = Math.ceil( + (rateLimit.dailyResetTime - now) / (1000 * 60 * 60) + ); + return { + allowed: false, + waitTime: 0, + keyIndex: null, + message: `Gemini free tier daily limit reached (20/day). Resets in ${hoursUntilReset} hours. Upgrade to paid plan or add multiple API keys in settings to remove limits.`, + }; + } + + // Clean old requests + const oneMinuteAgo = now - 60000; + rateLimit.requests = rateLimit.requests.filter( + (t: number) => t > oneMinuteAgo + ); + + // Check if need to wait + if (rateLimit.requests.length > 0) { + const lastRequest = Math.max(...rateLimit.requests); + const timeSinceLastRequest = now - lastRequest; + const minInterval = 12000; + + if (timeSinceLastRequest < minInterval) { + const waitTime = Math.ceil( + (minInterval - timeSinceLastRequest) / 1000 + ); + // Track now (with wait) + rateLimit.requests.push(now); + rateLimit.dailyCount += 1; + await browser.storage.local.set({ geminiRateLimit: rateLimit }); + + if ((window as any).debugLogger) { + (window as any).debugLogger.info( + '[RateLimit]', + `Gemini requests: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute` + ); + } + + return { allowed: true, waitTime, keyIndex: null }; + } + } + + // Track request + rateLimit.requests.push(now); + rateLimit.dailyCount += 1; + await browser.storage.local.set({ geminiRateLimit: rateLimit }); + + if ((window as any).debugLogger) { + (window as any).debugLogger.info( + '[RateLimit]', + `Gemini requests: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute` + ); + } + + return { allowed: true, waitTime: 0, keyIndex: null }; + }).catch((err: Error) => { + console.error('[RateLimit] Mutex internal error:', err); + geminiRateLimitMutex = Promise.resolve({ + allowed: true, + waitTime: 0, + keyIndex: 0, + }); + return { + allowed: false, + waitTime: 0, + keyIndex: null, + message: 'Rate limit check internal error: ' + err.message, + }; + })) as Promise; +} + +// Deprecated: Use checkAndTrackGeminiRateLimit instead +export async function checkGeminiRateLimit(): Promise { + console.warn( + '[Deprecated] checkGeminiRateLimit: Use checkAndTrackGeminiRateLimit instead' + ); + const result = await checkAndTrackGeminiRateLimit(); + // Note: This deprecated wrapper already tracked the request, so callers + // using this will need to NOT call trackGeminiRequest separately + return result; +} + +// Deprecated: No longer needed - tracking is done in checkAndTrackGeminiRateLimit +export async function trackGeminiRequest( + _keyIndex: number | null +): Promise { + console.warn( + '[Deprecated] trackGeminiRequest: No longer needed - tracking is done in checkAndTrackGeminiRateLimit' + ); +} diff --git a/src/background/ai/types.ts b/src/background/ai/types.ts new file mode 100644 index 0000000..83707de --- /dev/null +++ b/src/background/ai/types.ts @@ -0,0 +1,18 @@ +import type { EmailContext } from '../../types/email'; + +export interface AnalyzeRequest { + emailContent: string; + emailContext: EmailContext | null; + labels: string[]; + customPrompt?: string; +} + +export interface AnalyzeResult { + suggestedLabel: string; + confidence?: number; + rawResponse: string; +} + +export interface AIProvider { + analyze(request: AnalyzeRequest, settings: Record): Promise; +} diff --git a/src/background/auto-sort/index.ts b/src/background/auto-sort/index.ts new file mode 100644 index 0000000..262b4a7 --- /dev/null +++ b/src/background/auto-sort/index.ts @@ -0,0 +1,3 @@ +export { processWithConcurrency, classifyAndMove, handleNewMail } from './processor'; +export { registerAutoSortListener, isAutoSortRegistered } from './listener'; +export type { AutoSortSettings } from './types'; diff --git a/src/background/auto-sort/listener.ts b/src/background/auto-sort/listener.ts new file mode 100644 index 0000000..7f30c63 --- /dev/null +++ b/src/background/auto-sort/listener.ts @@ -0,0 +1,35 @@ +declare const browser: any; +declare const messenger: any; + +import { handleNewMail } from './processor'; + +let registered = false; + +export function registerAutoSortListener( + analyzeFn: (content: string, context: any) => Promise, + applyLabelsFn: (msgs: any[], label: string) => Promise, + getSettingsFn: () => Promise<{ autoSortEnabled?: boolean; enableAi?: boolean; aiProvider?: string }>, + getConcurrencyFn: (provider: string) => number, + debugLogger?: any +): void { + if (registered) return; + registered = true; + + browser.messages.onNewMailReceived.addListener( + async (folder: any, messageList: any) => { + const settings = await getSettingsFn(); + const provider = settings.aiProvider || 'gemini'; + const limit = getConcurrencyFn(provider); + + await handleNewMail( + folder, messageList, provider, limit, + analyzeFn, applyLabelsFn, settings, debugLogger + ); + }, + false + ); +} + +export function isAutoSortRegistered(): boolean { + return registered; +} diff --git a/src/background/auto-sort/processor.ts b/src/background/auto-sort/processor.ts new file mode 100644 index 0000000..b786e44 --- /dev/null +++ b/src/background/auto-sort/processor.ts @@ -0,0 +1,88 @@ +declare const browser: any; + +import { extractEmailContext } from '../email/extractor'; + +export async function processWithConcurrency( + items: T[], + processor: (item: T) => Promise, + limit: number = 3 +): Promise[]> { + const results: Promise[] = []; + const executing = new Set>(); + + for (const item of items) { + const promise = processor(item).then((result) => { + executing.delete(promise); + return result; + }); + executing.add(promise); + results.push(promise); + + if (executing.size >= limit) { + await Promise.race(executing); + } + } + + return Promise.allSettled(results); +} + +export async function classifyAndMove( + message: any, + analyzeFn: (content: string, context: any) => Promise, + applyLabelsFn: (msgs: any[], label: string) => Promise, + debugLogger?: any +): Promise { + try { + const fullMessage = await browser.messages.getFull(message.id); + if (!fullMessage) return; + + const emailContext = await extractEmailContext(fullMessage, message); + if (!emailContext) return; + + const emailContent = emailContext.body; + if (!emailContent?.trim()) return; + + const label = await analyzeFn(emailContent, emailContext); + if (!label || String(label).trim().toLowerCase() === 'null') return; + + await applyLabelsFn([message], label); + + if (debugLogger) { + debugLogger.info('[AutoSort]', `Auto-sorted message ${message.id} to ${label}`); + } + } catch (err: any) { + if (debugLogger) { + debugLogger.warn('[AutoSort]', `Failed to auto-sort message ${message.id}: ${err.message}`); + } + } +} + +export async function handleNewMail( + folder: any, + messageList: any, + provider: string, + concurrencyLimit: number, + analyzeFn: (content: string, context: any) => Promise, + applyLabelsFn: (msgs: any[], label: string) => Promise, + settings: { autoSortEnabled?: boolean; enableAi?: boolean }, + debugLogger?: any +): Promise { + if (!settings.autoSortEnabled) return; + if (settings.enableAi === false) return; + if (!folder.specialUse?.includes('inbox')) return; + + if (debugLogger) { + debugLogger.info('[AutoSort]', `Processing new mail with concurrency=${concurrencyLimit} for provider=${provider}`); + } + + let page = messageList; + while (true) { + await processWithConcurrency( + page.messages, + (msg) => classifyAndMove(msg, analyzeFn, applyLabelsFn, debugLogger), + concurrencyLimit + ); + if (!page.id) break; + page = await browser.messages.continueList(page.id); + } +} diff --git a/src/background/auto-sort/types.ts b/src/background/auto-sort/types.ts new file mode 100644 index 0000000..1d2a9da --- /dev/null +++ b/src/background/auto-sort/types.ts @@ -0,0 +1,5 @@ +export interface AutoSortSettings { + autoSortEnabled: boolean; + enableAi: boolean; + aiProvider: string; +} diff --git a/src/background/batch/engine.ts b/src/background/batch/engine.ts new file mode 100644 index 0000000..96373ce --- /dev/null +++ b/src/background/batch/engine.ts @@ -0,0 +1,171 @@ +declare const browser: any; + +import type { BatchState } from './types'; +import { DEFAULT_BATCH_CONFIG } from './types'; +import { broadcastBatchProgress } from './progress'; + +function createBatchState(total: number, provider: string): BatchState { + return { + running: true, + cancelled: false, + paused: false, + total, + completed: 0, + failed: 0, + skipped: 0, + provider, + chunkIndex: 0, + totalChunks: 0, + }; +} + +async function waitWhilePaused(state: BatchState): Promise { + while (state.paused && !state.cancelled) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return !state.cancelled; +} + +export interface BatchDependencies { + getFullMessage: (id: number) => Promise; + extractEmailContext: (fullMsg: any, header: any) => Promise<{ body: string }>; + analyzeEmailContent: (content: string, context: any) => Promise; + applyLabels: (msgs: any[], label: string) => Promise>; + showNotification: (title: string, message: string) => Promise; + debugLogger?: any; +} + +export async function batchAnalyzeEmails( + messages: any[], + deps: BatchDependencies, + externalState?: BatchState +): Promise { + const settingsData = (await browser.storage.local.get(['aiProvider', 'batchChunkSize'])) as { + aiProvider?: string; + batchChunkSize?: number; + }; + const provider = settingsData.aiProvider || 'gemini'; + const chunkSize = settingsData.batchChunkSize || 5; + + const state = externalState || createBatchState(messages.length, provider); + // When using external state, ensure it's initialized for this run + if (externalState) { + state.running = true; + state.cancelled = false; + state.paused = false; + state.total = messages.length; + state.completed = 0; + state.failed = 0; + state.skipped = 0; + state.provider = provider; + state.chunkIndex = 0; + state.totalChunks = 0; + } + await broadcastBatchProgress(state, 'running'); + + if (deps.debugLogger) { + deps.debugLogger.info('[Batch]', `Starting batch: ${messages.length} emails, provider=${provider}, chunkSize=${chunkSize}`); + } + + async function processOne(message: any): Promise { + if (state.cancelled) return; + if (state.paused) { + const resumed = await waitWhilePaused(state); + if (!resumed) return; + } + + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const fullMessage = await deps.getFullMessage(message.id); + if (!fullMessage) { + state.skipped++; + return; + } + + const emailContext = await deps.extractEmailContext(fullMessage, message); + const emailContent = emailContext.body; + if (!emailContent || !emailContent.trim()) { + state.skipped++; + return; + } + + const label = await deps.analyzeEmailContent(emailContent, emailContext); + + if (!label || String(label).trim().toLowerCase() === 'null') { + state.skipped++; + return; + } + + const results = await deps.applyLabels([message], label); + // Check if the move actually succeeded + const failed = results.filter((r) => r.status === 'Error'); + if (failed.length > 0) { + state.failed += failed.length; + state.completed += results.length - failed.length; + for (const f of failed) { + console.error(`[Batch] Move failed: ${f.destination}${f.error ? ' - ' + f.error : ''}`); + } + } else { + state.completed += results.length; + } + return; + } catch (err: any) { + if (deps.debugLogger) { + deps.debugLogger.warn('[Batch]', `Attempt ${attempt} failed for msg ${message.id}: ${err.message}`); + } + if (attempt === 2) { + state.failed++; + console.error(`[Batch] Message ${message.id} failed after retry:`, err.message); + } else { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + } + } + + const totalChunks = Math.ceil(messages.length / chunkSize); + state.totalChunks = totalChunks; + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + if (state.cancelled) break; + + while (state.paused && !state.cancelled) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (state.cancelled) break; + + const chunkStart = chunkIndex * chunkSize; + const chunkEnd = Math.min(chunkStart + chunkSize, messages.length); + const chunkMessages = messages.slice(chunkStart, chunkEnd); + + const chunkPromises = chunkMessages.map((msg) => processOne(msg)); + await Promise.allSettled(chunkPromises); + + state.chunkIndex = chunkIndex + 1; + await broadcastBatchProgress(state, 'running'); + } + + const finalStatus = state.cancelled ? 'cancelled' : 'done'; + state.running = false; + await broadcastBatchProgress(state, finalStatus); + + setTimeout(async () => { + await browser.storage.local.remove('currentBatch').catch(() => {}); + }, 6000); + + if (deps.debugLogger) { + deps.debugLogger.info('[Batch]', `Batch ${finalStatus}: completed=${state.completed}, failed=${state.failed}, skipped=${state.skipped}`); + } + + const { completed, failed, skipped, total } = state; + if (finalStatus === 'cancelled') { + await deps.showNotification('AutoSort+ Batch Cancelled', + `Stopped after ${completed + failed + skipped}/${total} emails. Sorted: ${completed}, failed: ${failed}.`); + } else if (failed === 0 && skipped === 0) { + await deps.showNotification('AutoSort+ Batch Complete', + `Successfully sorted all ${completed} emails.`); + } else { + await deps.showNotification('AutoSort+ Batch Complete', + `Processed ${total} emails — sorted: ${completed}, skipped: ${skipped}, failed: ${failed}.`); + } +} diff --git a/src/background/batch/index.ts b/src/background/batch/index.ts new file mode 100644 index 0000000..c11fcf1 --- /dev/null +++ b/src/background/batch/index.ts @@ -0,0 +1,5 @@ +export { batchAnalyzeEmails } from './engine'; +export type { BatchDependencies } from './engine'; +export { broadcastBatchProgress } from './progress'; +export type { BatchState, BatchProgressPayload } from './types'; +export { DEFAULT_BATCH_CONFIG } from './types'; diff --git a/src/background/batch/progress.ts b/src/background/batch/progress.ts new file mode 100644 index 0000000..585e571 --- /dev/null +++ b/src/background/batch/progress.ts @@ -0,0 +1,27 @@ +declare const browser: any; + +import type { BatchState, BatchProgressPayload } from './types'; +import type { BatchStatus } from '../../types/batch'; + +export async function broadcastBatchProgress( + state: BatchState, + status: BatchStatus | 'done' | 'cancelled' = 'running' +): Promise { + const payload: BatchProgressPayload = { + action: 'batchProgress', + status, + total: state.total, + completed: state.completed, + failed: state.failed, + skipped: state.skipped, + provider: state.provider, + chunkIndex: state.chunkIndex, + totalChunks: state.totalChunks, + }; + try { + await browser.storage.local.set({ currentBatch: { ...payload, startTime: Date.now() } }); + await browser.runtime.sendMessage(payload).catch(() => {}); + } catch { + // options page may not be open + } +} diff --git a/src/background/batch/types.ts b/src/background/batch/types.ts new file mode 100644 index 0000000..e3bae46 --- /dev/null +++ b/src/background/batch/types.ts @@ -0,0 +1,36 @@ +import type { BatchStatus } from '../../types/batch'; + +export interface BatchState { + running: boolean; + cancelled: boolean; + paused: boolean; + total: number; + completed: number; + failed: number; + skipped: number; + provider: string; + chunkIndex: number; + totalChunks: number; +} + +export interface BatchProgressPayload { + action: string; + status: BatchStatus | 'done' | 'cancelled'; + total: number; + completed: number; + failed: number; + skipped: number; + provider: string; + chunkIndex: number; + totalChunks: number; +} + +export const DEFAULT_BATCH_CONFIG: Record = { + gemini: { concurrency: 1, delayMs: 0 }, + openai: { concurrency: 3, delayMs: 500 }, + anthropic: { concurrency: 2, delayMs: 500 }, + groq: { concurrency: 5, delayMs: 200 }, + mistral: { concurrency: 2, delayMs: 500 }, + ollama: { concurrency: 1, delayMs: 0 }, + 'openai-compatible': { concurrency: 2, delayMs: 500 }, +}; diff --git a/src/background/email/extractor.ts b/src/background/email/extractor.ts new file mode 100644 index 0000000..94ead51 --- /dev/null +++ b/src/background/email/extractor.ts @@ -0,0 +1,60 @@ +import type { EmailContext, Attachment } from '../../types/email'; + +declare const browser: any; +declare const messenger: any; + +export async function extractEmailContext( + fullMessage: any, + messageHeader: any +): Promise { + const subject = fullMessage.headers?.Subject?.[0] || messageHeader?.subject || ''; + const author = fullMessage.headers?.From?.[0] || messageHeader?.author || ''; + + const attachments: Attachment[] = []; + async function collectAttachments(parts: any[]): Promise { + if (!parts) return; + for (const part of parts) { + if (part.parts) await collectAttachments(part.parts); + if (part.name) { + const isInlineText = + (part.contentType === 'text/plain' || part.contentType === 'text/html') && + !part.contentDisposition; + if (!isInlineText) { + attachments.push({ + name: part.name as string, + contentType: (part.contentType as string) || 'unknown', + size: (part.size as number) || 0, + }); + } + } + } + } + if (fullMessage.parts) await collectAttachments(fullMessage.parts); + + async function extractBodyText(parts: any[]): Promise { + if (!parts) return ''; + let text = ''; + for (const part of parts) { + if (part.parts) text += await extractBodyText(part.parts); + if (part.contentType === 'text/plain') { + text += part.body + '\n'; + } else if (part.contentType === 'text/html' && !text) { + text = await browser.messengerUtilities.convertToPlainText(part.body); + } else if (part.contentType === 'message/rfc822' && part.body) { + text += part.body + '\n'; + } + } + return text; + } + + const body = fullMessage.parts + ? await extractBodyText(fullMessage.parts) + : fullMessage.body || ''; + + return { subject, author, attachments, body }; +} + +export async function extractTextFromParts(fullMessage: any): Promise { + const context = await extractEmailContext(fullMessage, null); + return context.body; +} diff --git a/src/background/email/index.ts b/src/background/email/index.ts new file mode 100644 index 0000000..fac9ef9 --- /dev/null +++ b/src/background/email/index.ts @@ -0,0 +1 @@ +export { extractEmailContext, extractTextFromParts } from './extractor'; diff --git a/src/background/folders/history.ts b/src/background/folders/history.ts new file mode 100644 index 0000000..f83f94f --- /dev/null +++ b/src/background/folders/history.ts @@ -0,0 +1,41 @@ +declare const browser: any; + +export interface MoveHistoryEntry { + timestamp: string; + subject: string; + status: string; + destination: string; + error?: string; +} + +const MAX_HISTORY = 100; + +export async function storeMoveHistory(result: Omit): Promise { + try { + const data = (await browser.storage.local.get('moveHistory')) as { + moveHistory?: MoveHistoryEntry[]; + }; + const history: MoveHistoryEntry[] = data.moveHistory || []; + history.unshift({ + timestamp: new Date().toISOString(), + ...result, + }); + if (history.length > MAX_HISTORY) { + history.length = MAX_HISTORY; + } + await browser.storage.local.set({ moveHistory: history }); + } catch (error) { + console.error('Error storing move history:', error); + } +} + +export async function getMoveHistory(): Promise { + const data = (await browser.storage.local.get('moveHistory')) as { + moveHistory?: MoveHistoryEntry[]; + }; + return data.moveHistory || []; +} + +export async function clearMoveHistory(): Promise { + await browser.storage.local.set({ moveHistory: [] }); +} diff --git a/src/background/folders/index.ts b/src/background/folders/index.ts new file mode 100644 index 0000000..c8668ad --- /dev/null +++ b/src/background/folders/index.ts @@ -0,0 +1,4 @@ +export { applyLabelsToMessages } from './operations'; +export type { MoveResult } from './operations'; +export { storeMoveHistory, getMoveHistory, clearMoveHistory } from './history'; +export type { MoveHistoryEntry } from './history'; diff --git a/src/background/folders/operations.ts b/src/background/folders/operations.ts new file mode 100644 index 0000000..e87ae74 --- /dev/null +++ b/src/background/folders/operations.ts @@ -0,0 +1,135 @@ +declare const browser: any; + +import { storeMoveHistory } from './history'; +import type { MoveHistoryEntry } from './history'; + +export interface MoveResult { + subject: string; + status: string; + destination: string; + error?: string; +} + +interface FolderCache { + get(key: string): any; + set(key: string, value: any): void; + readonly size: number; +} + +interface MoveOptions { + label: string; + notificationId?: string; + debugLogger?: any; +} + +export async function applyLabelsToMessages( + messages: any[], + label: string +): Promise { + const messageCount = messages.length; + const moveResults: MoveResult[] = []; + let successCount = 0; + let errorCount = 0; + + const folderCache = new Map(); + const accountCache = new Map(); + + async function getAccount(accountId: string): Promise { + if (!accountCache.has(accountId)) { + const account = await browser.accounts.get(accountId); + accountCache.set(accountId, account); + } + return accountCache.get(accountId); + } + + function buildFolderMap(folders: any[], prefix: string, accountId: string): void { + if (!folders) return; + for (const folder of folders) { + const fullName = prefix ? `${prefix}/${folder.name}` : folder.name; + folderCache.set(`${accountId}:${fullName}`, folder); + if (folder.subFolders) { + buildFolderMap(folder.subFolders, fullName, accountId); + } + } + } + + // Pre-build folder cache + const uniqueAccountIds = [...new Set( + messages.map((m: any) => m.folder?.accountId).filter((id: any) => id) + )]; + for (const accountId of uniqueAccountIds) { + const account = await getAccount(accountId as string); + buildFolderMap(account.folders, '', accountId as string); + } + + for (const message of messages) { + const account = await getAccount(message.folder.accountId); + + // Cached folder lookup + let targetFolder = folderCache.get(`${message.folder.accountId}:${label}`); + + // Handle subfolder paths + if (!targetFolder && label.includes('/')) { + targetFolder = folderCache.get(`${message.folder.accountId}:${label}`); + } + + // Auto-create missing folder + if (!targetFolder) { + const looksImported = label.includes('/') || label.includes('\\'); + if (!looksImported) { + try { + const parentFolder = account.folders?.[0] || null; + if (parentFolder && browser.folders?.create) { + const created = await browser.folders.create(parentFolder, label); + if (created) { + targetFolder = created; + folderCache.set(`${message.folder.accountId}:${label}`, created); + } + } + } catch (createError) { + console.error(`Failed to create folder "${label}":`, createError); + } + } + } + + try { + if (!targetFolder) { + errorCount++; + console.error( + `Folder "${label}" not found in account "${message.folder.accountId}". ` + + `Please create this folder in Thunderbird, or check your label settings. (msg ${message.id})` + ); + const result: MoveResult = { + subject: message.subject || '(No subject)', + status: 'Error', + destination: `Not found: ${label}`, + }; + moveResults.push(result); + await storeMoveHistory(result); + continue; + } + + await browser.messages.move([message.id], targetFolder.id); + successCount++; + const result: MoveResult = { + subject: message.subject || '(No subject)', + status: 'Success', + destination: targetFolder.name, + }; + moveResults.push(result); + await storeMoveHistory(result); + } catch (moveError: any) { + errorCount++; + const result: MoveResult = { + subject: message.subject || '(No subject)', + status: 'Error', + destination: label, + error: moveError.message, + }; + moveResults.push(result); + await storeMoveHistory(result); + } + } + + return moveResults; +} diff --git a/src/background/index.ts b/src/background/index.ts new file mode 100644 index 0000000..68d9be9 --- /dev/null +++ b/src/background/index.ts @@ -0,0 +1,494 @@ +/** + * Background script entry point — bundles all TypeScript modules and registers + * them under `window._ts` for migration from legacy background.js. + * + * Migration path: legacy JS calls `win.debugLogger` → change to `window._ts.logger`. + * Each function moved to `_ts` can then be deleted from background.js. + */ + +import { DebugLogger, getStorage, setStorage, i18n, applyTranslations } from '../shared'; +import { analyzeEmail, injectPlaceholders, DEFAULT_PROMPT, stripCodeFences, matchLabelFromResponse } from './ai'; +import { analyze as geminiAnalyze } from './ai/providers/gemini'; +import { analyze as openaiAnalyze } from './ai/providers/openai'; +import { analyze as anthropicAnalyze } from './ai/providers/anthropic'; +import { analyze as groqAnalyze } from './ai/providers/groq'; +import { analyze as mistralAnalyze } from './ai/providers/mistral'; +import { analyze as ollamaAnalyze } from './ai/providers/ollama'; +import { analyze as openaiCompatAnalyze } from './ai/providers/openai-compat'; +import { checkAndTrackGeminiRateLimit as _checkRateLimit } from './ai/rate-limiter'; +import { extractEmailContext, extractTextFromParts } from './email/extractor'; +import { applyLabelsToMessages } from './folders/operations'; +import { storeMoveHistory, getMoveHistory, clearMoveHistory } from './folders/history'; +import { batchAnalyzeEmails } from './batch/engine'; +import { broadcastBatchProgress } from './batch/progress'; +import { DEFAULT_BATCH_CONFIG } from './batch/types'; +import { processWithConcurrency, classifyAndMove, handleNewMail } from './auto-sort/processor'; +import { registerAutoSortListener } from './auto-sort/listener'; + +// ── Initialize logger ────────────────────────────────────────────────────── +const logger = new DebugLogger(); +logger.init().catch(() => {}); + +// ── Register all TS modules on window._ts ────────────────────────────────── + +interface TSModules { + logger: DebugLogger; + storage: { get: typeof getStorage; set: typeof setStorage }; + i18n: typeof i18n; + applyTranslations: typeof applyTranslations; + analyzeEmail: typeof analyzeEmail; + injectPlaceholders: typeof injectPlaceholders; + DEFAULT_PROMPT: string; + stripCodeFences: typeof stripCodeFences; + matchLabelFromResponse: typeof matchLabelFromResponse; + providers: { + gemini: typeof geminiAnalyze; + openai: typeof openaiAnalyze; + anthropic: typeof anthropicAnalyze; + groq: typeof groqAnalyze; + mistral: typeof mistralAnalyze; + ollama: typeof ollamaAnalyze; + openaiCompat: typeof openaiCompatAnalyze; + }; + checkRateLimit: typeof checkAndTrackGeminiRateLimit; + extractEmailContext: typeof extractEmailContext; + extractTextFromParts: typeof extractTextFromParts; + applyLabelsToMessages: typeof applyLabelsToMessages; + storeMoveHistory: typeof storeMoveHistory; + getMoveHistory: typeof getMoveHistory; + clearMoveHistory: typeof clearMoveHistory; + batchAnalyzeEmails: typeof batchAnalyzeEmails; + broadcastBatchProgress: typeof broadcastBatchProgress; + BATCH_CONFIG: typeof DEFAULT_BATCH_CONFIG; + processWithConcurrency: typeof processWithConcurrency; + classifyAndMove: typeof classifyAndMove; + handleNewMail: typeof handleNewMail; + registerAutoSortListener: typeof registerAutoSortListener; +} + +const modules: TSModules = { + logger, + storage: { get: getStorage, set: setStorage }, + i18n, + applyTranslations, + analyzeEmail, + injectPlaceholders, + DEFAULT_PROMPT, + stripCodeFences, + matchLabelFromResponse, + providers: { + gemini: geminiAnalyze, + openai: openaiAnalyze, + anthropic: anthropicAnalyze, + groq: groqAnalyze, + mistral: mistralAnalyze, + ollama: ollamaAnalyze, + openaiCompat: openaiCompatAnalyze, + }, + checkRateLimit: _checkRateLimit, + extractEmailContext, + extractTextFromParts, + applyLabelsToMessages, + storeMoveHistory, + getMoveHistory, + clearMoveHistory, + batchAnalyzeEmails, + broadcastBatchProgress, + BATCH_CONFIG: DEFAULT_BATCH_CONFIG, + processWithConcurrency, + classifyAndMove, + handleNewMail, + registerAutoSortListener, +}; + +const win = window as any; + +win._ts = modules; + +// Also maintain backward-compatible globals +if (!win.debugLogger) win.debugLogger = logger; +if (!win.applyTranslations) win.applyTranslations = applyTranslations; +if (!win.extractEmailContext) win.extractEmailContext = extractEmailContext; + +// ── Runtime helpers ───────────────────────────────────────────────────────── + +const _batchState = { + running: false, + cancelled: false, + paused: false, + total: 0, + completed: 0, + failed: 0, + skipped: 0, + provider: '', + chunkIndex: 0, + totalChunks: 0, +}; + +async function _broadcastBatchProgress(status = 'running'): Promise { + const payload = { + action: 'batchProgress', + status, + total: _batchState.total, + completed: _batchState.completed, + failed: _batchState.failed, + skipped: _batchState.skipped, + provider: _batchState.provider, + chunkIndex: _batchState.chunkIndex, + totalChunks: _batchState.totalChunks, + }; + try { + await browser.storage.local.set({ currentBatch: { ...payload, startTime: Date.now() } }); + await browser.runtime.sendMessage(payload).catch(() => {}); + } catch { + // options page may not be open + } +} + +async function _waitWhilePaused(): Promise { + while (_batchState.paused && !_batchState.cancelled) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return !_batchState.cancelled; +} + +async function showNotification( + title: string, + message: string, + type = 'basic' +): Promise { + if (win.debugLogger) { + win.debugLogger.info( + '[AutoSort+]', + `${title}: ${message}` + ); + } + try { + if (browser.notifications?.create) { + const id = `autosort-${Date.now()}`; + await browser.notifications.create(id, { + type, + iconUrl: browser.runtime.getURL('icons/icon-48.png'), + title, + message, + eventTime: Date.now(), + priority: 2, + requireInteraction: true, + }); + return id; + } + } catch { + // notifications not supported + } + return null; +} + +async function updateNotification( + id: string | null, + title: string, + message: string +): Promise { + if (win.debugLogger) { + win.debugLogger.info( + '[AutoSort+]', + `${title}: ${message}` + ); + } + try { + if (browser.notifications?.clear && id) { + await browser.notifications.clear(id); + } + } catch { + // notifications not supported + } + return showNotification(title, message); +} + +// ── Thin delegation wrappers ──────────────────────────────────────────────── + +async function checkAndTrackGeminiRateLimit(keyIndex: number | null = null) { + return _checkRateLimit(keyIndex); +} + +async function analyzeEmailContent(emailContent: string, emailContext = null) { + const settings = await browser.storage.local.get([ + 'apiKey', 'geminiApiKeys', 'currentGeminiKeyIndex', + 'aiProvider', 'labels', 'enableAi', 'geminiPaidPlan', + 'geminiRateLimit', 'geminiRateLimits', 'ollamaUrl', 'ollamaModel', + 'ollamaCustomModel', 'ollamaAuthToken', 'ollamaCpuOnly', 'ollamaNumCtx', + 'customBaseUrl', 'customModel', 'customPrompt', + ]); + const provider: string = settings.aiProvider || 'gemini'; + + if (provider === 'gemini' && !settings.geminiPaidPlan) { + const rateLimit = await checkAndTrackGeminiRateLimit(); + if (!rateLimit.allowed) { + throw new Error(rateLimit.message || 'Gemini rate limit reached'); + } + if (rateLimit.waitTime > 0) { + await new Promise((resolve) => setTimeout(resolve, rateLimit.waitTime * 1000)); + } + } + + if (settings.enableAi === false) { + console.warn('AI is disabled'); + return null; + } + + const result = await analyzeEmail( + { + emailContent, + emailContext: emailContext || null, + labels: settings.labels || [], + customPrompt: settings.customPrompt, + }, + provider, + settings + ); + + if (result?.suggestedLabel) { + if (String(result.suggestedLabel).trim().toLowerCase() === 'null') return null; + return result.suggestedLabel; + } + return null; +} + +async function storeMoveHistoryFn(result: Record) { + return storeMoveHistory(result as { subject: string; status: string; destination: string; error?: string }); +} + +async function applyLabelsToMessagesFn(messages: unknown[], label: string) { + return applyLabelsToMessages(messages, label); +} + +async function processWithConcurrencyFn(items: T[], processor: (item: T) => Promise, limit = 3) { + return processWithConcurrency(items, processor, limit); +} + +function registerAutoSortListenerFn() { + registerAutoSortListener( + analyzeEmailContent, + applyLabelsToMessagesFn, + async () => { + const s = await browser.storage.local.get(['autoSortEnabled', 'enableAi', 'aiProvider']); + return s; + }, + (provider: string) => DEFAULT_BATCH_CONFIG[provider]?.concurrency || 3, + win.debugLogger + ); +} + +async function batchAnalyzeEmailsFn(messages: unknown[]) { + const settingsData = await browser.storage.local.get(['aiProvider', 'batchChunkSize']); + const provider: string = settingsData.aiProvider || 'gemini'; + + await _broadcastBatchProgress('running'); + + if (win.debugLogger) { + win.debugLogger.info( + '[Batch]', + `Starting batch via _ts: ${(messages as unknown[]).length} emails` + ); + } + + await batchAnalyzeEmails( + messages as unknown[], + { + getFullMessage: (id) => browser.messages.getFull(id as number), + extractEmailContext, + analyzeEmailContent, + applyLabels: (msgs: unknown[], label: string) => applyLabelsToMessages(msgs as unknown[], label), + showNotification: showNotification as (t: string, m: string) => Promise, + debugLogger: win.debugLogger, + }, + _batchState // Shared state — engine reads/writes same object as UI controls + ); +} + +// ── Message Routing ────────────────────────────────────────────────────────── + +browser.runtime.onMessage.addListener( + (message: Record, _sender: unknown, sendResponse: (r: unknown) => void) => { + if (message.action === 'applyLabels') { + applyLabelsToMessagesFn(message.messages as unknown[], message.label as string) + .then(() => sendResponse({ ok: true })) + .catch((err: Error) => sendResponse({ ok: false, error: err.message })); + return true; + } + if (message.action === 'analyzeEmail') { + analyzeEmailContent(message.emailContent as string) + .then((label) => sendResponse({ label })) + .catch((err: Error) => sendResponse({ label: null, error: err.message })); + return true; + } + if (message.action === 'startOllamaPull') { + (async () => { + try { + const { ollamaUrl, model, headers } = message as Record; + const ollamaHeaders = Object.assign({}, headers as Record, { + 'Content-Type': 'application/json', + }); + const res = await fetch(`${ollamaUrl}/api/pull`, { + method: 'POST', + headers: ollamaHeaders, + body: JSON.stringify({ name: model, stream: true }), + }); + const text = await res.text(); + sendResponse({ ok: true, data: text }); + } catch (e: unknown) { + sendResponse({ ok: false, error: (e as Error).message }); + } + })(); + return true; + } + if (message.action === 'batchControl') { + if (message.command === 'pause') { + _batchState.paused = true; + } else if (message.command === 'resume') { + _batchState.paused = false; + } else if (message.command === 'cancel') { + _batchState.cancelled = true; + _batchState.paused = false; + } + sendResponse({ ok: true }); + } + } +); + +browser.browserAction.onClicked.addListener(() => { + browser.runtime.openOptionsPage(); +}); + +registerAutoSortListenerFn(); + +// ── Context Menus ──────────────────────────────────────────────────────────── + +const PROVIDER_BATCH_CONFIG = DEFAULT_BATCH_CONFIG; + +let _rebuildQueue: Promise = Promise.resolve(); + +async function rebuildAllMenus(labels: string[]): Promise { + // Chain onto the previous rebuild to prevent concurrent runs racing + let resolve!: () => void; + const next = new Promise((r) => { resolve = r; }); + const prev = _rebuildQueue; + _rebuildQueue = next; + await prev; + + try { + // Remove all extension menus, then recreate from scratch. + // Individual browser.menus.remove() is unreliable in Thunderbird. + try { await browser.menus.removeAll(); } catch { /* ok */ } + } catch { + // Ignore errors + } + + // ── Parent menu ── + browser.menus.create({ + id: 'autosort-label', + title: 'AutoSort+ Label', + contexts: ['message_list'], + }); + + // ── Label submenu items ── + if (labels?.length > 0) { + for (const label of labels) { + try { + browser.menus.create({ + id: `label-${label}`, + parentId: 'autosort-label', + title: label, + contexts: ['message_list'], + }); + } catch { + // Ignore + } + } + } + + // ── Analyze action ── + browser.menus.create({ + id: 'autosort-analyze', + title: 'AutoSort+ Analyze with AI', + contexts: ['message_list'], + }); + + resolve(); +} + +browser.storage.local.get(['labels']).then((result: Record) => { + rebuildAllMenus(result.labels as string[]); +}); + +browser.storage.onChanged.addListener((changes: Record) => { + if (changes.labels) { + rebuildAllMenus(changes.labels.newValue as string[]); + } +}); + +browser.menus.onClicked.addListener(async (info: Record, tab: unknown) => { + if (info.parentMenuItemId === 'autosort-label') { + const label = (info.menuItemId as string).replace('label-', ''); + if (win.debugLogger) { + win.debugLogger.info( + '[AutoSort+]', + `Manual label selected: ${label}` + ); + } + await showNotification('AutoSort+', `Applying label: ${label}`); + try { + const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + if (mailTabs?.[0]) { + const messages = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); + if (messages?.messages?.length > 0) { + await applyLabelsToMessages(messages.messages, label); + } else { + await showNotification('AutoSort+ Error', 'No messages selected for labeling.'); + } + } else { + await showNotification('AutoSort+ Error', 'No active mail tab found.'); + } + } catch (error: unknown) { + console.error('Error applying manual label:', error); + await showNotification('AutoSort+ Error', `Error applying label: ${(error as Error).message}`); + } + } else if (info.menuItemId === 'autosort-analyze') { + if (win.debugLogger) { + win.debugLogger.info( + '[AutoSort+]', + 'AI analysis selected - starting batch process' + ); + } + try { + if (_batchState.running) { + await showNotification( + 'AutoSort+ Busy', + 'A batch is already in progress. Please wait or cancel it from the settings page.' + ); + return; + } + const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + if (!mailTabs?.[0]) { + await showNotification('AutoSort+ Error', 'No active mail tab found'); + return; + } + const selectedMessageList = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); + if (!selectedMessageList?.messages?.length) { + await showNotification('AutoSort+ Error', 'No messages selected for analysis'); + return; + } + const messages = selectedMessageList.messages; + await showNotification( + 'AutoSort+ Batch', + `Starting AI analysis of ${messages.length} email${messages.length > 1 ? 's' : ''}...` + ); + batchAnalyzeEmailsFn(messages).catch((err: Error) => { + console.error('[Batch] Unhandled batch error:', err); + showNotification('AutoSort+ Error', `Batch processing failed: ${err.message}`); + }); + } catch (error: unknown) { + console.error('Error starting batch analysis:', error); + await showNotification('AutoSort+ Error', `Error: ${(error as Error).message}`); + } + } +}); diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..1b6d07d --- /dev/null +++ b/src/content.ts @@ -0,0 +1,239 @@ +// ── Debug Log ───────────────────────────────────── + +interface DebugLog { + enabled: boolean; + init(): Promise; + info(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; +} + +const debugLog: DebugLog = { + enabled: false, + + async init() { + try { + const result = await browser.storage.local.get('debugMode'); + this.enabled = !!result.debugMode; + } catch (_e) { + // Ignore errors reading storage + } + + // Listen for changes + browser.storage.onChanged.addListener( + (changes: Record, area: string) => { + if (area === 'local' && changes.debugMode !== undefined) { + this.enabled = !!changes.debugMode.newValue; + } + } + ); + }, + + info(message: string, data: unknown = null) { + if (this.enabled) { + console.info( + '%c[Content]', + 'color: white; background: #00BCD4; padding: 2px 6px; border-radius: 4px;', + message, + data !== null ? data : '' + ); + } + }, + + error(message: string, data: unknown = null) { + // Always output errors + console.error( + '%c[Content]', + 'color: white; background: #F44336; padding: 2px 6px; border-radius: 4px;', + message, + data !== null ? data : '' + ); + }, +}; + +// Initialize debug mode +(async () => { + await debugLog.init(); +})(); + +// ── Message Types ───────────────────────────────── + +interface OllamaFetchMessage { + action: 'ollamaFetch'; + fetchAction: 'pull' | 'chat'; + model: string; + prompt?: string; + headers?: Record; + correlationId?: string; +} + +interface SelectedMessage { + id: string; +} + +// ── Message Listener ────────────────────────────── + +browser.runtime.onMessage.addListener( + ( + message: { action: string } & Record, + _sender: unknown, + sendResponse: (response: unknown) => void + ): boolean => { + if (message.action === 'getSelectedMessages') { + try { + // Get the message list container + const messageList = document.querySelector('#threadTree'); + if (!messageList) { + console.error('Could not find message list'); + sendResponse([]); + return true; + } + + // Get selected rows + const selectedRows = messageList.querySelectorAll('tr.selected'); + if (!selectedRows || selectedRows.length === 0) { + debugLog.info('No messages selected'); + sendResponse([]); + return true; + } + + // Extract message IDs + const selectedMessages: SelectedMessage[] = Array.from(selectedRows) + .map((row) => { + // Try different possible ID attributes + const messageId = + row.getAttribute('data-message-id') || + row.getAttribute('data-id') || + row.getAttribute('id'); + + if (!messageId) { + debugLog.info('Row missing message ID:', row); + return null; + } + + // Clean up the ID if needed + const cleanId = messageId.replace(/^msg-/i, ''); + return { id: cleanId }; + }) + .filter( + (msg): msg is SelectedMessage => + msg !== null && msg.id !== '' + ); + + debugLog.info('Found selected messages:', selectedMessages); + sendResponse(selectedMessages); + } catch (error: unknown) { + console.error('Error getting selected messages:', error); + sendResponse([]); + } + } else if (message.action === 'ollamaFetch') { + // Runs inside a tab at http://localhost:11434 to avoid CORS + (async () => { + try { + const msg = message as unknown as OllamaFetchMessage; + const { fetchAction, model, prompt, headers, correlationId } = msg; + const base = window.location.origin; + + if (fetchAction === 'pull') { + const res = await fetch(`${base}/api/pull`, { + method: 'POST', + headers: Object.assign( + { 'Content-Type': 'application/json' }, + headers || {} + ), + body: JSON.stringify({ name: model, stream: true }), + }); + if (!res.ok) { + const t = await res.text(); + let errorMsg: string = t || `HTTP ${res.status}`; + try { + const j = JSON.parse(t); + if (j.error) errorMsg = j.error; + } catch (_parseErr) { + // Not JSON, use raw text + } + throw new Error(errorMsg); + } + if (!res.body) { + throw new Error('Response body is null'); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + const payload: Record = { + action: 'ollamaPullProgress', + correlationId, + status: data.status || '', + }; + if (data.completed && data.total) { + payload.percent = Math.round( + (data.completed / data.total) * 100 + ); + } + browser.runtime.sendMessage(payload).catch(() => { + // Ignore send errors + }); + } catch (_e) { + // ignore parse errors for partial lines + } + } + } + browser.runtime + .sendMessage({ + action: 'ollamaPullComplete', + correlationId, + ok: true, + }) + .catch(() => { + // Ignore send errors + }); + sendResponse({ ok: true }); + } else if (fetchAction === 'chat') { + const res = await fetch(`${base}/api/chat`, { + method: 'POST', + headers: Object.assign( + { 'Content-Type': 'application/json' }, + headers || {} + ), + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: prompt }], + stream: false, + }), + }); + if (!res.ok) { + const errBody = await res.text(); + let msg = `HTTP ${res.status}`; + try { + const j = JSON.parse(errBody); + if (j.error) msg = j.error; + } catch (_e) { + // Use default error message + } + throw new Error(msg); + } + const data = await res.json(); + sendResponse({ ok: true, data }); + } else { + sendResponse({ ok: false, error: 'unknown fetchAction' }); + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : String(err); + sendResponse({ ok: false, error: message }); + } + })(); + return true; + } + return true; + } +); diff --git a/src/ollama.ts b/src/ollama.ts new file mode 100644 index 0000000..0b93e5d --- /dev/null +++ b/src/ollama.ts @@ -0,0 +1,98 @@ +declare const window: any; + +export interface OllamaOptions { + host?: string; + model?: string; + stream?: boolean; + num_ctx?: number; + authToken?: string; +} + +export interface OllamaModelsResult { + ok: boolean; + response?: unknown; + error?: string; + is_exception?: boolean; +} + +export class Ollama { + host = ''; + model = ''; + stream = false; + num_ctx = 0; + authToken = ''; + + constructor({ + host = '', + model = '', + stream = false, + num_ctx = 0, + authToken = '', + }: OllamaOptions = {}) { + this.host = (host || '').trim().replace(/\/+$/, ''); + if (!this.host) { + throw new Error('Ollama host URL is required'); + } + this.model = model; + this.stream = stream; + this.num_ctx = num_ctx; + this.authToken = authToken || ''; + } + + getHeaders = (): Record => { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + return headers; + }; + + fetchModels = async (): Promise => { + try { + const response = await fetch(this.host + '/api/tags', { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const errorDetail = await response.text(); + console.error(`[AutoSort+] Ollama API request failed: ${response.status} ${response.statusText}, Detail: ${errorDetail}`); + return { ok: false, error: errorDetail }; + } + + const responseData = await response.json(); + return { ok: true, response: responseData }; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[AutoSort+] Ollama API request failed: ${msg}`); + return { is_exception: true, ok: false, error: `Ollama API request failed: ${msg}` }; + } + }; + + fetchResponse = async (messages: unknown[]): Promise => { + try { + const body: Record = { + model: this.model, + messages, + stream: this.stream, + }; + if (this.num_ctx > 0) { + body.options = { num_ctx: parseInt(String(this.num_ctx), 10) }; + } + + return await fetch(this.host + '/api/chat', { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[AutoSort+] Ollama API request failed: ${msg}`); + throw new Error(`Ollama API request failed: ${msg}`); + } + }; +} + +if (typeof window !== 'undefined') { + (window as unknown as Record).Ollama = Ollama; +} diff --git a/src/options/api-test.ts b/src/options/api-test.ts new file mode 100644 index 0000000..7ecbeb2 --- /dev/null +++ b/src/options/api-test.ts @@ -0,0 +1,133 @@ +import { els } from './shared/dom-refs'; +import { showApiTestResult } from './shared/ui'; +import { getGeminiKeys } from './gemini-keys'; + +export async function handleTestApi(): Promise { + const provider = els.aiProviderSelect.value; + const apiKey = + provider === 'gemini' + ? getGeminiKeys().find((k) => k && k.trim()) || '' + : els.apiKeyInput.value.trim(); + + if (provider === 'ollama') { + showApiTestResult( + 'Please use the "Test Ollama Connection" button below', + false, + true + ); + return; + } + if (provider === 'openai-compatible') { + showApiTestResult( + 'Please use the "Test Connection" button in the OpenAI-Compatible section', + false, + true + ); + return; + } + + if (!apiKey) { + showApiTestResult('Please enter an API key', false); + return; + } + + try { + showApiTestResult('Testing connection...', false, true); + + let response: Response; + if (provider === 'gemini') { + response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: 'Test' }] }], + generationConfig: { maxOutputTokens: 10 }, + }), + } + ); + } else if (provider === 'openai') { + response = await fetch( + 'https://api.openai.com/v1/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'Test' }], + max_tokens: 10, + }), + } + ); + } else if (provider === 'anthropic') { + response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test' }], + max_tokens: 10, + }), + }); + } else if (provider === 'groq') { + response = await fetch( + 'https://api.groq.com/openai/v1/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: 'Test' }], + max_tokens: 10, + }), + } + ); + } else if (provider === 'mistral') { + response = await fetch( + 'https://api.mistral.ai/v1/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: 'Test' }], + max_tokens: 10, + }), + } + ); + } else { + showApiTestResult('Unknown provider: ' + provider, false); + return; + } + + if (response.ok) { + showApiTestResult('✓ API connection successful!', true); + } else { + const error = await response.json().catch(() => ({} as Record)); + showApiTestResult( + `API Error: ${(error.error as Record)?.message || error.message || 'Unknown error'}`, + false + ); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showApiTestResult(`Connection Error: ${msg}`, false); + } +} diff --git a/src/options/app.ts b/src/options/app.ts new file mode 100644 index 0000000..24c91ac --- /dev/null +++ b/src/options/app.ts @@ -0,0 +1,475 @@ +/** + * Options page main entry point. + * Bundled by esbuild into dist/options.js as a single IIFE. + */ + +import { i18n } from '../shared/i18n'; +import { DebugLogger } from '../shared/logger'; +import { loadSettings } from './settings'; +import { showProviderSections, getProviderInfo } from './providers'; +import type { ProviderUIInfo } from './providers'; +import type { AutoSortSettings } from '../types/settings'; + +import { addLabelInput, loadFoldersFromImap, importLabelsFromBulk } from './labels'; +import { + addGeminiKeyInput, + getGeminiKeys, + setGeminiKeys, + updateGeminiUsageDisplay, + initGeminiKeysFromStorage, +} from './gemini-keys'; +import { applyBatchProgress, initBatchPanel } from './batch-panel'; + +import { els } from './shared/dom-refs'; +import { showMessage, formatTimestamp } from './shared/ui'; +import { updateSaveButtonState, collectSettings, handleSave } from './save-settings'; +import { handleTestApi } from './api-test'; +import { initOllamaListeners } from './ollama-config'; +import { initOpenAICompatListeners } from './openai-compat'; + +// ── Logger singleton ───────────────────────────────────────────── + +const debugLogger = new DebugLogger(); + +// ── i18n Key Map for Providers ──────────────────────────────────── + +const PROVIDER_I18N_REFS: Record = { + gemini: { nameKey: 'providerGemini', infoKey: 'providerInfoGemini' }, + openai: { nameKey: 'providerOpenAI', infoKey: 'providerInfoOpenai' }, + anthropic: { nameKey: 'providerAnthropic', infoKey: 'providerInfoAnthropic' }, + groq: { nameKey: 'providerGroq', infoKey: 'providerInfoGroq' }, + mistral: { nameKey: 'providerMistral', infoKey: 'providerInfoMistral' }, + ollama: { nameKey: 'providerOllama', infoKey: 'providerInfoOllama' }, + 'openai-compatible': { + nameKey: 'providerOpenAICompatible', + infoKey: 'providerInfoOpenaiCompatible', + }, +}; + +// ── Provider Info ───────────────────────────────────────────────── + +function updateProviderInfo(): void { + const provider = els.aiProviderSelect.value; + const config = getProviderInfo(provider); + const i18nRef = PROVIDER_I18N_REFS[provider]; + + showProviderSections(provider); + + if (els.providerInfo && config && i18nRef) { + const displayName = i18n.get(i18nRef.nameKey); + const infoText = i18n.get(i18nRef.infoKey); + const badgeHtml = config.isFree + ? `${i18n.get('freeBadge', 'FREE')}` + : ``; + + els.providerInfo.innerHTML = ` +
+ ${displayName} ${badgeHtml} +

${infoText}

+
+ `; + } + + // Show/hide rate-limit warning for Gemini + const rateLimitWarning = document.getElementById('rate-limit-warning'); + if (rateLimitWarning) { + rateLimitWarning.style.display = provider === 'gemini' ? 'block' : 'none'; + } + + // Update Gemini usage display when Gemini is selected + if (provider === 'gemini') { + updateGeminiUsageDisplay(); + } + + if (provider !== 'ollama' && provider !== 'openai-compatible') { + els.apiKeyInput.placeholder = i18n.get('apiKeyPlaceholder'); + } + + updateSaveButtonState(); +} + +// ── Move History ────────────────────────────────────────────────── + +async function updateHistoryTable(): Promise { + if (!els.historyBody) return; + + const data = await browser.storage.local.get('moveHistory'); + const history: Array<{ + timestamp?: unknown; + subject?: string; + status?: string; + destination?: string; + }> = data.moveHistory || []; + + els.historyBody.innerHTML = history + .map( + (entry) => ` + + ${formatTimestamp(entry.timestamp)} + ${entry.subject || ''} + ${entry.status || ''} + ${entry.destination || ''} + + ` + ) + .join(''); +} + +async function clearHistory(): Promise { + if (confirm('Are you sure you want to clear the move history?')) { + await browser.storage.local.set({ moveHistory: [] }); + await updateHistoryTable(); + } +} + +// ── Main Initialization ─────────────────────────────────────────── + +async function init(): Promise { + // Apply i18n translations first + if (typeof (window as unknown as Record).applyTranslations === 'function') { + ((window as unknown as Record).applyTranslations as () => void)(); + } + + // Initialize debug logger + await debugLogger.init(); + + // Section collapse/expand handlers + const sectionHeaders = document.querySelectorAll('.section-header'); + sectionHeaders.forEach((header) => { + header.addEventListener('click', function (this: HTMLElement) { + const sectionId = this.getAttribute('data-section'); + if (!sectionId) return; + const content = document.getElementById(sectionId); + const section = this.parentElement; + const icon = this.querySelector('.collapse-icon') as HTMLElement | null; + + if (section && content) { + if (section.classList.contains('collapsed')) { + section.classList.remove('collapsed'); + content.style.display = 'block'; + if (icon) icon.textContent = '▼'; + setTimeout(() => { + content.style.animation = 'slideDown 0.3s ease-out'; + }, 0); + } else { + section.classList.add('collapsed'); + content.style.display = 'none'; + if (icon) icon.textContent = '▶'; + } + } + }); + }); + + // Load saved settings and populate UI + const settings = await loadSettings(); + + // Labels + if (settings.labels && settings.labels.length > 0) { + els.labelsContainer.innerHTML = ''; + settings.labels.forEach((label) => { + addLabelInput(label, els.labelsContainer, updateSaveButtonState); + }); + } else { + els.labelsContainer.innerHTML = + '
No folders/labels configured. Click "Load Folders from Mail Account" above or add custom labels below.
'; + } + + // Gemini keys + await initGeminiKeysFromStorage( + els.geminiKeysList, + els.apiKeyInput, + updateSaveButtonState + ); + + // Ollama settings + if (settings.ollamaUrl && els.ollamaUrlInput) { + els.ollamaUrlInput.value = settings.ollamaUrl; + } + if (settings.ollamaAuthToken && els.ollamaAuthTokenInput) { + els.ollamaAuthTokenInput.value = settings.ollamaAuthToken; + } + if (settings.ollamaModel && els.ollamaModelSelect) { + els.ollamaModelSelect.value = settings.ollamaModel; + if ( + settings.ollamaModel === 'custom' && + settings.ollamaCustomModel && + els.ollamaCustomModelInput + ) { + els.ollamaCustomModelInput.value = settings.ollamaCustomModel; + els.ollamaCustomModelInput.style.display = 'block'; + } + } + if (els.ollamaCpuOnlyCheckbox) { + els.ollamaCpuOnlyCheckbox.checked = settings.ollamaCpuOnly === true; + } + + // OpenAI-Compatible settings + if (settings.customBaseUrl && els.customBaseUrlInput) { + els.customBaseUrlInput.value = settings.customBaseUrl; + } + if (settings.customModel) { + const dropdownOptions = els.customModelSelect + ? Array.from(els.customModelSelect.options).map((o) => o.value) + : []; + if (dropdownOptions.includes(settings.customModel)) { + if (els.customModelSelect) els.customModelSelect.value = settings.customModel; + } else { + if (els.customModelSelect) { + els.customModelSelect.value = 'custom'; + if (els.customModelCustomInput) { + els.customModelCustomInput.style.display = 'block'; + els.customModelCustomInput.value = settings.customModel; + } + } + } + } + + // Provider + if (settings.aiProvider) { + els.aiProviderSelect.value = settings.aiProvider; + updateProviderInfo(); + } + + // Enable AI (default true) + els.enableAiCheckbox.checked = settings.enableAi !== false; + + // Gemini Paid Plan + els.geminiPaidCheckbox.checked = settings.geminiPaidPlan === true; + + // Debug mode + if (els.enableDebugCheckbox && settings.debugMode !== undefined) { + els.enableDebugCheckbox.checked = settings.debugMode; + } + + // Batch chunk size + if (els.batchChunkSizeInput && settings.batchChunkSize) { + els.batchChunkSizeInput.value = String(settings.batchChunkSize); + } + + // Auto-sort + if (els.autoSortCheckbox) { + els.autoSortCheckbox.checked = settings.autoSortEnabled === true; + } + + // Custom prompt + if (els.customPromptTextarea) { + els.customPromptTextarea.value = settings.customPrompt || ''; + } + + updateSaveButtonState(); + + // ── Event Listeners ────────────────────────────────────────── + + els.aiProviderSelect.addEventListener('change', updateProviderInfo); + + // Add label button + els.addLabelButton.addEventListener('click', () => { + const instructionMsg = + els.labelsContainer.querySelector('.instruction-message'); + if (instructionMsg) { + els.labelsContainer.innerHTML = ''; + } + addLabelInput('', els.labelsContainer, updateSaveButtonState); + updateSaveButtonState(); + }); + + // Save button + els.saveButton.addEventListener('click', handleSave); + + // Debug mode toggle + if (els.enableDebugCheckbox) { + els.enableDebugCheckbox.addEventListener('change', async () => { + if (els.enableDebugCheckbox!.checked) { + await debugLogger.enable(); + showMessage( + '✓ Debug mode enabled. Open Thunderbird Developer Tools (Ctrl+Shift+I) to view logs.', + true + ); + } else { + await debugLogger.disable(); + showMessage('✓ Debug mode disabled.', true); + } + }); + } + + // Reset prompt button + if (els.resetPromptButton && els.customPromptTextarea) { + els.resetPromptButton.addEventListener('click', () => { + els.customPromptTextarea!.value = ''; + showMessage( + 'Custom prompt cleared. Default prompt will be used.', + true + ); + }); + } + + // API key input + els.apiKeyInput.addEventListener('input', updateSaveButtonState); + els.labelsContainer.addEventListener('input', updateSaveButtonState); + + // Test API button + els.testApiButton.addEventListener('click', handleTestApi); + + // Get API Key button + els.getApiKeyButton.addEventListener('click', async () => { + const provider = els.aiProviderSelect.value; + const config = getProviderInfo(provider); + + if (!config?.signupUrl) { + showMessage( + "This provider doesn't have a signup URL. Configure the endpoint directly in the settings above.", + false + ); + return; + } + + try { + await browser.tabs.create({ url: config.signupUrl }); + } catch (error: unknown) { + console.error('Failed to open tab:', error); + const url = config.signupUrl; + try { + await navigator.clipboard.writeText(url); + showMessage(`URL copied to clipboard:\n${url}`, true); + } catch { + alert(`Please visit:\n${url}`); + } + } + }); + + // Import labels + els.importLabelsButton.addEventListener('click', () => { + const bulkText = els.bulkImportTextarea.value.trim(); + importLabelsFromBulk( + bulkText, + els.labelsContainer, + updateSaveButtonState, + showMessage + ); + els.bulkImportTextarea.value = ''; + }); + + // Load IMAP folders + let loadedFolders: string[] = []; + + els.loadImapFoldersButton.addEventListener('click', async () => { + loadedFolders = await loadFoldersFromImap( + els.folderLoadingIndicator, + els.folderSelection, + els.foldersPreview, + els.folderCount, + showMessage + ); + }); + + els.useImapFoldersButton.addEventListener('click', () => { + if ( + confirm( + `This will replace any existing folders/labels with ${loadedFolders.length} folders from your mail account. Continue?` + ) + ) { + els.labelsContainer.innerHTML = ''; + loadedFolders.forEach((folder) => { + addLabelInput(folder, els.labelsContainer, updateSaveButtonState); + }); + els.folderSelection.style.display = 'none'; + updateSaveButtonState(); + showMessage( + `Loaded ${loadedFolders.length} folders from your mail account. Don't forget to save!`, + true + ); + } + }); + + els.useCustomFoldersButton.addEventListener('click', () => { + els.folderSelection.style.display = 'none'; + showMessage('You can now add custom folders below', true); + }); + + // Gemini multi-key + els.addGeminiKeyButton.addEventListener('click', () => { + addGeminiKeyInput('', -1, els.geminiKeysList, updateSaveButtonState); + }); + + // Reset Gemini counter + const resetGeminiCounterBtn = document.getElementById('reset-gemini-counter'); + if (resetGeminiCounterBtn) { + resetGeminiCounterBtn.addEventListener('click', async () => { + if ( + confirm( + 'Reset usage counter? Do this only after switching to a new API key.' + ) + ) { + await browser.storage.local.set({ + geminiRateLimit: { + requests: [], + dailyCount: 0, + dailyResetTime: Date.now() + 24 * 60 * 60 * 1000, + }, + }); + await updateGeminiUsageDisplay(); + const usageMessage = document.getElementById('usage-message'); + if (usageMessage) { + usageMessage.className = 'usage-message info'; + usageMessage.textContent = + '✓ Usage counter reset. You can now process up to 20 more emails today with your new API key.'; + } + } + }); + } + + // Refresh Gemini usage + const refreshUsageBtn = document.getElementById('refresh-usage'); + if (refreshUsageBtn) { + refreshUsageBtn.addEventListener('click', async () => { + await updateGeminiUsageDisplay(); + const usageMessage = document.getElementById('usage-message'); + if (usageMessage) { + usageMessage.className = 'usage-message info'; + usageMessage.textContent = '✓ Usage information refreshed.'; + setTimeout(() => { + if (usageMessage.classList.contains('info')) { + usageMessage.style.display = 'none'; + } + }, 3000); + } + }); + } + + // Refresh all usage + const refreshAllUsageBtn = document.getElementById('refresh-all-usage'); + if (refreshAllUsageBtn) { + refreshAllUsageBtn.addEventListener('click', async () => { + await updateGeminiUsageDisplay(); + showMessage('✓ All usage information refreshed.', true); + }); + } + + // Initialize Ollama listeners + initOllamaListeners(debugLogger); + + // Initialize OpenAI-Compatible listeners + initOpenAICompatListeners(debugLogger); + + // Initialize batch panel + initBatchPanel(); + + // Move history + await updateHistoryTable(); + const clearHistoryBtn = document.getElementById('clear-history'); + const refreshHistoryBtn = document.getElementById('refresh-history'); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', clearHistory); + } + if (refreshHistoryBtn) { + refreshHistoryBtn.addEventListener('click', updateHistoryTable); + } +} + +// ── Entry Point ─────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + init().catch((err: unknown) => { + console.error('Options initialization failed:', err); + }); +}); diff --git a/src/options/batch-panel.ts b/src/options/batch-panel.ts new file mode 100644 index 0000000..01fa709 --- /dev/null +++ b/src/options/batch-panel.ts @@ -0,0 +1,186 @@ +/** + * Batch progress panel for the options page. + * Displays real-time batch processing status and provides pause/resume/cancel controls. + */ + +declare const browser: any; + +interface BatchProgressPayload { + status?: string; + total?: number; + completed?: number; + failed?: number; + skipped?: number; + provider?: string; + chunkIndex?: number; + totalChunks?: number; +} + +let batchHideTimer: ReturnType | null = null; + +function getPanelElements() { + return { + panel: document.getElementById('batch-status-panel'), + fill: document.getElementById('batch-progress-fill'), + text: document.getElementById('batch-progress-text'), + badge: document.getElementById('batch-provider-badge'), + pauseBtn: document.getElementById('batch-pause-btn'), + resumeBtn: document.getElementById('batch-resume-btn'), + cancelBtn: document.getElementById('batch-cancel-btn') as HTMLButtonElement | null, + }; +} + +/** + * Update the batch panel UI from a progress payload. + */ +export function applyBatchProgress(payload: BatchProgressPayload): void { + const els = getPanelElements(); + if (!els.panel || !payload) return; + + const { + status = 'running', + total = 0, + completed = 0, + failed = 0, + skipped = 0, + provider = '', + chunkIndex = 0, + totalChunks = 0, + } = payload; + + const done = (completed || 0) + (failed || 0) + (skipped || 0); + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + els.panel.style.display = 'block'; + els.panel.dataset.status = status; + + if (els.badge && provider) { + els.badge.textContent = provider; + } + + if (els.fill) { + els.fill.style.width = pct + '%'; + } + + const displayChunk = chunkIndex || 0; + const displayTotal = totalChunks || 0; + + if (els.text) { + if (status === 'paused') { + if (displayTotal > 0) { + els.text.textContent = `⏸ Paused — chunk ${displayChunk}/${displayTotal} (${done}/${total})`; + } else { + els.text.textContent = `⏸ Paused (${done}/${total})`; + } + } else if (status === 'done') { + els.text.textContent = `✅ Done — sorted: ${completed}, skipped: ${skipped}, failed: ${failed}`; + } else if (status === 'cancelled') { + if (displayTotal > 0) { + els.text.textContent = `⏹ Cancelled after chunk ${displayChunk}/${displayTotal}`; + } else { + els.text.textContent = `⏹ Cancelled (${done}/${total})`; + } + } else { + if (displayTotal > 0) { + els.text.textContent = `Chunk ${displayChunk}/${displayTotal} — ${done}/${total} (sorted: ${completed}, failed: ${failed})`; + } else { + els.text.textContent = `${done}/${total} (sorted: ${completed}, failed: ${failed})`; + } + } + } + + // Pause/Resume button visibility + if (els.pauseBtn && els.resumeBtn) { + if (status === 'paused') { + els.pauseBtn.style.display = 'none'; + els.resumeBtn.style.display = ''; + } else { + els.pauseBtn.style.display = ''; + els.resumeBtn.style.display = 'none'; + } + } + + // Cancel button visibility + if (els.cancelBtn) { + els.cancelBtn.style.display = + status === 'done' || status === 'cancelled' ? 'none' : ''; + } + + // Auto-hide after completion + if (status === 'done' || status === 'cancelled') { + if (batchHideTimer) clearTimeout(batchHideTimer); + batchHideTimer = setTimeout(() => { + if (els.panel) els.panel.style.display = 'none'; + }, 5000); + } +} + +/** + * Initialize batch panel: restore any running batch state and wire up controls. + */ +export function initBatchPanel(): void { + const els = getPanelElements(); + + // Restore running batch state + browser.storage.local.get('currentBatch').then( + (result: { currentBatch?: BatchProgressPayload }) => { + if (result.currentBatch && result.currentBatch.status === 'running') { + applyBatchProgress(result.currentBatch); + } + } + ); + + // Listen for progress messages from background + browser.runtime.onMessage.addListener( + (msg: { action: string } & BatchProgressPayload) => { + if (msg.action === 'batchProgress') { + applyBatchProgress(msg); + } + } + ); + + // Pause button + if (els.pauseBtn) { + els.pauseBtn.addEventListener('click', () => { + browser.runtime + .sendMessage({ action: 'batchControl', command: 'pause' }) + .catch(() => {}); + if (els.panel) els.panel.dataset.status = 'paused'; + if (els.text) + els.text.textContent = + '⏸ Pausing… current request will finish first.'; + if (els.pauseBtn) els.pauseBtn.style.display = 'none'; + if (els.resumeBtn) els.resumeBtn.style.display = ''; + }); + } + + // Resume button + if (els.resumeBtn) { + els.resumeBtn.addEventListener('click', () => { + browser.runtime + .sendMessage({ action: 'batchControl', command: 'resume' }) + .catch(() => {}); + if (els.panel) els.panel.dataset.status = 'running'; + if (els.pauseBtn) els.pauseBtn.style.display = ''; + if (els.resumeBtn) els.resumeBtn.style.display = 'none'; + }); + } + + // Cancel button + if (els.cancelBtn) { + els.cancelBtn.addEventListener('click', () => { + if ( + !confirm( + 'Cancel the current batch? Already-sorted emails will not be undone.' + ) + ) + return; + browser.runtime + .sendMessage({ action: 'batchControl', command: 'cancel' }) + .catch(() => {}); + if (els.text) + els.text.textContent = + '⏹ Cancelling… current request will finish first.'; + if (els.cancelBtn) els.cancelBtn.disabled = true; + }); + } +} diff --git a/src/options/gemini-keys.ts b/src/options/gemini-keys.ts new file mode 100644 index 0000000..b3ae2c7 --- /dev/null +++ b/src/options/gemini-keys.ts @@ -0,0 +1,458 @@ +/** + * Gemini multi-key management and usage display for the options page. + */ + +import { i18n } from '../shared/i18n'; + +declare const browser: any; + +interface GeminiRateLimit { + requests: number[]; + dailyCount: number; + dailyResetTime: number; +} + +// ── Module State ────────────────────────────────────────────────── + +let geminiKeys: string[] = []; + +export function getGeminiKeys(): string[] { + return geminiKeys; +} + +export function setGeminiKeys(keys: string[]): void { + geminiKeys = keys; +} + +function updateSingleKeyUsageDisplay(rateLimit: GeminiRateLimit): void { + const now = Date.now(); + + const dailyCountEl = document.getElementById('gemini-daily-count'); + if (dailyCountEl) dailyCountEl.textContent = String(rateLimit.dailyCount); + + const lastRequestEl = document.getElementById('gemini-last-request'); + if (lastRequestEl) { + if (rateLimit.requests && rateLimit.requests.length > 0) { + const lastRequest = Math.max(...rateLimit.requests); + const minutesAgo = Math.floor((now - lastRequest) / 60000); + if (minutesAgo < 1) { + lastRequestEl.textContent = i18n.get('geminiNever'); + lastRequestEl.dataset.i18nFallback = 'just_now'; + } else if (minutesAgo < 60) { + lastRequestEl.textContent = `${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago`; + } else { + const hoursAgo = Math.floor(minutesAgo / 60); + lastRequestEl.textContent = `${hoursAgo} hour${hoursAgo > 1 ? 's' : ''} ago`; + } + } else { + lastRequestEl.textContent = i18n.get('geminiNever'); + } + } + + const resetTimeEl = document.getElementById('gemini-reset-time'); + if (resetTimeEl) { + if (rateLimit.dailyResetTime > now) { + const hoursUntil = Math.ceil( + (rateLimit.dailyResetTime - now) / (1000 * 60 * 60) + ); + resetTimeEl.textContent = `In ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''}`; + } else { + resetTimeEl.textContent = i18n.get( + 'geminiResetExpired', + 'Expired (will reset on next request)' + ); + } + } + + const usageMessage = document.getElementById('usage-message'); + const statusSpan = document.getElementById('gemini-status'); + + if (rateLimit.dailyCount >= 20) { + if (statusSpan) { + statusSpan.textContent = + '🔴 ' + + i18n.get('geminiStatusLimitReached', 'Limit Reached'); + statusSpan.style.color = '#dc3545'; + } + if (usageMessage) { + usageMessage.className = 'usage-message warning'; + usageMessage.textContent = + '⚠️ ' + + i18n.get( + 'geminiLimitMessage', + 'Daily limit reached! Create a new API key in a different project and update it above to continue processing emails.' + ); + } + } else if (rateLimit.dailyCount >= 15) { + if (statusSpan) { + statusSpan.textContent = + '🟡 ' + + i18n.get('geminiStatusNearlyFull', 'Nearly Full'); + statusSpan.style.color = '#ffc107'; + } + if (usageMessage) { + usageMessage.className = 'usage-message warning'; + usageMessage.textContent = + `⚠️ ${i18n.get('geminiRemainingMessage', 'Only')} ${20 - rateLimit.dailyCount} ${i18n.get('requestsRemainingToday', 'requests remaining today. Consider switching to a new API key soon.')}`; + } + } else { + if (statusSpan) { + statusSpan.textContent = + '🟢 ' + i18n.get('geminiStatusReady', 'Ready'); + statusSpan.style.color = '#28a745'; + } + if (usageMessage) { + usageMessage.style.display = 'none'; + } + } +} + +function updateMultiKeyUsageDisplay( + keys: string[], + rateLimits: GeminiRateLimit[], + currentIndex: number +): void { + const container = document.getElementById('all-keys-usage-stats'); + if (!container) return; + const now = Date.now(); + container.innerHTML = ''; + + keys.forEach((key, index) => { + const rateLimit = rateLimits[index] || { + requests: [], + dailyCount: 0, + dailyResetTime: now, + }; + const isActive = index === currentIndex; + + const card = document.createElement('div'); + card.className = `key-usage-card${isActive ? ' active' : ''}`; + + let statusBadge = ''; + if (isActive) { + statusBadge = + '🔵 ACTIVE'; + } else if (rateLimit.dailyCount >= 20) { + statusBadge = + '🔴 LIMIT'; + } else if (rateLimit.dailyCount >= 15) { + statusBadge = + '🟡 NEAR LIMIT'; + } else { + statusBadge = + '🟢 READY'; + } + + let resetText = '--'; + if (rateLimit.dailyResetTime > now) { + const hoursUntil = Math.ceil( + (rateLimit.dailyResetTime - now) / (1000 * 60 * 60) + ); + resetText = `${hoursUntil}h`; + } + + let lastRequestText = 'Never'; + if (rateLimit.requests && rateLimit.requests.length > 0) { + const lastRequest = Math.max(...rateLimit.requests); + const minutesAgo = Math.floor((now - lastRequest) / 60000); + if (minutesAgo < 1) { + lastRequestText = 'Just now'; + } else if (minutesAgo < 60) { + lastRequestText = `${minutesAgo}m ago`; + } else { + lastRequestText = `${Math.floor(minutesAgo / 60)}h ago`; + } + } + + const maskedKey = key ? `...${key.slice(-8)}` : 'Not set'; + + card.innerHTML = ` +
+ Key ${index + 1}: ${maskedKey} + ${statusBadge} +
+
+
+ Usage: + ${rateLimit.dailyCount}/20 +
+
+ Last: + ${lastRequestText} +
+
+ Resets: + ${resetText} +
+
+ Available: + ${20 - rateLimit.dailyCount} +
+
+ `; + + container.appendChild(card); + }); +} + +export async function updateGeminiUsageDisplay(): Promise { + const data = await browser.storage.local.get([ + 'geminiRateLimits', + 'currentGeminiKeyIndex', + 'geminiApiKeys', + 'geminiRateLimit', + ]); + const currentIndex: number = data.currentGeminiKeyIndex || 0; + const keys: string[] = data.geminiApiKeys || geminiKeys; + + const singleKeyEl = document.getElementById('single-key-usage'); + const multiKeyEl = document.getElementById('multi-key-usage'); + + if (keys.length > 1) { + if (singleKeyEl) singleKeyEl.style.display = 'none'; + if (multiKeyEl) multiKeyEl.style.display = 'block'; + const rateLimits: GeminiRateLimit[] = data.geminiRateLimits || []; + updateMultiKeyUsageDisplay(keys, rateLimits, currentIndex); + } else if (keys.length === 1) { + if (singleKeyEl) singleKeyEl.style.display = 'block'; + if (multiKeyEl) multiKeyEl.style.display = 'none'; + const rateLimits: GeminiRateLimit[] = data.geminiRateLimits || [ + { requests: [], dailyCount: 0, dailyResetTime: Date.now() }, + ]; + updateSingleKeyUsageDisplay(rateLimits[0]); + } else { + if (singleKeyEl) singleKeyEl.style.display = 'block'; + if (multiKeyEl) multiKeyEl.style.display = 'none'; + const rateLimit: GeminiRateLimit = data.geminiRateLimit || { + requests: [], + dailyCount: 0, + dailyResetTime: Date.now(), + }; + updateSingleKeyUsageDisplay(rateLimit); + } +} + +// ── Key Management ──────────────────────────────────────────────── + +export async function testGeminiKey( + apiKey: string, + index: number, + keyItemElement: HTMLElement +): Promise { + const statusSpan = keyItemElement.querySelector('.key-test-result') as HTMLElement | null; + + if (!apiKey) { + if (statusSpan) { + statusSpan.textContent = '⚠️ Enter key first'; + statusSpan.className = 'key-test-result error'; + } + return; + } + + try { + if (statusSpan) { + statusSpan.textContent = 'Testing...'; + statusSpan.className = 'key-test-result testing'; + } + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: 'Test' }] }], + generationConfig: { maxOutputTokens: 10 }, + }), + } + ); + + if (response.ok) { + if (statusSpan) { + statusSpan.textContent = '✓ Valid'; + statusSpan.className = 'key-test-result success'; + } + } else if (response.status === 429) { + if (statusSpan) { + statusSpan.textContent = '⚠️ Limit reached'; + statusSpan.className = 'key-test-result error'; + statusSpan.title = + 'This key has reached its daily rate limit (20/day). Will reset in ~24 hours.'; + } + console.error(`Key #${index + 1} has reached rate limit (429)`); + } else if (response.status === 401 || response.status === 403) { + if (statusSpan) { + statusSpan.textContent = '✗ Invalid key'; + statusSpan.className = 'key-test-result error'; + statusSpan.title = + 'API key is invalid or expired. Check your key in Google AI Studio.'; + } + console.error(`Key #${index + 1} test failed: ${response.status}`); + } else { + if (statusSpan) { + statusSpan.textContent = `✗ Failed (${response.status})`; + statusSpan.className = 'key-test-result error'; + } + console.error(`Key #${index + 1} test failed:`, response.status); + } + } catch (error: unknown) { + if (statusSpan) { + statusSpan.textContent = '✗ Error'; + statusSpan.className = 'key-test-result error'; + } + console.error(`Key #${index + 1} test error:`, error); + } +} + +function refreshGeminiKeysList( + geminiKeysList: HTMLElement, + onUpdateSaveState: () => void +): void { + geminiKeysList.innerHTML = ''; + geminiKeys.forEach((key, index) => { + addGeminiKeyInput(key, index, geminiKeysList, onUpdateSaveState); + }); +} + +function removeGeminiKey( + index: number, + geminiKeysList: HTMLElement, + onUpdateSaveState: () => void +): void { + if (geminiKeys.length <= 1) { + alert('You must have at least one API key configured.'); + return; + } + + if (confirm(`Remove API key #${index + 1}?`)) { + geminiKeys.splice(index, 1); + refreshGeminiKeysList(geminiKeysList, onUpdateSaveState); + } +} + +export function addGeminiKeyInput( + value: string, + index: number, + geminiKeysList: HTMLElement, + onUpdateSaveState: () => void +): void { + if (index === -1) { + index = geminiKeys.length; + geminiKeys.push(value); + } + + const keyItem = document.createElement('div'); + keyItem.className = 'gemini-key-item'; + keyItem.dataset.index = String(index); + + const keyIndex = document.createElement('span'); + keyIndex.className = 'key-index'; + keyIndex.textContent = `#${index + 1}`; + + const input = document.createElement('input'); + input.type = 'password'; + input.className = 'gemini-api-key-input'; + input.placeholder = i18n.get( + 'geminiKeyInputPlaceholder', + 'Enter Gemini API key from another project' + ); + input.value = value; + input.dataset.index = String(index); + input.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + const newKey = target.value.trim(); + geminiKeys[index] = newKey; + + if (newKey) { + const isDuplicate = geminiKeys.some( + (key, i) => i !== index && key.trim() === newKey + ); + if (isDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = '⚠️ This key is already added!'; + } else { + input.style.borderColor = ''; + input.title = ''; + } + } else { + input.style.borderColor = ''; + input.title = ''; + } + + onUpdateSaveState(); + }); + + const testButton = document.createElement('button'); + testButton.className = 'button'; + testButton.textContent = i18n.get('testButton', 'Test'); + testButton.addEventListener('click', () => { + const keyValue = input.value.trim(); + const statusSpan = keyItem.querySelector('.key-test-result') as HTMLElement | null; + + if (!keyValue) { + if (statusSpan) { + statusSpan.textContent = '⚠️ Enter key first'; + statusSpan.className = 'key-test-result error'; + } + return; + } + + const isDuplicate = geminiKeys.some( + (key, i) => i !== index && key.trim() === keyValue + ); + if (isDuplicate) { + if (statusSpan) { + statusSpan.textContent = '⚠️ Duplicate key'; + statusSpan.className = 'key-test-result error'; + statusSpan.title = 'This key is already added in the list'; + } + return; + } + + testGeminiKey(keyValue, index, keyItem); + }); + + const removeButton = document.createElement('button'); + removeButton.className = 'button'; + removeButton.textContent = '×'; + removeButton.addEventListener('click', () => + removeGeminiKey(index, geminiKeysList, onUpdateSaveState) + ); + + const statusSpan = document.createElement('span'); + statusSpan.className = 'key-test-result'; + statusSpan.dataset.index = String(index); + + keyItem.appendChild(keyIndex); + keyItem.appendChild(input); + keyItem.appendChild(testButton); + keyItem.appendChild(removeButton); + keyItem.appendChild(statusSpan); + geminiKeysList.appendChild(keyItem); +} + +/** + * Initialize Gemini keys display from saved settings. + */ +export async function initGeminiKeysFromStorage( + geminiKeysList: HTMLElement, + apiKeyInput: HTMLInputElement, + onUpdateSaveState: () => void +): Promise { + const data = await browser.storage.local.get(['geminiApiKeys', 'apiKey']); + + if (data.geminiApiKeys && data.geminiApiKeys.length > 0) { + geminiKeys = data.geminiApiKeys; + geminiKeys.forEach((key: string, idx: number) => { + addGeminiKeyInput(key, idx, geminiKeysList, onUpdateSaveState); + }); + } else if (data.apiKey) { + // Migrate from single key to multi-key + geminiKeys = [data.apiKey]; + addGeminiKeyInput(data.apiKey, 0, geminiKeysList, onUpdateSaveState); + apiKeyInput.value = data.apiKey; + } else { + // No keys configured yet - add one empty field + addGeminiKeyInput('', 0, geminiKeysList, onUpdateSaveState); + } +} diff --git a/src/options/index.ts b/src/options/index.ts new file mode 100644 index 0000000..f4bfb95 --- /dev/null +++ b/src/options/index.ts @@ -0,0 +1,4 @@ +export { loadSettings, saveSettings, getSetting, SETTINGS_KEYS } from './settings'; +export { getProviderInfo, showProviderSections, PROVIDER_UI_LIST } from './providers'; +export type { ProviderUIInfo } from './providers'; +export { getElement, getElementOrNull, safeQuerySelector, eventBus } from './shared'; diff --git a/src/options/labels.ts b/src/options/labels.ts new file mode 100644 index 0000000..affd111 --- /dev/null +++ b/src/options/labels.ts @@ -0,0 +1,199 @@ +/** + * Label/folder management for the options page. + * Handles adding/removing label inputs, IMAP folder loading, and bulk import. + */ + +declare const browser: any; + +interface Folder { + type: string; + name: string; + subFolders?: Folder[]; +} + +interface Account { + folders: Folder[]; +} + +// ── Label Inputs ────────────────────────────────────────────────── + +export function addLabelInput( + value: string, + labelsContainer: HTMLElement, + onUpdateSaveState: () => void +): void { + const labelItem = document.createElement('div'); + labelItem.className = 'label-item'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'label-input'; + input.placeholder = 'Enter category/folder name'; + input.value = value; + input.addEventListener('input', onUpdateSaveState); + + const removeButton = document.createElement('button'); + removeButton.className = 'remove-label'; + removeButton.textContent = '×'; + removeButton.addEventListener('click', () => { + labelItem.remove(); + onUpdateSaveState(); + + const remainingLabels = labelsContainer.querySelectorAll('.label-input'); + if (remainingLabels.length === 0) { + labelsContainer.innerHTML = + '
No folders/labels configured. Click "Load Folders from Mail Account" above or add custom labels below.
'; + } + }); + + labelItem.appendChild(input); + labelItem.appendChild(removeButton); + labelsContainer.appendChild(labelItem); +} + +// ── IMAP Folder Loading ────────────────────────────────────────── + +async function getAllFolders(account: Account): Promise { + const folders: string[] = []; + + async function processFolder(folder: Folder): Promise { + if ( + folder.type !== 'inbox' && + folder.type !== 'trash' && + folder.type !== 'sent' && + folder.type !== 'drafts' && + folder.type !== 'junk' && + folder.type !== 'templates' && + folder.type !== 'outbox' && + folder.type !== 'archives' + ) { + folders.push(folder.name); + } + + if (folder.subFolders) { + for (const subFolder of folder.subFolders) { + await processFolder(subFolder); + } + } + } + + for (const folder of account.folders) { + await processFolder(folder); + } + + return folders; +} + +export async function loadFoldersFromImap( + folderLoadingIndicator: HTMLElement, + folderSelection: HTMLElement, + foldersPreview: HTMLElement, + folderCount: HTMLElement, + showMessageFn: (msg: string, success?: boolean) => void +): Promise { + folderLoadingIndicator.style.display = 'block'; + folderSelection.style.display = 'none'; + + try { + const accounts: Account[] = await browser.accounts.list(); + const allFolders: string[] = []; + + for (const account of accounts) { + const folderList = await getAllFolders(account); + allFolders.push(...folderList); + } + + const loadedFolders = [ + ...new Set( + allFolders + .filter( + (f) => + ![ + 'Inbox', 'Trash', 'Drafts', 'Sent', 'Spam', 'Junk', + 'Templates', 'Outbox', 'Archives', + ].includes(f) + ) + .map((f) => f.replace(/^INBOX\./i, '').trim()) + ), + ].sort(); + + if (loadedFolders.length === 0) { + showMessageFn( + 'No folders found. You can create custom folders instead.', + false + ); + folderLoadingIndicator.style.display = 'none'; + return []; + } + + folderCount.textContent = String(loadedFolders.length); + foldersPreview.innerHTML = + loadedFolders + .slice(0, 10) + .map((f) => `
${f}
`) + .join('') + + (loadedFolders.length > 10 + ? `
...and ${loadedFolders.length - 10} more
` + : ''); + + folderSelection.style.display = 'block'; + return loadedFolders; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessageFn(`Error loading folders: ${msg}`, false); + console.error('Error loading folders:', error); + return []; + } finally { + folderLoadingIndicator.style.display = 'none'; + } +} + +// ── Bulk Import ─────────────────────────────────────────────────── + +export function importLabelsFromBulk( + bulkText: string, + labelsContainer: HTMLElement, + onUpdateSaveState: () => void, + showMessageFn: (msg: string, success?: boolean) => void +): void { + const labels = bulkText + .split('\n') + .map((l) => l.trim()) + .filter((l) => l !== ''); + + if (labels.length === 0) { + showMessageFn( + 'Please add at least one folder/label before importing. Enter labels one per line.', + false + ); + return; + } + + const existingLabels = Array.from( + document.querySelectorAll('.label-input') + ) + .map((input) => input.value.trim()) + .filter((label) => label !== ''); + + if (existingLabels.length > 0) { + if ( + !confirm( + `This will replace your ${existingLabels.length} existing folders/labels with ${labels.length} new ones. Continue?` + ) + ) { + return; + } + } + + labelsContainer.innerHTML = ''; + + labels.forEach((label) => { + addLabelInput(label, labelsContainer, onUpdateSaveState); + }); + + onUpdateSaveState(); + showMessageFn( + `Imported ${labels.length} categories/folders. Don't forget to save!`, + true + ); +} diff --git a/src/options/ollama-config.ts b/src/options/ollama-config.ts new file mode 100644 index 0000000..b255098 --- /dev/null +++ b/src/options/ollama-config.ts @@ -0,0 +1,350 @@ +import { els } from './shared/dom-refs'; +import { updateSaveButtonState } from './save-settings'; +import type { DebugLogger } from '../shared/logger'; + +export function initOllamaListeners(debugLogger: DebugLogger): void { + if (els.ollamaUrlInput) { + els.ollamaUrlInput.addEventListener('input', () => { + const url = els.ollamaUrlInput!.value.trim() || 'http://localhost:11434'; + const chatEndpoint = document.getElementById('ollama-chat-endpoint'); + const pullEndpoint = document.getElementById('ollama-pull-endpoint'); + const tagsEndpoint = document.getElementById('ollama-tags-endpoint'); + + if (chatEndpoint) + chatEndpoint.textContent = `${url}/api/chat`; + if (pullEndpoint) + pullEndpoint.textContent = `${url}/api/pull`; + if (tagsEndpoint) + tagsEndpoint.textContent = `${url}/api/tags`; + + updateSaveButtonState(); + }); + } + + if (els.ollamaModelSelect) { + els.ollamaModelSelect.addEventListener('change', () => { + if (els.ollamaCustomModelInput) { + els.ollamaCustomModelInput.style.display = + els.ollamaModelSelect!.value === 'custom' ? 'block' : 'none'; + } + updateSaveButtonState(); + }); + } + + if (els.ollamaCustomModelInput) { + els.ollamaCustomModelInput.addEventListener('input', updateSaveButtonState); + } + + if (els.testOllamaButton) { + els.testOllamaButton.addEventListener('click', async () => { + const ollamaUrl = + els.ollamaUrlInput?.value.trim() || 'http://localhost:11434'; + let selectedModel = els.ollamaModelSelect?.value || ''; + + if (selectedModel === 'custom') { + selectedModel = els.ollamaCustomModelInput?.value.trim() || ''; + if (!selectedModel) { + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = + '⚠️ Please enter a custom model name first'; + els.ollamaTestResult.className = 'api-test-result error'; + } + return; + } + } + + try { + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = + 'Testing connection and checking model...'; + els.ollamaTestResult.className = 'api-test-result'; + } + + const testUrl = `${ollamaUrl}/api/tags`; + debugLogger.info('[Ollama]', 'Test connecting to: ' + testUrl); + + const headers: Record = {}; + if (els.ollamaAuthTokenInput?.value.trim()) { + headers['Authorization'] = `Bearer ${els.ollamaAuthTokenInput.value.trim()}`; + } + + const response = await fetch(testUrl, { + method: 'GET', + headers, + }); + + debugLogger.info( + '[Ollama]', + 'Response status: ' + response.status + ); + + if (response.ok) { + const data = await response.json(); + debugLogger.info('[Ollama]', 'Success:', data); + const installedModels: string[] = + data.models && data.models.length > 0 + ? data.models.map((m: { name: string }) => m.name) + : []; + + if (installedModels.length === 0) { + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = + '⚠️ Ollama is running but no models installed. Enter a model name in "Download Model" and click "Download" to get started.'; + els.ollamaTestResult.className = 'api-test-result error'; + } + } else { + const selectedBase = selectedModel.split(':')[0].toLowerCase(); + const installedBases = installedModels.map((m: string) => + m.split(':')[0].toLowerCase() + ); + const modelFound = installedBases.some( + (base: string) => base === selectedBase + ); + if (modelFound) { + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = `✓ Connected! Model "${selectedModel}" is installed and ready. Available: ${installedModels.join(', ')}`; + els.ollamaTestResult.className = 'api-test-result success'; + } + } else { + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = `✗ Model "${selectedModel}" not installed. Available models: ${installedModels.join(', ')}. Use "Download Model" to install it.`; + els.ollamaTestResult.className = 'api-test-result error'; + } + } + } + } else { + const errorText = await response.text(); + console.error('[Ollama Test] Error response:', errorText); + let errorMsg = 'Connection failed'; + if (response.status === 403) { + errorMsg = + 'Access denied (403). Check if Ollama is running and the URL is correct.'; + } else if (response.status === 404) { + errorMsg = 'Ollama not found (404). Check the server URL.'; + } else { + try { + const errorData = JSON.parse(errorText); + errorMsg = errorData.error || errorText; + } catch { + errorMsg = errorText || `HTTP ${response.status}`; + } + } + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = `✗ Error: ${errorMsg}`; + els.ollamaTestResult.className = 'api-test-result error'; + } + } + } catch (error: unknown) { + console.error('[Ollama Test] Exception:', error); + const msg = + error instanceof Error ? error.message : String(error); + if (els.ollamaTestResult) { + els.ollamaTestResult.textContent = `✗ Connection failed: ${msg}. Make sure Ollama is running (try: ollama serve)`; + els.ollamaTestResult.className = 'api-test-result error'; + } + } + }); + } + + if (els.listOllamaModelsButton && els.ollamaTestResult) { + els.listOllamaModelsButton.addEventListener('click', async () => { + const ollamaUrl = + els.ollamaUrlInput?.value.trim() || 'http://localhost:11434'; + + try { + els.ollamaTestResult!.textContent = 'Fetching models...'; + els.ollamaTestResult!.className = 'api-test-result'; + + const response = await fetch(`${ollamaUrl}/api/tags`); + + if (response.ok) { + const data = await response.json(); + if (data.models && data.models.length > 0) { + const modelNames = data.models + .map((m: { name: string }) => m.name) + .join(', '); + els.ollamaTestResult!.textContent = `✓ Available models: ${modelNames}`; + els.ollamaTestResult!.className = 'api-test-result success'; + } else { + els.ollamaTestResult!.textContent = + '⚠️ No models installed. Run "ollama pull llama3.2" to download one.'; + els.ollamaTestResult!.className = 'api-test-result error'; + } + } else { + els.ollamaTestResult!.textContent = '✗ Failed to fetch models'; + els.ollamaTestResult!.className = 'api-test-result error'; + } + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + els.ollamaTestResult!.textContent = `✗ Connection failed: ${msg}. Is Ollama running?`; + els.ollamaTestResult!.className = 'api-test-result error'; + } + }); + } + + if (els.downloadOllamaModelButton && els.ollamaDownloadModelInput && els.ollamaDownloadStatus) { + els.downloadOllamaModelButton.addEventListener('click', async () => { + const ollamaUrl = ( + els.ollamaUrlInput?.value.trim() || 'http://localhost:11434' + ).replace(/\/$/, ''); + const modelName = els.ollamaDownloadModelInput!.value.trim(); + const token = els.ollamaAuthTokenInput?.value.trim(); + + if (!modelName) { + els.ollamaDownloadStatus!.textContent = + '⚠️ Please enter a model name to download'; + els.ollamaDownloadStatus!.className = 'api-test-result error'; + els.ollamaDownloadStatus!.style.display = 'block'; + return; + } + + try { + els.downloadOllamaModelButton!.disabled = true; + els.ollamaDownloadStatus!.textContent = `Starting download of ${modelName}...`; + els.ollamaDownloadStatus!.className = 'api-test-result'; + els.ollamaDownloadStatus!.style.display = 'block'; + + const headers: Record = token + ? { Authorization: `Bearer ${token}` } + : {}; + await browser.runtime.sendMessage({ + action: 'startOllamaPull', + ollamaUrl, + model: modelName, + headers, + }); + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + els.ollamaDownloadStatus!.textContent = `✗ Failed to start: ${msg}`; + els.ollamaDownloadStatus!.className = 'api-test-result error'; + } finally { + els.downloadOllamaModelButton!.disabled = false; + } + }); + + // Listen for pull progress messages + browser.runtime.onMessage.addListener( + (msg: { action: string; status?: string; percent?: number; ok?: boolean; error?: string }) => { + if (msg.action === 'ollamaPullProgress') { + const parts: string[] = []; + if (msg.status) parts.push(msg.status); + if (typeof msg.percent === 'number') + parts.push(`${msg.percent}%`); + if (els.ollamaDownloadStatus) { + els.ollamaDownloadStatus.textContent = parts.join(' — '); + els.ollamaDownloadStatus.className = 'api-test-result'; + els.ollamaDownloadStatus.style.display = 'block'; + } + } else if (msg.action === 'ollamaPullComplete') { + if (els.ollamaDownloadStatus) { + if (msg.ok) { + els.ollamaDownloadStatus.textContent = '✓ Download complete'; + els.ollamaDownloadStatus.className = 'api-test-result success'; + } else { + els.ollamaDownloadStatus.textContent = `✗ Download failed: ${msg.error || 'unknown error'}`; + els.ollamaDownloadStatus.className = 'api-test-result error'; + } + els.ollamaDownloadStatus.style.display = 'block'; + } + } + } + ); + } + + if (els.diagnoseOllamaButton && els.ollamaDiagnostics) { + els.diagnoseOllamaButton.addEventListener('click', async () => { + const ollamaUrl = + els.ollamaUrlInput?.value.trim() || 'http://localhost:11434'; + let diagnosticOutput = + '🔍 OLLAMA DIAGNOSTICS\n' + '='.repeat(50) + '\n\n'; + + els.ollamaDiagnostics!.style.display = 'block'; + els.ollamaDiagnostics!.className = 'diagnostics-result'; + els.ollamaDiagnostics!.textContent = + diagnosticOutput + 'Running tests...\n'; + + try { + diagnosticOutput += '📋 Test 1: List Models Endpoint\n'; + diagnosticOutput += ` URL: ${ollamaUrl}/api/tags\n`; + try { + const tagsResponse = await fetch(`${ollamaUrl}/api/tags`); + diagnosticOutput += ` Status: ${tagsResponse.status} ${tagsResponse.statusText}\n`; + + if (tagsResponse.ok) { + const data = await tagsResponse.json(); + diagnosticOutput += ` ✓ SUCCESS - Found ${data.models?.length || 0} models\n`; + if (data.models && data.models.length > 0) { + diagnosticOutput += + ' Installed models: ' + + data.models.map((m: { name: string }) => m.name).join(', ') + + '\n'; + } else { + diagnosticOutput += ' ⚠️ No models installed\n'; + } + } else { + diagnosticOutput += ' ✗ FAILED\n'; + } + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + diagnosticOutput += ` ✗ ERROR: ${msg}\n`; + } + + diagnosticOutput += '\n🔢 Test 2: Version Endpoint\n'; + diagnosticOutput += ` URL: ${ollamaUrl}/api/version\n`; + try { + const versionResponse = await fetch( + `${ollamaUrl}/api/version` + ); + diagnosticOutput += ` Status: ${versionResponse.status} ${versionResponse.statusText}\n`; + + if (versionResponse.ok) { + const data = await versionResponse.json(); + diagnosticOutput += ` ✓ SUCCESS - Ollama version: ${data.version || 'unknown'}\n`; + } else { + diagnosticOutput += + ' ⚠️ Endpoint not available (older Ollama version)\n'; + } + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + diagnosticOutput += ` ✗ ERROR: ${msg}\n`; + } + + diagnosticOutput += '\n⬇️ Test 3: Pull Endpoint Check\n'; + diagnosticOutput += ` URL: ${ollamaUrl}/api/pull\n`; + diagnosticOutput += + ' Note: This endpoint is used for downloading models\n'; + + diagnosticOutput += '\n' + '='.repeat(50) + '\n'; + diagnosticOutput += '📊 SUMMARY:\n\n'; + + if (diagnosticOutput.includes('✓ SUCCESS - Found')) { + diagnosticOutput += '✓ Ollama is running and accessible\n'; + diagnosticOutput += `✓ API base URL: ${ollamaUrl}\n`; + els.ollamaDiagnostics!.className = 'diagnostics-result success'; + } else { + diagnosticOutput += '✗ Cannot connect to Ollama\n'; + diagnosticOutput += '\nTroubleshooting:\n'; + diagnosticOutput += + '1. Check if Ollama is running: ps aux | grep ollama\n'; + diagnosticOutput += '2. Start Ollama: ollama serve\n'; + diagnosticOutput += `3. Test manually: curl ${ollamaUrl}/api/tags\n`; + diagnosticOutput += + '4. Check if port 11434 is in use: lsof -i :11434\n'; + els.ollamaDiagnostics!.className = 'diagnostics-result error'; + } + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + diagnosticOutput += '\n❌ CRITICAL ERROR:\n'; + diagnosticOutput += msg + '\n'; + els.ollamaDiagnostics!.className = 'diagnostics-result error'; + } + + els.ollamaDiagnostics!.textContent = diagnosticOutput; + }); + } +} diff --git a/src/options/openai-compat.ts b/src/options/openai-compat.ts new file mode 100644 index 0000000..1fe6e71 --- /dev/null +++ b/src/options/openai-compat.ts @@ -0,0 +1,256 @@ +import { els } from './shared/dom-refs'; +import { updateSaveButtonState } from './save-settings'; +import type { DebugLogger } from '../shared/logger'; + +async function fetchModelsViaTab( + baseUrl: string, + apiKey: string +): Promise { + const tab = await browser.tabs.create({ url: baseUrl, active: false }); + await new Promise((resolve) => setTimeout(resolve, 500)); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + + const scriptCode = ` + (async () => { + try { + const headers = ${JSON.stringify(headers)}; + const response = await fetch(window.location.origin + '/v1/models', { + method: 'GET', + headers + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + const data = await response.json(); + window.__models_result = { ok: true, data }; + } catch (error) { + window.__models_result = { ok: false, error: error.message }; + } + })(); + `; + + await browser.tabs.executeScript(tab.id, { + code: scriptCode, + }); + + let result: { ok: boolean; data?: unknown; error?: string } | null = null; + for (let i = 0; i < 30; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + const results = await browser.tabs.executeScript(tab.id, { + code: 'window.__models_result || null', + }); + if (results && results[0]) { + result = results[0]; + break; + } + } catch { + continue; + } + } + + if (!result || !result.ok) { + throw new Error(result?.error || 'Timeout fetching models'); + } + + return result.data; + } finally { + try { + await browser.tabs.remove(tab.id); + } catch { + // tab may already be closed + } + } +} + +export function initOpenAICompatListeners(debugLogger: DebugLogger): void { + if (els.customModelSelect) { + els.customModelSelect.addEventListener('change', () => { + if (els.customModelCustomInput) { + els.customModelCustomInput.style.display = + els.customModelSelect!.value === 'custom' ? 'block' : 'none'; + } + updateSaveButtonState(); + }); + } + + if (els.customBaseUrlInput) { + els.customBaseUrlInput.addEventListener('input', updateSaveButtonState); + } + if (els.customModelCustomInput) { + els.customModelCustomInput.addEventListener('input', updateSaveButtonState); + } + + if (els.fetchCustomModelsButton && els.customTestResult) { + els.fetchCustomModelsButton.addEventListener('click', async () => { + const baseUrl = els.customBaseUrlInput?.value.trim().replace(/\/$/, '') || ''; + const apiKey = els.customApiKeyInput?.value.trim() || ''; + + if (!baseUrl) { + els.customTestResult!.textContent = + '⚠️ Please enter a base URL first'; + els.customTestResult!.className = 'api-test-result error'; + return; + } + + try { + els.customTestResult!.textContent = 'Fetching models from endpoint...'; + els.customTestResult!.className = 'api-test-result'; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + + const isLocalhost = + baseUrl.startsWith('http://localhost') || + baseUrl.startsWith('http://127.0.0.1'); + + let modelsData: { data?: Array<{ id?: string; name?: string }>; models?: Array<{ id?: string; name?: string }> }; + + if (isLocalhost) { + modelsData = (await fetchModelsViaTab( + baseUrl, + apiKey + )) as typeof modelsData; + } else { + const response = await fetch(baseUrl + '/models', { headers }); + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}` + ); + } + modelsData = await response.json(); + } + + const models: Array<{ id?: string; name?: string }> = + modelsData.data || modelsData.models || []; + + if (models.length === 0) { + els.customTestResult!.textContent = + '⚠️ No models found at this endpoint'; + els.customTestResult!.className = 'api-test-result error'; + return; + } + + if (els.customModelSelect) { + els.customModelSelect.innerHTML = + ''; + models.forEach((m) => { + const modelId = m.id || m.name || String(m); + const option = document.createElement('option'); + option.value = modelId; + option.textContent = modelId; + els.customModelSelect!.appendChild(option); + }); + const customOpt = document.createElement('option'); + customOpt.value = 'custom'; + customOpt.textContent = 'Custom (enter manually)'; + els.customModelSelect.appendChild(customOpt); + } + + els.customTestResult!.textContent = `✓ Found ${models.length} models. Select from dropdown or use "Custom" option.`; + els.customTestResult!.className = 'api-test-result success'; + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + console.error('[Fetch Models] Error:', error); + els.customTestResult!.textContent = `✗ Failed to fetch models: ${msg}`; + els.customTestResult!.className = 'api-test-result error'; + } + }); + } + + if (els.testCustomEndpointButton && els.customTestResult) { + els.testCustomEndpointButton.addEventListener('click', async () => { + const baseUrl = els.customBaseUrlInput?.value.trim() || ''; + let model = els.customModelSelect?.value || ''; + const apiKey = els.customApiKeyInput?.value.trim() || ''; + + if (model === 'custom' && els.customModelCustomInput) { + model = els.customModelCustomInput.value.trim(); + } + + if (!baseUrl) { + els.customTestResult!.textContent = '⚠️ Please enter a base URL'; + els.customTestResult!.className = 'api-test-result error'; + return; + } + if (!model) { + els.customTestResult!.textContent = '⚠️ Please enter a model name'; + els.customTestResult!.className = 'api-test-result error'; + return; + } + + try { + els.customTestResult!.textContent = 'Testing connection...'; + els.customTestResult!.className = 'api-test-result'; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const normalizedUrl = baseUrl.replace(/\/$/, ''); + debugLogger.info( + '[Custom]', + 'Test connecting to: ' + normalizedUrl + '/chat/completions' + ); + + const response = await fetch( + normalizedUrl + '/chat/completions', + { + method: 'POST', + headers, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'Test' }], + max_tokens: 10, + }), + } + ); + + debugLogger.info( + '[Custom]', + 'Response status: ' + response.status + ); + + if (response.ok) { + els.customTestResult!.textContent = `✓ Connected successfully! Model "${model}" is ready at ${normalizedUrl}`; + els.customTestResult!.className = 'api-test-result success'; + } else { + const errorText = await response.text(); + console.error( + '[Custom Endpoint Test] Error response:', + errorText + ); + let errorMsg = 'Connection failed'; + try { + const errorData = JSON.parse(errorText); + errorMsg = + errorData.error?.message || + errorData.error || + errorText; + } catch { + errorMsg = `HTTP ${response.status}: ${response.statusText}`; + } + els.customTestResult!.textContent = `✗ Error: ${errorMsg}`; + els.customTestResult!.className = 'api-test-result error'; + } + } catch (error: unknown) { + const msg = + error instanceof Error ? error.message : String(error); + console.error('[Custom Endpoint Test] Exception:', error); + els.customTestResult!.textContent = `✗ Connection failed: ${msg}. Check the base URL and ensure the endpoint is running.`; + els.customTestResult!.className = 'api-test-result error'; + } + }); + } +} diff --git a/src/options/providers.ts b/src/options/providers.ts new file mode 100644 index 0000000..76fee44 --- /dev/null +++ b/src/options/providers.ts @@ -0,0 +1,46 @@ +import type { ProviderId } from '../types/provider'; + +export interface ProviderUIInfo { + id: ProviderId; + name: string; + signupUrl: string | null; + isFree: boolean; + isLocal: boolean; +} + +export const PROVIDER_UI_LIST: ProviderUIInfo[] = [ + { id: 'gemini', name: 'Google Gemini', signupUrl: 'https://aistudio.google.com/app/apikey', isFree: true, isLocal: false }, + { id: 'openai', name: 'OpenAI ChatGPT', signupUrl: 'https://platform.openai.com/api-keys', isFree: false, isLocal: false }, + { id: 'anthropic', name: 'Anthropic Claude', signupUrl: 'https://console.anthropic.com/', isFree: false, isLocal: false }, + { id: 'groq', name: 'Groq', signupUrl: 'https://console.groq.com/keys', isFree: true, isLocal: false }, + { id: 'mistral', name: 'Mistral AI', signupUrl: 'https://console.mistral.ai/', isFree: false, isLocal: false }, + { id: 'ollama', name: 'Ollama (Local)', signupUrl: null, isFree: true, isLocal: true }, + { id: 'openai-compatible', name: 'OpenAI-Compatible', signupUrl: null, isFree: true, isLocal: true }, +]; + +export function getProviderInfo(id: string): ProviderUIInfo | undefined { + return PROVIDER_UI_LIST.find((p) => p.id === id); +} + +export function showProviderSections(provider: string): void { + const ollamaSection = document.getElementById('ollama-settings-subsection'); + const apiKeySection = document.getElementById('api-key-subsection'); + const geminiMultiSection = document.getElementById('gemini-multi-keys-subsection'); + const geminiUsageSection = document.getElementById('gemini-usage-subsection'); + const rateLimitWarning = document.getElementById('rate-limit-warning'); + const openaiCompatSection = document.getElementById('openai-compatible-settings-subsection'); + const geminiPaidContainer = document.getElementById('gemini-paid-container'); + + const isOllama = provider === 'ollama'; + const isOpenAICompat = provider === 'openai-compatible'; + const isGemini = provider === 'gemini'; + const isLocal = isOllama || isOpenAICompat; + + if (ollamaSection) ollamaSection.style.display = isOllama ? 'block' : 'none'; + if (apiKeySection) apiKeySection.style.display = isLocal ? 'none' : 'block'; + if (geminiMultiSection) geminiMultiSection.style.display = isGemini ? 'block' : 'none'; + if (geminiUsageSection) geminiUsageSection.style.display = isGemini ? 'block' : 'none'; + if (rateLimitWarning) rateLimitWarning.style.display = isGemini ? 'block' : 'none'; + if (openaiCompatSection) openaiCompatSection.style.display = isOpenAICompat ? 'block' : 'none'; + if (geminiPaidContainer) geminiPaidContainer.style.display = isGemini ? 'block' : 'none'; +} diff --git a/src/options/save-settings.ts b/src/options/save-settings.ts new file mode 100644 index 0000000..cdea5ba --- /dev/null +++ b/src/options/save-settings.ts @@ -0,0 +1,348 @@ +import { els } from './shared/dom-refs'; +import { showMessage } from './shared/ui'; +import { getGeminiKeys } from './gemini-keys'; +import { SETTINGS_KEYS } from './settings'; +import type { AutoSortSettings } from '../types/settings'; + +// ── Save Button State ───────────────────────────────────────────── + +export function updateSaveButtonState(): void { + const labels = Array.from( + els.labelsContainer.querySelectorAll('.label-input') + ) + .map((input) => input.value.trim()) + .filter((label) => label !== ''); + + const provider = els.aiProviderSelect.value; + let hasValidApiKey = true; + + if (provider === 'gemini') { + const keys = getGeminiKeys(); + const validKeys = keys.filter((k) => k && k.trim() !== ''); + hasValidApiKey = validKeys.length > 0; + } else if (provider === 'ollama') { + const ollamaUrl = els.ollamaUrlInput ? els.ollamaUrlInput.value.trim() : ''; + let ollamaModel = els.ollamaModelSelect ? els.ollamaModelSelect.value : ''; + const ollamaCustomModel = els.ollamaCustomModelInput + ? els.ollamaCustomModelInput.value.trim() + : ''; + hasValidApiKey = + !!ollamaUrl && + (!!ollamaModel || + (!!ollamaCustomModel && ollamaModel === 'custom')); + } else if (provider === 'openai-compatible') { + const baseUrl = els.customBaseUrlInput ? els.customBaseUrlInput.value.trim() : ''; + const model = els.customModelSelect ? els.customModelSelect.value : ''; + const customModel = els.customModelCustomInput + ? els.customModelCustomInput.value.trim() + : ''; + hasValidApiKey = + !!baseUrl && (!!model || (!!customModel && model === 'custom')); + } else { + const apiKey = els.apiKeyInput.value.trim(); + hasValidApiKey = !!apiKey; + } + + if (labels.length === 0 || !hasValidApiKey) { + els.saveButton.disabled = true; + els.saveButton.classList.add('disabled'); + const missingItems: string[] = []; + if (labels.length === 0) missingItems.push('folders/labels'); + if (!hasValidApiKey) { + if (provider === 'ollama') missingItems.push('Ollama URL/model'); + else if (provider === 'openai-compatible') + missingItems.push('endpoint URL/model'); + else if (provider === 'gemini') + missingItems.push('Gemini API key'); + else missingItems.push('API key'); + } + els.saveButton.title = `Please configure: ${missingItems.join(' and ')}`; + } else { + els.saveButton.disabled = false; + els.saveButton.classList.remove('disabled'); + els.saveButton.title = ''; + } +} + +// ── Collect Settings ────────────────────────────────────────────── + +export function collectSettings(): Partial { + const labels = Array.from( + document.querySelectorAll('.label-input') + ) + .map((input) => input.value.trim()) + .filter((label) => label !== ''); + + const provider = els.aiProviderSelect.value; + const batchChunkSizeEl = document.getElementById('batch-chunk-size') as HTMLInputElement | null; + const batchChunkSize = Math.max( + 1, + Math.min(20, parseInt(batchChunkSizeEl?.value || '5') || 5) + ); + + const autoSortEl = document.getElementById('enable-auto-sort') as HTMLInputElement | null; + const autoSortEnabled = autoSortEl ? autoSortEl.checked : false; + + const promptEl = document.getElementById('custom-prompt-text') as HTMLTextAreaElement | null; + const customPrompt = promptEl ? promptEl.value.trim() : ''; + + const enableAiEl = document.getElementById('enable-ai') as HTMLInputElement | null; + const enableAi = enableAiEl ? enableAiEl.checked : true; + + const debugMode = els.enableDebugCheckbox ? els.enableDebugCheckbox.checked : false; + + return { + labels, + aiProvider: provider as AutoSortSettings['aiProvider'], + enableAi, + debugMode, + batchChunkSize, + autoSortEnabled, + customPrompt, + }; +} + +// ── Handle Save ─────────────────────────────────────────────────── + +export async function handleSave(): Promise { + const labels = Array.from( + document.querySelectorAll('.label-input') + ) + .map((input) => input.value.trim()) + .filter((label) => label !== ''); + + const provider = els.aiProviderSelect.value; + + const batchChunkSizeEl = document.getElementById('batch-chunk-size') as HTMLInputElement | null; + const batchChunkSize = Math.max( + 1, + Math.min(20, parseInt(batchChunkSizeEl?.value || '5') || 5) + ); + + const autoSortEl = document.getElementById('enable-auto-sort') as HTMLInputElement | null; + const autoSortEnabled = autoSortEl ? autoSortEl.checked : false; + + const promptEl = document.getElementById('custom-prompt-text') as HTMLTextAreaElement | null; + const customPrompt = promptEl ? promptEl.value.trim() : ''; + + if (labels.length === 0) { + showMessage( + 'Please add at least one folder/label before saving. Use "Load Folders from Mail Account" or add custom labels.', + false + ); + return; + } + + if (provider === 'gemini') { + const geminiKeys = getGeminiKeys(); + const validGeminiKeys = geminiKeys.filter((k) => k && k.trim() !== ''); + + if (validGeminiKeys.length === 0) { + showMessage( + 'Please add at least one Gemini API key before saving.', + false + ); + return; + } + + const uniqueKeys = new Set( + validGeminiKeys.map((k) => k.trim().toLowerCase()) + ); + if (uniqueKeys.size !== validGeminiKeys.length) { + showMessage( + '⚠️ Duplicate API keys detected! Each key must be unique. Please remove duplicates before saving.', + false + ); + return; + } + + let storageData: Record; + try { + storageData = await browser.storage.local.get([ + 'currentGeminiKeyIndex', + 'geminiRateLimits', + ]); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessage('Error checking rate limits: ' + msg, false); + return; + } + + const settings: Record = { + labels, + geminiApiKeys: validGeminiKeys, + currentGeminiKeyIndex: storageData.currentGeminiKeyIndex || 0, + aiProvider: provider, + enableAi: els.enableAiCheckbox.checked, + geminiPaidPlan: els.geminiPaidCheckbox.checked, + debugMode: els.enableDebugCheckbox ? els.enableDebugCheckbox.checked : false, + batchChunkSize, + autoSortEnabled, + customPrompt, + }; + + if ( + !storageData.geminiRateLimits || + (storageData.geminiRateLimits as unknown[]).length !== + validGeminiKeys.length + ) { + settings.geminiRateLimits = validGeminiKeys.map(() => ({ + requests: [], + dailyCount: 0, + dailyResetTime: Date.now() + 24 * 60 * 60 * 1000, + })); + } + + try { + await browser.storage.local.remove(SETTINGS_KEYS); + await browser.storage.local.set(settings); + showMessage( + '✓ Settings saved successfully! Multiple Gemini API keys configured for automatic rotation.', + true + ); + updateSaveButtonState(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessage('Error saving settings: ' + msg, false); + } + } else if (provider === 'ollama') { + let ollamaModel = els.ollamaModelSelect ? els.ollamaModelSelect.value : ''; + if (ollamaModel === 'custom') { + ollamaModel = els.ollamaCustomModelInput + ? els.ollamaCustomModelInput.value.trim() + : ''; + if (!ollamaModel) { + showMessage( + 'Please enter a custom model name for Ollama.', + false + ); + return; + } + } + + const settings: Record = { + labels, + aiProvider: provider, + enableAi: els.enableAiCheckbox.checked, + ollamaUrl: + (els.ollamaUrlInput ? els.ollamaUrlInput.value.trim() : '') || + 'http://localhost:11434', + ollamaModel, + ollamaCustomModel: els.ollamaCustomModelInput + ? els.ollamaCustomModelInput.value.trim() + : '', + ollamaAuthToken: els.ollamaAuthTokenInput + ? els.ollamaAuthTokenInput.value.trim() + : '', + ollamaCpuOnly: els.ollamaCpuOnlyCheckbox + ? els.ollamaCpuOnlyCheckbox.checked + : false, + debugMode: els.enableDebugCheckbox ? els.enableDebugCheckbox.checked : false, + batchChunkSize, + autoSortEnabled, + customPrompt, + }; + + try { + await browser.storage.local.remove(SETTINGS_KEYS); + await browser.storage.local.set(settings); + const cpuMode = els.ollamaCpuOnlyCheckbox?.checked + ? ' (CPU-only mode)' + : ''; + showMessage( + `✓ Settings saved successfully! Ollama is configured for local email processing${cpuMode}.`, + true + ); + updateSaveButtonState(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessage('Error saving settings: ' + msg, false); + } + } else if (provider === 'openai-compatible') { + const baseUrl = els.customBaseUrlInput + ? els.customBaseUrlInput.value.trim() + : ''; + let model = els.customModelSelect ? els.customModelSelect.value : ''; + const apiKey = els.customApiKeyInput + ? els.customApiKeyInput.value.trim() + : ''; + + if (model === 'custom' && els.customModelCustomInput) { + model = els.customModelCustomInput.value.trim(); + } + + if (!baseUrl) { + showMessage( + 'Please enter a base URL for the custom endpoint.', + false + ); + return; + } + if (!model) { + showMessage( + 'Please select or enter a model name for the custom endpoint.', + false + ); + return; + } + + const settings: Record = { + labels, + aiProvider: provider, + enableAi: els.enableAiCheckbox.checked, + customBaseUrl: baseUrl.replace(/\/$/, ''), + customModel: model, + apiKey, + debugMode: els.enableDebugCheckbox ? els.enableDebugCheckbox.checked : false, + batchChunkSize, + autoSortEnabled, + customPrompt, + }; + + try { + await browser.storage.local.remove(SETTINGS_KEYS); + await browser.storage.local.set(settings); + showMessage( + '✓ Settings saved successfully! Custom OpenAI-compatible endpoint configured.', + true + ); + updateSaveButtonState(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessage('Error saving settings: ' + msg, false); + } + } else { + const apiKey = els.apiKeyInput.value.trim(); + if (!apiKey) { + showMessage( + 'Please enter your API key before saving. Click "Get API Key" to obtain one.', + false + ); + return; + } + + const settings: Record = { + labels, + apiKey, + aiProvider: provider, + enableAi: els.enableAiCheckbox.checked, + debugMode: els.enableDebugCheckbox ? els.enableDebugCheckbox.checked : false, + batchChunkSize, + autoSortEnabled, + customPrompt, + }; + + try { + await browser.storage.local.remove(SETTINGS_KEYS); + await browser.storage.local.set(settings); + showMessage( + '✓ Settings saved successfully! You can now use AutoSort+ to analyze emails.', + true + ); + updateSaveButtonState(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + showMessage('Error saving settings: ' + msg, false); + } + } +} diff --git a/src/options/settings.ts b/src/options/settings.ts new file mode 100644 index 0000000..d44dae8 --- /dev/null +++ b/src/options/settings.ts @@ -0,0 +1,37 @@ +import type { AutoSortSettings } from '../types/settings'; + +declare const browser: any; + +const SETTINGS_KEYS: (keyof AutoSortSettings)[] = [ + 'aiProvider', 'enableAi', 'apiKey', 'geminiApiKeys', 'geminiPaidPlan', + 'ollamaUrl', 'ollamaModel', 'ollamaCustomModel', 'ollamaAuthToken', 'ollamaCpuOnly', + 'ollamaNumCtx', + 'customBaseUrl', 'customModel', 'labels', 'debugMode', + 'batchChunkSize', 'autoSortEnabled', 'customPrompt', +]; + +// Keys that are managed internally (rate limits, history, batch state, etc.) +// and should NOT be cleared when saving user settings. +const INTERNAL_KEYS = [ + 'geminiRateLimits', 'currentGeminiKeyIndex', + 'moveHistory', 'currentBatch', +]; + +export async function loadSettings(): Promise { + return browser.storage.local.get(SETTINGS_KEYS) as Promise; +} + +export async function saveSettings(settings: Partial): Promise { + // Preserve internal state that should survive config saves + const internal = await browser.storage.local.get(INTERNAL_KEYS); + // Wipe all user-facing keys, then write the full new set atomically + await browser.storage.local.remove(SETTINGS_KEYS); + await browser.storage.local.set({ ...settings, ...internal }); +} + +export async function getSetting(key: K): Promise { + const data = (await browser.storage.local.get(key)) as Partial; + return data[key]; +} + +export { SETTINGS_KEYS }; diff --git a/src/options/shared/dom-refs.ts b/src/options/shared/dom-refs.ts new file mode 100644 index 0000000..7447d13 --- /dev/null +++ b/src/options/shared/dom-refs.ts @@ -0,0 +1,68 @@ +import { getElement, getElementOrNull } from './dom'; + +export const els = { + // Always-present core elements + labelsContainer: getElement('labels-container'), + addLabelButton: getElement('add-label'), + saveButton: getElement('save-settings'), + apiKeyInput: getElement('api-key'), + aiProviderSelect: getElement('ai-provider'), + providerInfo: getElement('provider-info'), + getApiKeyButton: getElement('get-api-key'), + testApiButton: getElement('test-api'), + apiTestResult: getElement('api-test-result'), + geminiPaidContainer: getElement('gemini-paid-container'), + geminiPaidCheckbox: getElement('gemini-paid-plan'), + importLabelsButton: getElement('import-labels'), + bulkImportTextarea: getElement('bulk-import-text'), + loadImapFoldersButton: getElement('load-imap-folders'), + folderLoadingIndicator: getElement('folder-loading'), + folderSelection: getElement('folder-selection'), + foldersPreview: getElement('folders-preview'), + folderCount: getElement('folder-count'), + useImapFoldersButton: getElement('use-imap-folders'), + useCustomFoldersButton: getElement('use-custom-folders'), + geminiMultiKeysContainer: getElement('gemini-multi-keys-subsection'), + geminiKeysList: getElement('gemini-keys-list'), + addGeminiKeyButton: getElement('add-gemini-key'), + + // Ollama-specific elements (hidden unless provider=ollama) + ollamaModelSelect: getElementOrNull('ollama-model'), + ollamaCustomModelInput: getElementOrNull('ollama-custom-model'), + ollamaUrlInput: getElementOrNull('ollama-url'), + ollamaAuthTokenInput: getElementOrNull('ollama-auth-token'), + ollamaCpuOnlyCheckbox: getElementOrNull('ollama-cpu-only'), + testOllamaButton: getElementOrNull('test-ollama'), + listOllamaModelsButton: getElementOrNull('list-ollama-models'), + downloadOllamaModelButton: getElementOrNull('download-ollama-model'), + ollamaDownloadModelInput: getElementOrNull('ollama-download-model'), + ollamaDownloadStatus: getElementOrNull('ollama-download-status'), + ollamaTestResult: getElementOrNull('ollama-test-result'), + diagnoseOllamaButton: getElementOrNull('diagnose-ollama'), + ollamaDiagnostics: getElementOrNull('ollama-diagnostics'), + + // OpenAI-Compatible elements (hidden unless provider=openai-compatible) + customBaseUrlInput: getElementOrNull('custom-base-url'), + customModelSelect: getElementOrNull('custom-model-select'), + customModelCustomInput: getElementOrNull('custom-model-custom'), + customApiKeyInput: getElementOrNull('custom-api-key'), + fetchCustomModelsButton: getElementOrNull('fetch-custom-models'), + testCustomEndpointButton: getElementOrNull('test-custom-endpoint'), + customTestResult: getElementOrNull('custom-test-result'), + + // AI enable + enableAiCheckbox: getElement('enable-ai'), + + // Debug mode + enableDebugCheckbox: getElement('enable-debug'), + + // Batch progress + batchChunkSizeInput: getElement('batch-chunk-size'), + autoSortCheckbox: getElement('enable-auto-sort'), + customPromptTextarea: getElement('custom-prompt-text'), + + // History + historyBody: getElement('history-body'), + historyTable: getElement('history-table'), + resetPromptButton: getElement('reset-prompt'), +}; diff --git a/src/options/shared/dom.ts b/src/options/shared/dom.ts new file mode 100644 index 0000000..09ebe4b --- /dev/null +++ b/src/options/shared/dom.ts @@ -0,0 +1,13 @@ +export function getElement(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Element #${id} not found`); + return el as T; +} + +export function getElementOrNull(id: string): T | null { + return document.getElementById(id) as T | null; +} + +export function safeQuerySelector(selector: string): T | null { + return document.querySelector(selector) as T | null; +} diff --git a/src/options/shared/event-bus.ts b/src/options/shared/event-bus.ts new file mode 100644 index 0000000..7f5f284 --- /dev/null +++ b/src/options/shared/event-bus.ts @@ -0,0 +1,22 @@ +type Listener = (...args: unknown[]) => void; + +class EventBus { + private listeners = new Map>(); + + on(event: string, fn: Listener): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(fn); + } + + off(event: string, fn: Listener): void { + this.listeners.get(event)?.delete(fn); + } + + emit(event: string, ...args: unknown[]): void { + this.listeners.get(event)?.forEach((fn) => fn(...args)); + } +} + +export const eventBus = new EventBus(); diff --git a/src/options/shared/index.ts b/src/options/shared/index.ts new file mode 100644 index 0000000..db3de8e --- /dev/null +++ b/src/options/shared/index.ts @@ -0,0 +1,2 @@ +export { getElement, getElementOrNull, safeQuerySelector } from './dom'; +export { eventBus } from './event-bus'; diff --git a/src/options/shared/ui.ts b/src/options/shared/ui.ts new file mode 100644 index 0000000..4a7b57b --- /dev/null +++ b/src/options/shared/ui.ts @@ -0,0 +1,34 @@ +import { els } from './dom-refs'; + +export function showMessage(message: string, isSuccess = true): void { + const messageDiv = document.createElement('div'); + messageDiv.className = 'message'; + messageDiv.textContent = message; + messageDiv.style.backgroundColor = isSuccess + ? 'var(--success-color)' + : 'var(--error-color)'; + document.body.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.remove(); + }, 3000); +} + +export function showApiTestResult( + message: string, + isSuccess: boolean, + isInfo?: boolean +): void { + els.apiTestResult.textContent = message; + if (isInfo) { + els.apiTestResult.className = 'api-test-result info'; + } else { + els.apiTestResult.className = `api-test-result ${isSuccess ? 'success' : 'error'}`; + } +} + +export function formatTimestamp(timestamp: unknown): string { + if (!timestamp) return 'Unknown'; + const date = new Date(timestamp as number); + return isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); +} diff --git a/src/popup/ollama-popup.ts b/src/popup/ollama-popup.ts new file mode 100644 index 0000000..90d551a --- /dev/null +++ b/src/popup/ollama-popup.ts @@ -0,0 +1,138 @@ +interface OllamaAnalyzeMessage { + command: 'ollama_analyze'; + ollama_host: string; + ollama_model: string; + ollama_num_ctx: number; + ollama_auth_token: string; + prompt: string; +} + +interface OllamaErrorMessage { + command: 'ollama_error'; + error: string; +} + +type PopupMessage = OllamaAnalyzeMessage | OllamaErrorMessage; + +let statusEl: HTMLElement | null = null; +let messagesEl: HTMLElement | null = null; +let responseEl: HTMLElement | null = null; +let analysisResult: string | null = null; + +const urlParams = new URLSearchParams(window.location.search); +const callId = urlParams.get('call_id'); + +document.addEventListener('DOMContentLoaded', async () => { + statusEl = document.getElementById('status'); + messagesEl = document.getElementById('messages'); + responseEl = document.getElementById('response'); + + if (statusEl) statusEl.textContent = 'Ready'; + + const currentWindow = await browser.windows.getCurrent(); + browser.runtime + .sendMessage({ + command: `ollama_popup_ready_${callId}`, + window_id: currentWindow.id, + }) + .catch((err: Error) => console.log('Ready message error (expected):', err.message)); +}); + +browser.runtime.onMessage.addListener( + (message: PopupMessage, _sender: unknown, _sendResponse: unknown) => { + switch (message.command) { + case 'ollama_analyze': + handleOllamaAnalyze(message); + break; + case 'ollama_error': + if (statusEl) statusEl.textContent = `Error: ${message.error}`; + if (responseEl) responseEl.textContent = message.error; + analysisResult = null; + sendResultToBackground(); + break; + default: + console.log('Unknown command:', (message as { command: string }).command); + } + } +); + +async function handleOllamaAnalyze(message: OllamaAnalyzeMessage): Promise { + const { ollama_host, ollama_model, ollama_num_ctx, ollama_auth_token, prompt } = message; + + try { + if (statusEl) statusEl.textContent = 'Connecting to Ollama...'; + if (responseEl) responseEl.textContent = ''; + analysisResult = null; + + const userMsgEl = document.createElement('div'); + userMsgEl.className = 'message user-message'; + userMsgEl.textContent = `Analyzing: ${prompt.substring(0, 100)}...`; + if (messagesEl) messagesEl.appendChild(userMsgEl); + + if (statusEl) statusEl.textContent = 'Processing with Ollama...'; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (ollama_auth_token) { + headers['Authorization'] = `Bearer ${ollama_auth_token}`; + } + + const requestBody: Record = { + model: ollama_model, + messages: [{ role: 'user', content: prompt }], + stream: false, + }; + + if (ollama_num_ctx > 0) { + requestBody.options = { num_ctx: parseInt(String(ollama_num_ctx), 10) }; + } + + console.log(`[Ollama Popup] Sending POST to: ${ollama_host}/api/chat`); + console.log(`[Ollama Popup] Model: ${ollama_model}`); + + const response = await fetch(`${ollama_host}/api/chat`, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + mode: 'cors', + credentials: 'omit', + }); + + console.log(`[Ollama Popup] Response status: ${response.status}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[Ollama Popup] Error response: ${errorText}`); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as { message?: { content?: string } }; + console.log(`[Ollama Popup] Response data: ${JSON.stringify(data).substring(0, 300)}`); + + if (data.message?.content) { + analysisResult = data.message.content; + if (responseEl) responseEl.textContent = analysisResult; + if (statusEl) statusEl.textContent = 'Analysis complete ✓'; + } else { + throw new Error('Invalid response format: missing message.content'); + } + + setTimeout(sendResultToBackground, 1000); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Ollama Popup] Error: ${msg}`); + if (statusEl) statusEl.textContent = `Error: ${msg}`; + if (responseEl) responseEl.textContent = `Error: ${msg}`; + analysisResult = null; + sendResultToBackground(); + } +} + +function sendResultToBackground(): void { + browser.runtime + .sendMessage({ + command: `ollama_analysis_result_${callId}`, + result: analysisResult, + error: analysisResult === null ? 'Analysis failed' : null, + }) + .catch((err: Error) => console.log('Result message error:', err.message)); +} diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts new file mode 100644 index 0000000..d368b4f --- /dev/null +++ b/src/shared/i18n.ts @@ -0,0 +1,34 @@ +declare const browser: { i18n: { getMessage(messageName: string, substitutions?: string | string[]): string } }; + +export const i18n = { + get(key: string, substitutions?: string | string[]): string { + try { + const msg = browser.i18n.getMessage(key, substitutions); + return msg || key; + } catch { + return key; + } + }, +}; + +export function applyTranslations(): void { + document.querySelectorAll('[data-i18n]').forEach((el) => { + const key = el.getAttribute('data-i18n'); + if (key) el.textContent = i18n.get(key); + }); + + document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + const key = el.getAttribute('data-i18n-placeholder'); + if (key) (el as HTMLInputElement).placeholder = i18n.get(key); + }); + + document.querySelectorAll('[data-i18n-title]').forEach((el) => { + const key = el.getAttribute('data-i18n-title'); + if (key) el.setAttribute('title', i18n.get(key)); + }); +} + +// Global singleton for backward compatibility with existing JS +if (typeof window !== 'undefined') { + (window as unknown as Record).applyTranslations = applyTranslations; +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..8e22e5a --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,4 @@ +export { DebugLogger } from './logger'; +export { getStorage, setStorage, removeStorage, clearStorage } from './storage'; +export { i18n, applyTranslations } from './i18n'; +export { fetchViaTab, ollamaChatViaTabUtil, openaiCompatChatViaTabUtil } from './tab-fetch'; diff --git a/src/shared/logger.ts b/src/shared/logger.ts new file mode 100644 index 0000000..8a9200e --- /dev/null +++ b/src/shared/logger.ts @@ -0,0 +1,170 @@ +type LogType = 'info' | 'warn' | 'error' | 'log'; + +interface QueuedLog { + type: LogType; + tag: string; + message: string; + data: unknown; +} + +declare const browser: any; + +export class DebugLogger { + private enabled = false; + private isReady = false; + private queue: QueuedLog[] = []; + + constructor() { + this.listenForChanges(); + this.init().catch(() => {}); + } + + async init(): Promise { + try { + const result = (await browser.storage.local.get('debugMode')) as { + debugMode?: boolean; + }; + this.enabled = !!result.debugMode; + this.isReady = true; + this.flushQueue(); + } catch { + this.isReady = true; + } + } + + private listenForChanges(): void { + if ( + typeof browser !== 'undefined' && + browser.storage?.onChanged + ) { + browser.storage.onChanged.addListener( + (changes: Record, area: string) => { + if (area === 'local' && changes.debugMode !== undefined) { + this.enabled = !!changes.debugMode.newValue; + } + } + ); + } + } + + async enable(): Promise { + this.enabled = true; + if (typeof browser === 'undefined' || !browser.storage) return; + try { + await browser.storage.local.set({ debugMode: true }); + } catch { + /* storage may not be available */ + } + } + + async disable(): Promise { + this.enabled = false; + if (typeof browser === 'undefined' || !browser.storage) return; + try { + await browser.storage.local.set({ debugMode: false }); + } catch { + /* storage may not be available */ + } + } + + private flushQueue(): void { + if (this.enabled && this.queue.length > 0) { + this.queue.forEach((log) => { + const style = this.getTagStyle(log.tag); + console[log.type](`%c${log.tag}`, style, log.message, log.data || ''); + }); + } + this.queue = []; + } + + private getTagStyle(tag: string): string { + if (tag.includes('Error') || tag.includes('error')) { + return 'color: white; background: #F44336; padding: 2px 6px; border-radius: 4px;'; + } + if (tag.includes('API')) { + return 'color: white; background: #9C27B0; padding: 2px 6px; border-radius: 4px;'; + } + if (tag.includes('RateLimit') || tag.includes('warn') || tag.includes('Warning')) { + return 'color: #333; background: #FFC107; padding: 2px 6px; border-radius: 4px;'; + } + if (tag.includes('Folder')) { + return 'color: white; background: #009688; padding: 2px 6px; border-radius: 4px;'; + } + return 'color: white; background: #2196F3; padding: 2px 6px; border-radius: 4px;'; + } + + private enqueueOrLog(type: LogType, tag: string, message: string, data: unknown): void { + if (!this.isReady) { + this.queue.push({ type, tag, message, data }); + return; + } + if (this.enabled) { + const style = this.getTagStyle(tag); + if (data !== null && data !== undefined) { + console[type](`%c${tag}`, style, message, data); + } else { + console[type](`%c${tag}`, style, message); + } + } + } + + info(tag: string, message: string, data: unknown = null): void { + this.enqueueOrLog('info', tag, message, data); + } + + warn(tag: string, message: string, data: unknown = null): void { + this.enqueueOrLog('warn', tag, message, data); + } + + error(tag: string, message: string, data: unknown = null): void { + if (!this.isReady) { + this.queue.push({ type: 'error', tag, message, data }); + return; + } + const style = 'color: white; background: #F44336; padding: 2px 6px; border-radius: 4px;'; + console.error( + `%c${tag}`, + style, + message, + data !== null && data !== undefined ? data : '' + ); + } + + apiRequest(provider: string, url: string, requestBody: unknown): void { + if (!this.isReady) return; + if (this.enabled) { + console.groupCollapsed( + `%c[API: ${provider}] Request`, + 'color: #9C27B0; font-weight: bold;' + ); + console.log('URL:', url); + console.log('Request Body:', requestBody); + console.groupEnd(); + } + } + + apiResponse(provider: string, status: number, data: unknown): void { + if (!this.isReady) return; + if (this.enabled) { + const isSuccess = status >= 200 && status < 300; + const color = isSuccess ? '#4CAF50' : '#F44336'; + const icon = isSuccess ? '✅' : '❌'; + console.groupCollapsed( + `%c[API: ${provider}] ${icon} Response (${status})`, + `color: ${color}; font-weight: bold;` + ); + console.log('Response Data:', data); + console.groupEnd(); + } + } + + log(tag: string, message: string, data: unknown = null): void { + this.info(tag, message, data); + } +} + +// Global singleton — maintain backward compatibility with existing JS +const logger = new DebugLogger(); +if (typeof window !== 'undefined') { + (window as unknown as Record).debugLogger = logger; +} diff --git a/src/shared/storage.ts b/src/shared/storage.ts new file mode 100644 index 0000000..40e486a --- /dev/null +++ b/src/shared/storage.ts @@ -0,0 +1,17 @@ +declare const browser: { storage: { local: { get(keys: string | string[] | null): Promise>; set(items: Record): Promise; remove(keys: string | string[]): Promise; clear(): Promise } } }; + +export async function getStorage(keys: string | string[] | null): Promise { + return browser.storage.local.get(keys) as Promise; +} + +export async function setStorage(items: Record): Promise { + return browser.storage.local.set(items); +} + +export async function removeStorage(keys: string | string[]): Promise { + return browser.storage.local.remove(keys); +} + +export async function clearStorage(): Promise { + return browser.storage.local.clear(); +} diff --git a/src/shared/tab-fetch.ts b/src/shared/tab-fetch.ts new file mode 100644 index 0000000..3f79325 --- /dev/null +++ b/src/shared/tab-fetch.ts @@ -0,0 +1,175 @@ +declare const browser: any; + +interface FetchViaTabOptions { + endpoint?: string; + body?: Record; + headers?: Record; + timeoutMs?: number; + resultKey?: string; +} + +interface TabFetchResult { + ok: boolean; + data?: unknown; + error?: string; +} + +export async function fetchViaTab( + baseUrl: string, + options: FetchViaTabOptions = {} +): Promise { + const { + endpoint = '', + body = {}, + headers = {}, + timeoutMs = 30000, + resultKey = '__tab_fetch_result', + } = options; + + const tab = await browser.tabs.create({ url: baseUrl, active: false }); + + let tabLoaded = false; + await new Promise((resolve) => { + const listener = ( + tabId: number, + changeInfo: { status?: string } + ): void => { + if (tabId === tab.id && changeInfo.status === 'complete') { + browser.tabs.onUpdated.removeListener(listener); + tabLoaded = true; + resolve(); + } + }; + browser.tabs.onUpdated.addListener(listener); + // 10 s timeout — Ollama can be slow to respond, especially on first request + // after idle when the model needs to warm up. + setTimeout(() => { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + }, 10000); + }); + + try { + const headersJson = JSON.stringify({ + 'Content-Type': 'application/json', + ...headers, + }); + const bodyJson = JSON.stringify(body); + + const scriptCode = ` +(async () => { + try { + const headers = ${headersJson}; + const response = await fetch(window.location.origin + '${endpoint}', { + method: 'POST', + headers, + body: ${bodyJson} + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + const data = await response.json(); + window.${resultKey} = { ok: true, data }; + } catch (error) { + window.${resultKey} = { ok: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +})(); +`; + + if (!tabLoaded) { + console.warn('[TabFetch] Tab did not finish loading within 10 s, attempting executeScript anyway'); + } + + await browser.tabs.executeScript(tab.id, { code: scriptCode }); + + const maxIterations = Math.ceil(timeoutMs / 500); + let result: TabFetchResult | null = null; + + for (let i = 0; i < maxIterations; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + const results = await browser.tabs.executeScript(tab.id, { + code: `window.${resultKey} || null`, + }); + if (results?.[0]) { + result = results[0] as TabFetchResult; + break; + } + } catch (e) { + // Transient error (tab may still be initializing) — retry a few times + if (i < 3) { + console.warn('[TabFetch] executeScript retry', i + 1, ':', (e as Error).message); + continue; + } + console.warn('[TabFetch] executeScript error:', (e as Error).message); + break; + } + } + + if (!result) { + throw new Error(`Tab fetch timed out (${timeoutMs}ms)`); + } + + if (!result.ok) { + throw new Error(result.error || 'Unknown error'); + } + + return result.data; + } finally { + try { + await browser.tabs.remove(tab.id); + } catch { + /* tab may already be closed */ + } + } +} + +export async function ollamaChatViaTabUtil( + ollamaUrl: string, + model: string, + prompt: string, + authToken?: string +): Promise { + const headers: Record = {}; + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + return fetchViaTab(ollamaUrl, { + endpoint: '/api/chat', + body: { + model, + messages: [{ role: 'user', content: prompt }], + stream: false, + }, + headers, + resultKey: '__ollama_result', + }); +} + +export async function openaiCompatChatViaTabUtil( + baseUrl: string, + model: string, + prompt: string, + apiKey?: string +): Promise { + const headers: Record = {}; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + return fetchViaTab(baseUrl, { + endpoint: '/v1/chat/completions', + body: { + model, + messages: [{ role: 'user', content: prompt }], + max_tokens: 8192, + stream: false, + }, + headers, + resultKey: '__openai_compat_result', + }); +} + +// Global singleton for backward compatibility with existing JS +if (typeof window !== 'undefined') { + (window as unknown as Record).tabFetchUtils = { + fetchViaTab, + ollamaChatViaTabUtil, + openaiCompatChatViaTabUtil, + }; +} diff --git a/src/types/batch.ts b/src/types/batch.ts new file mode 100644 index 0000000..342f275 --- /dev/null +++ b/src/types/batch.ts @@ -0,0 +1,26 @@ +export type BatchStatus = 'idle' | 'running' | 'paused' | 'cancelled'; + +export interface BatchProgress { + total: number; + completed: number; + failed: number; + status: BatchStatus; + currentFile?: string; +} + +export interface BatchState { + isRunning: boolean; + isPaused: boolean; + isCancelled: boolean; + totalMessages: number; + completedCount: number; + failedCount: number; + results: BatchResult[]; +} + +export interface BatchResult { + messageId: number; + success: boolean; + label?: string; + error?: string; +} diff --git a/src/types/email.ts b/src/types/email.ts new file mode 100644 index 0000000..d3120f6 --- /dev/null +++ b/src/types/email.ts @@ -0,0 +1,12 @@ +export interface Attachment { + name: string; + contentType: string; + size: number; +} + +export interface EmailContext { + subject: string; + author: string; + attachments: Attachment[]; + body: string; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..8a1cd5b --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './provider'; +export * from './email'; +export * from './batch'; +export * from './settings'; diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..0d145e6 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,39 @@ +export const PROVIDERS = { + GEMINI: 'gemini', + OPENAI: 'openai', + ANTHROPIC: 'anthropic', + GROQ: 'groq', + MISTRAL: 'mistral', + OLLAMA: 'ollama', + OPENAI_COMPATIBLE: 'openai-compatible', +} as const; + +export type ProviderId = (typeof PROVIDERS)[keyof typeof PROVIDERS]; + +export interface BatchConfig { + concurrency: number; + delayMs: number; +} + +export interface ProviderConfig { + name: string; + signupUrl: string | null; + isFree: boolean; + endpoint: string | null; + requiresAuth: 'query' | 'header' | 'optional'; + batchConfig: BatchConfig; + isLocal?: boolean; +} + +export type ProviderConfigMap = Record; + +export function getProviderBatchConfig( + provider: ProviderId, + configs: ProviderConfigMap +): BatchConfig { + return configs[provider]?.batchConfig || { concurrency: 1, delayMs: 0 }; +} + +export function isValidProvider(provider: string): provider is ProviderId { + return Object.values(PROVIDERS).includes(provider as ProviderId); +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..219de5c --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,22 @@ +import type { ProviderId } from './provider'; + +export interface AutoSortSettings { + aiProvider: ProviderId; + enableAi: boolean; + apiKey: string; + geminiApiKeys: string[]; + geminiPaidPlan: boolean; + ollamaUrl: string; + ollamaModel: string; + ollamaCustomModel: string; + ollamaAuthToken: string; + ollamaCpuOnly: boolean; + ollamaNumCtx: number; + customBaseUrl: string; + customModel: string; + labels: string[]; + debugMode: boolean; + batchChunkSize: number; + autoSortEnabled: boolean; + customPrompt: string; +} diff --git a/src/types/thunderbird.d.ts b/src/types/thunderbird.d.ts new file mode 100644 index 0000000..3ae245e --- /dev/null +++ b/src/types/thunderbird.d.ts @@ -0,0 +1,8 @@ +/** + * Global Thunderbird WebExtension API declarations. + * These are provided at runtime by Thunderbird — declared here for TypeScript. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +declare const browser: any; +declare const messenger: any; diff --git a/src/workers/ollama-worker.ts b/src/workers/ollama-worker.ts new file mode 100644 index 0000000..8e41959 --- /dev/null +++ b/src/workers/ollama-worker.ts @@ -0,0 +1,154 @@ +import { Ollama } from '../ollama'; + +declare const self: Worker; + +interface WorkerMessage { + type: 'init' | 'chatMessage' | 'stop' | 'clearHistory'; + ollama_host?: string; + ollama_model?: string; + ollama_num_ctx?: number | string; + ollama_auth_token?: string; + message?: string; +} + +let ollamaHost: string | null = null; +let ollamaModel = ''; +let ollamaNumCtx: number | undefined = undefined; +let ollamaAuthToken = ''; +let ollama: Ollama | null = null; +let stopStreaming = false; +let conversationHistory: Array<{ role: string; content: string }> = []; +let assistantResponseAccumulator = ''; + +self.onmessage = async function (event: MessageEvent) { + switch (event.data.type) { + case 'init': { + ollamaHost = event.data.ollama_host || null; + ollamaModel = event.data.ollama_model || ''; + ollamaNumCtx = parseInt(String(event.data.ollama_num_ctx), 10) || undefined; + ollamaAuthToken = event.data.ollama_auth_token || ''; + try { + ollama = new Ollama({ + host: ollamaHost || '', + model: ollamaModel, + stream: true, + num_ctx: ollamaNumCtx, + authToken: ollamaAuthToken, + }); + console.log(`[Ollama Worker] Initialized with host: ${ollamaHost}, model: ${ollamaModel}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + self.postMessage({ type: 'error', payload: msg }); + } + break; + } + + case 'chatMessage': { + if (!ollama) { + self.postMessage({ type: 'error', payload: 'Worker not initialized' }); + break; + } + conversationHistory.push({ role: 'user', content: event.data.message || '' }); + console.log(`[Ollama Worker] Chat message received: ${event.data.message}`); + + try { + const response = await ollama.fetchResponse(conversationHistory); + self.postMessage({ type: 'messageSent' }); + + if (!response.ok) { + let errorMessage = response.statusText; + try { + const errorJSON = (await response.json()) as { error?: { message?: string } }; + if (errorJSON.error?.message) errorMessage = errorJSON.error.message; + } catch { + // Not JSON, use statusText + } + console.error(`[Ollama Worker] API Error: ${errorMessage}`); + self.postMessage({ + type: 'error', + payload: `Ollama API Error: ${response.status} ${errorMessage}`, + }); + break; + } + + if (!response.body) { + self.postMessage({ type: 'error', payload: 'No response body' }); + break; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + while (true) { + if (stopStreaming) { + stopStreaming = false; + reader.cancel(); + conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); + assistantResponseAccumulator = ''; + self.postMessage({ type: 'tokensDone' }); + break; + } + + const { done, value } = await reader.read(); + if (done) { + conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); + assistantResponseAccumulator = ''; + self.postMessage({ type: 'tokensDone' }); + break; + } + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + const parsedLines = lines + .map((line) => line.trim()) + .filter((line) => line !== '') + .map((line) => { + try { + return JSON.parse(line) as { message?: { content?: string } }; + } catch { + console.warn(`[Ollama Worker] JSON parse warning, skipped: ${line}`); + return null; + } + }) + .filter((parsed) => parsed !== null); + + for (const parsedLine of parsedLines) { + const content = parsedLine?.message?.content; + if (content) { + assistantResponseAccumulator += content; + self.postMessage({ type: 'newToken', payload: { token: content } }); + } + } + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Ollama Worker] Stream error: ${msg}`); + self.postMessage({ type: 'error', payload: `Connection error: ${msg}` }); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Ollama Worker] fetchResponse error:`, error); + self.postMessage({ type: 'error', payload: msg || 'Ollama API request failed' }); + } + break; + } + + case 'stop': + stopStreaming = true; + break; + + case 'clearHistory': + conversationHistory = []; + assistantResponseAccumulator = ''; + console.log('[Ollama Worker] Conversation history cleared'); + break; + + default: + console.error('[Ollama Worker] Unknown message type:', (event.data as { type: string }).type); + } +}; diff --git a/styles.css b/styles.css index c91319d..e2a4a8d 100644 --- a/styles.css +++ b/styles.css @@ -1,773 +1,939 @@ -:root { - --primary-color: #0060df; - --primary-hover: #003eaa; - --background-color: #f9f9fa; - --text-color: #0c0c0d; - --border-color: #d7d7db; - --success-color: #4CAF50; - --error-color: #f44336; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background-color: var(--background-color); - color: var(--text-color); - margin: 0; - padding: 20px; -} - -.container { - max-width: 800px; - margin: 0 auto; - background-color: white; - padding: 30px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -h1 { - color: var(--primary-color); - margin-bottom: 30px; -} - -h2 { - font-size: 1.2em; - margin-bottom: 15px; -} - -.section { - margin-bottom: 30px; - padding: 0; - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px; - cursor: pointer; - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-bottom: 1px solid var(--border-color); - transition: all 0.3s ease; - user-select: none; -} - -.section-header:hover { - background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%); -} - -.section-header h2 { - margin: 0; - font-size: 1.3em; - color: var(--primary-color); -} - -.collapse-icon { - font-size: 1.2em; - color: var(--primary-color); - transition: transform 0.3s ease; - font-weight: bold; -} - -.collapsible-section.collapsed .collapse-icon { - transform: rotate(-90deg); -} - -.section-content { - padding: 20px; - animation: slideDown 0.3s ease-out; -} - -.subsection { - background: #f8f9fa; - padding: 20px; - margin-bottom: 20px; - border-radius: 8px; - border-left: 4px solid var(--primary-color); -} - -.subsection h3 { - margin-top: 0; - margin-bottom: 15px; - color: var(--primary-color); - font-size: 1.1em; - font-weight: 600; -} - -.subsection:last-child { - margin-bottom: 0; -} - -.ai-info-subsection { - background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); - border-left-color: #1976d2; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.save-section { - padding: 20px; - background: linear-gradient(135deg, #f0f4ff 0%, #e0e8ff 100%); - border: 2px solid var(--primary-color); -} - -.input-group { - margin-bottom: 15px; -} - -.input-group label { - display: block; - margin-bottom: 5px; - font-weight: 500; -} - -.api-key-input { - width: 100%; - padding: 8px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 14px; - margin-bottom: 10px; -} - -.bulk-import { - margin-bottom: 20px; - padding: 15px; - background-color: var(--background-color); - border-radius: 4px; -} - -.bulk-import label { - display: block; - margin-bottom: 8px; - font-weight: 500; -} - -.bulk-import-textarea { - width: 100%; - height: 100px; - padding: 10px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 14px; - resize: vertical; - margin-bottom: 10px; - font-family: inherit; -} - -#test-api { - margin-top: 10px; - background-color: var(--primary-color); - color: white; -} - -.api-test-result { - margin-top: 10px; - padding: 10px; - border-radius: 4px; - display: none; -} - -.api-test-result.success { - display: block; - background-color: var(--success-color); - color: white; -} - -.api-test-result.error { - display: block; - background-color: var(--error-color); - color: white; -} - -.warning-box { - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 4px; - padding: 12px 16px; - margin: 15px 0; - color: #856404; -} - -.warning-box strong { - color: #856404; -} - -.usage-info { - background-color: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 4px; - padding: 15px; - margin: 15px 0; -} - -.usage-info h3 { - margin-top: 0; - margin-bottom: 12px; - font-size: 1em; - color: var(--primary-color); -} - -.usage-stats { - margin-bottom: 12px; -} - -.usage-item { - padding: 6px 0; - border-bottom: 1px solid #e9ecef; -} - -.usage-item:last-child { - border-bottom: none; -} - -.usage-item strong { - display: inline-block; - width: 150px; -} - -.usage-message { - margin-top: 10px; - padding: 10px; - border-radius: 4px; - display: none; -} - -.usage-message.warning { - display: block; - background-color: #fff3cd; - border: 1px solid #ffc107; - color: #856404; -} - -.usage-message.info { - display: block; - background-color: #d1ecf1; - border: 1px solid #bee5eb; - color: #0c5460; -} - -.button { - background-color: var(--primary-color); - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.2s; - text-decoration: none; - display: inline-block; -} - -.button:hover { - background-color: var(--primary-hover); -} - -.button-link { - background-color: #28a745; -} - -.button-link:hover { - background-color: #218838; -} - -.button.primary { - background-color: var(--primary-color); - padding: 10px 20px; - font-size: 16px; -} - -.button.disabled, -.button:disabled { - background-color: #cccccc; - color: #666666; - cursor: not-allowed; - opacity: 0.6; -} - -.button.disabled:hover, -.button:disabled:hover { - background-color: #cccccc; -} - -.instruction-message { - padding: 20px; - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 4px; - color: #856404; - text-align: center; - font-style: italic; - margin-bottom: 15px; -} - -.provider-select { - width: 100%; - padding: 10px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 14px; - background-color: white; - cursor: pointer; -} - -.provider-info { - margin: 15px 0; - padding: 15px; - background-color: #f0f8ff; - border-left: 4px solid var(--primary-color); - border-radius: 4px; -} - -.provider-details { - font-size: 14px; - line-height: 1.6; -} - -.provider-details strong { - font-size: 16px; - color: var(--text-color); -} - -.provider-details p { - margin: 8px 0 0 0; - color: #555; -} - -.free-badge { - display: inline-block; - padding: 2px 8px; - background-color: #28a745; - color: white; - border-radius: 3px; - font-size: 11px; - font-weight: bold; - margin-left: 8px; -} - -.paid-badge { - display: inline-block; - padding: 2px 8px; - background-color: #ffc107; - color: #333; - border-radius: 3px; - font-size: 11px; - font-weight: bold; - margin-left: 8px; -} - -.label-item { - display: flex; - gap: 10px; - margin-bottom: 10px; - align-items: center; -} - -.label-input { - flex: 1; - padding: 8px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 14px; -} - -.remove-label { - background-color: #ff4f4f; - color: white; - border: none; - width: 24px; - height: 24px; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; -} - -.remove-label:hover { - background-color: #d43535; -} - -.checkbox-container { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} - -input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; -} - -.message { - position: fixed; - bottom: 20px; - right: 20px; - background-color: var(--primary-color); - color: white; - padding: 10px 20px; - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - z-index: 1000; -} - -.ai-description { - margin-top: 20px; - padding: 15px; - background-color: var(--background-color); - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.ai-description p { - margin-bottom: 10px; - color: var(--text-color); - line-height: 1.5; -} - -.ai-description ul { - margin: 0; - padding-left: 20px; - color: var(--text-color); -} - -.ai-description li { - margin-bottom: 8px; - line-height: 1.4; -} - -.history-controls { - margin-bottom: 15px; - display: flex; - gap: 10px; -} - -.history-container { - max-height: 400px; - overflow-y: auto; - border: 1px solid #ddd; - border-radius: 4px; -} - -#history-table { - width: 100%; - border-collapse: collapse; -} - -#history-table th, -#history-table td { - padding: 10px; - text-align: left; - border-bottom: 1px solid #ddd; -} - -#history-table th { - background-color: #f5f5f5; - position: sticky; - top: 0; - z-index: 1; -} - -#history-table tr:hover { - background-color: #f9f9f9; -} - -#history-table .success { - color: #28a745; -} - -#history-table .error { - color: #dc3545; -} - -.timestamp { - white-space: nowrap; - font-family: monospace; -} - -.folder-source { - margin-bottom: 20px; - padding: 15px; - background-color: #f5f5f5; - border-radius: 4px; -} - -.folder-source h3 { - margin-top: 0; - margin-bottom: 10px; - font-size: 1em; -} - -.loading-indicator { - padding: 10px; - color: var(--primary-color); - font-style: italic; -} - -.folder-selection { - margin-top: 15px; - padding: 15px; - background-color: white; - border-radius: 4px; - border: 1px solid var(--border-color); -} - -.folder-selection p { - margin-bottom: 10px; -} - -.folders-preview { - max-height: 200px; - overflow-y: auto; - margin: 15px 0; - padding: 10px; - background-color: #f9f9f9; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.folder-preview-item { - padding: 5px 10px; - margin-bottom: 5px; - background-color: white; - border-radius: 3px; - border-left: 3px solid var(--primary-color); -} - -.button-group { - display: flex; - gap: 10px; - margin-top: 15px; -} - -/* Multiple Gemini API Keys */ -.multi-keys-header { - margin-bottom: 15px; -} - -.multi-keys-header h3 { - margin: 0 0 5px 0; - font-size: 1.1em; -} - -.multi-keys-header .info-text { - margin: 0; - font-size: 0.9em; - color: #666; -} - -.gemini-keys-list { - margin: 15px 0; -} - -.gemini-key-item { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - padding: 10px; - background-color: #f9f9f9; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.gemini-key-item.active { - border-left: 4px solid #28a745; - background-color: #f0fff0; -} - -.gemini-key-item .key-index { - font-weight: bold; - min-width: 30px; - color: #666; -} - -.gemini-key-item input { - flex: 1; - padding: 8px; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.gemini-key-item input:focus { - outline: none; - border-color: var(--primary-color); -} - -.gemini-key-item input[style*="border-color: #dc3545"] { - border-color: #dc3545 !important; - background-color: #fff5f5; -} - -.gemini-key-item .key-status { - font-size: 0.85em; - padding: 3px 8px; - border-radius: 3px; - white-space: nowrap; -} - -.gemini-key-item .key-status.ready { - background-color: #d4edda; - color: #155724; -} - -.gemini-key-item .key-status.warning { - background-color: #fff3cd; - color: #856404; -} - -.gemini-key-item .key-status.limit { - background-color: #f8d7da; - color: #721c24; -} - -.gemini-key-item .key-status.active { - background-color: #d1ecf1; - color: #0c5460; - font-weight: bold; -} - -.gemini-key-item button { - padding: 5px 10px; - font-size: 0.9em; -} - -.key-test-result { - font-size: 0.85em; - padding: 3px 8px; - border-radius: 3px; - white-space: nowrap; - min-width: 70px; - text-align: center; - cursor: help; -} - -.key-test-result.success { - background-color: #d4edda; - color: #155724; -} - -.key-test-result.error { - background-color: #f8d7da; - color: #721c24; -} - -.key-test-result.testing { - background-color: #d1ecf1; - color: #0c5460; -} - -.all-keys-usage { - margin-bottom: 15px; -} - -.key-usage-card { - margin-bottom: 10px; - padding: 12px; - background-color: #f9f9f9; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.key-usage-card.active { - border-left: 4px solid #28a745; - background-color: #f0fff0; -} - -.key-usage-card .key-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.key-usage-card .key-title { - font-weight: bold; - color: #333; -} - -.key-usage-card .key-stats { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - font-size: 0.9em; -} - -.key-usage-card .stat-item { - display: flex; - justify-content: space-between; -} - -.key-usage-card .stat-label { - color: #666; -} - -.key-usage-card .stat-value { - font-weight: 600; -} - -.info-box { - background: #e3f2fd; - border: 1px solid #90caf9; - padding: 12px; - border-radius: 6px; - margin-bottom: 15px; - font-size: 0.95em; -} - -.info-box code { - background: #fff; - padding: 2px 6px; - border-radius: 3px; - font-family: 'Courier New', monospace; - color: #d32f2f; -} - -.help-text { - display: block; - margin-top: 5px; - color: #666; - font-size: 0.9em; -} - -.diagnostics-result { - margin-top: 15px; - padding: 15px; - background: #f5f5f5; - border-radius: 6px; - border-left: 4px solid #ff9800; - font-family: 'Courier New', monospace; - font-size: 0.85em; - white-space: pre-wrap; - max-height: 300px; - overflow-y: auto; -} - -.diagnostics-result.success { - border-left-color: #4CAF50; - background: #e8f5e9; -} - -.diagnostics-result.error { - border-left-color: #f44336; - background: #ffebee; -} - \ No newline at end of file +:root { + --primary-color: #0060df; + --primary-hover: #003eaa; + --background-color: #f9f9fa; + --text-color: #0c0c0d; + --border-color: #d7d7db; + --success-color: #4CAF50; + --error-color: #f44336; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + margin: 0; + padding: 20px; +} + +.container { + max-width: 800px; + margin: 0 auto; + background-color: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +h1 { + color: var(--primary-color); + margin-bottom: 30px; +} + +h2 { + font-size: 1.2em; + margin-bottom: 15px; +} + +.section { + margin-bottom: 30px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + cursor: pointer; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-bottom: 1px solid var(--border-color); + transition: all 0.3s ease; + user-select: none; +} + +.section-header:hover { + background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%); +} + +.section-header h2 { + margin: 0; + font-size: 1.3em; + color: var(--primary-color); +} + +.collapse-icon { + font-size: 1.2em; + color: var(--primary-color); + transition: transform 0.3s ease; + font-weight: bold; +} + +.collapsible-section.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.section-content { + padding: 20px; + animation: slideDown 0.3s ease-out; +} + +.subsection { + background: #f8f9fa; + padding: 20px; + margin-bottom: 20px; + border-radius: 8px; + border-left: 4px solid var(--primary-color); +} + +.subsection h3 { + margin-top: 0; + margin-bottom: 15px; + color: var(--primary-color); + font-size: 1.1em; + font-weight: 600; +} + +.subsection:last-child { + margin-bottom: 0; +} + +.ai-info-subsection { + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + border-left-color: #1976d2; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.save-section { + padding: 20px; + background: linear-gradient(135deg, #f0f4ff 0%, #e0e8ff 100%); + border: 2px solid var(--primary-color); +} + +.input-group { + margin-bottom: 15px; +} + +.input-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.api-key-input { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + margin-bottom: 10px; +} + +.bulk-import { + margin-bottom: 20px; + padding: 15px; + background-color: var(--background-color); + border-radius: 4px; +} + +.bulk-import label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.bulk-import-textarea { + width: 100%; + height: 100px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + resize: vertical; + margin-bottom: 10px; + font-family: inherit; +} + +#test-api { + margin-top: 10px; + background-color: var(--primary-color); + color: white; +} + +.api-test-result { + margin-top: 10px; + padding: 10px; + border-radius: 4px; + display: none; +} + +.api-test-result.success { + display: block; + background-color: var(--success-color); + color: white; +} + +.api-test-result.error { + display: block; + background-color: var(--error-color); + color: white; +} + +.api-test-result.info { + display: block; + background-color: #0066cc; + color: white; +} + +.warning-box { + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + padding: 12px 16px; + margin: 15px 0; + color: #856404; +} + +.warning-box strong { + color: #856404; +} + +.usage-info { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 15px; + margin: 15px 0; +} + +.usage-info h3 { + margin-top: 0; + margin-bottom: 12px; + font-size: 1em; + color: var(--primary-color); +} + +.usage-stats { + margin-bottom: 12px; +} + +.usage-item { + padding: 6px 0; + border-bottom: 1px solid #e9ecef; +} + +.usage-item:last-child { + border-bottom: none; +} + +.usage-item strong { + display: inline-block; + width: 150px; +} + +.usage-message { + margin-top: 10px; + padding: 10px; + border-radius: 4px; + display: none; +} + +.usage-message.warning { + display: block; + background-color: #fff3cd; + border: 1px solid #ffc107; + color: #856404; +} + +.usage-message.info { + display: block; + background-color: #d1ecf1; + border: 1px solid #bee5eb; + color: #0c5460; +} + +.button { + background-color: var(--primary-color); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--primary-hover); +} + +.button-link { + background-color: #28a745; +} + +.button-link:hover { + background-color: #218838; +} + +.button.primary { + background-color: var(--primary-color); + padding: 10px 20px; + font-size: 16px; +} + +.button.disabled, +.button:disabled { + background-color: #cccccc; + color: #666666; + cursor: not-allowed; + opacity: 0.6; +} + +.button.disabled:hover, +.button:disabled:hover { + background-color: #cccccc; +} + +.instruction-message { + padding: 20px; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + color: #856404; + text-align: center; + font-style: italic; + margin-bottom: 15px; +} + +.provider-select { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; +} + +.provider-info { + margin: 15px 0; + padding: 15px; + background-color: #f0f8ff; + border-left: 4px solid var(--primary-color); + border-radius: 4px; +} + +.provider-details { + font-size: 14px; + line-height: 1.6; +} + +.provider-details strong { + font-size: 16px; + color: var(--text-color); +} + +.provider-details p { + margin: 8px 0 0 0; + color: #555; +} + +.free-badge { + display: inline-block; + padding: 2px 8px; + background-color: #28a745; + color: white; + border-radius: 3px; + font-size: 11px; + font-weight: bold; + margin-left: 8px; +} + +.paid-badge { + display: inline-block; + padding: 2px 8px; + background-color: #ffc107; + color: #333; + border-radius: 3px; + font-size: 11px; + font-weight: bold; + margin-left: 8px; +} + +.label-item { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: center; +} + +.label-input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; +} + +.remove-label { + background-color: #ff4f4f; + color: white; + border: none; + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.remove-label:hover { + background-color: #d43535; +} + +.checkbox-container { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.message { + position: fixed; + bottom: 20px; + right: 20px; + background-color: var(--primary-color); + color: white; + padding: 10px 20px; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 1000; +} + +.ai-description { + margin-top: 20px; + padding: 15px; + background-color: var(--background-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.ai-description p { + margin-bottom: 10px; + color: var(--text-color); + line-height: 1.5; +} + +.ai-description ul { + margin: 0; + padding-left: 20px; + color: var(--text-color); +} + +.ai-description li { + margin-bottom: 8px; + line-height: 1.4; +} + +.history-controls { + margin-bottom: 15px; + display: flex; + gap: 10px; +} + +.history-container { + max-height: 400px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 4px; +} + +#history-table { + width: 100%; + border-collapse: collapse; +} + +#history-table th, +#history-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +#history-table th { + background-color: #f5f5f5; + position: sticky; + top: 0; + z-index: 1; +} + +#history-table tr:hover { + background-color: #f9f9f9; +} + +#history-table .success { + color: #28a745; +} + +#history-table .error { + color: #dc3545; +} + +.timestamp { + white-space: nowrap; + font-family: monospace; +} + +.folder-source { + margin-bottom: 20px; + padding: 15px; + background-color: #f5f5f5; + border-radius: 4px; +} + +.folder-source h3 { + margin-top: 0; + margin-bottom: 10px; + font-size: 1em; +} + +.loading-indicator { + padding: 10px; + color: var(--primary-color); + font-style: italic; +} + +.folder-selection { + margin-top: 15px; + padding: 15px; + background-color: white; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.folder-selection p { + margin-bottom: 10px; +} + +.folders-preview { + max-height: 200px; + overflow-y: auto; + margin: 15px 0; + padding: 10px; + background-color: #f9f9f9; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.folder-preview-item { + padding: 5px 10px; + margin-bottom: 5px; + background-color: white; + border-radius: 3px; + border-left: 3px solid var(--primary-color); +} + +.button-group { + display: flex; + gap: 10px; + margin-top: 15px; +} + +/* Multiple Gemini API Keys */ +.multi-keys-header { + margin-bottom: 15px; +} + +.multi-keys-header h3 { + margin: 0 0 5px 0; + font-size: 1.1em; +} + +.multi-keys-header .info-text { + margin: 0; + font-size: 0.9em; + color: #666; +} + +.gemini-keys-list { + margin: 15px 0; +} + +.gemini-key-item { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + padding: 10px; + background-color: #f9f9f9; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.gemini-key-item.active { + border-left: 4px solid #28a745; + background-color: #f0fff0; +} + +.gemini-key-item .key-index { + font-weight: bold; + min-width: 30px; + color: #666; +} + +.gemini-key-item input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.gemini-key-item input:focus { + outline: none; + border-color: var(--primary-color); +} + +.gemini-key-item input[style*="border-color: #dc3545"] { + border-color: #dc3545 !important; + background-color: #fff5f5; +} + +.gemini-key-item .key-status { + font-size: 0.85em; + padding: 3px 8px; + border-radius: 3px; + white-space: nowrap; +} + +.gemini-key-item .key-status.ready { + background-color: #d4edda; + color: #155724; +} + +.gemini-key-item .key-status.warning { + background-color: #fff3cd; + color: #856404; +} + +.gemini-key-item .key-status.limit { + background-color: #f8d7da; + color: #721c24; +} + +.gemini-key-item .key-status.active { + background-color: #d1ecf1; + color: #0c5460; + font-weight: bold; +} + +.gemini-key-item button { + padding: 5px 10px; + font-size: 0.9em; +} + +.key-test-result { + font-size: 0.85em; + padding: 3px 8px; + border-radius: 3px; + white-space: nowrap; + min-width: 70px; + text-align: center; + cursor: help; +} + +.key-test-result.success { + background-color: #d4edda; + color: #155724; +} + +.key-test-result.error { + background-color: #f8d7da; + color: #721c24; +} + +.key-test-result.testing { + background-color: #d1ecf1; + color: #0c5460; +} + +.all-keys-usage { + margin-bottom: 15px; +} + +.key-usage-card { + margin-bottom: 10px; + padding: 12px; + background-color: #f9f9f9; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.key-usage-card.active { + border-left: 4px solid #28a745; + background-color: #f0fff0; +} + +.key-usage-card .key-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.key-usage-card .key-title { + font-weight: bold; + color: #333; +} + +.key-usage-card .key-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + font-size: 0.9em; +} + +.key-usage-card .stat-item { + display: flex; + justify-content: space-between; +} + +.key-usage-card .stat-label { + color: #666; +} + +.key-usage-card .stat-value { + font-weight: 600; +} + +.info-box { + background: #e3f2fd; + border: 1px solid #90caf9; + padding: 12px; + border-radius: 6px; + margin-bottom: 15px; + font-size: 0.95em; +} + +.info-box code { + background: #fff; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + color: #d32f2f; +} + +.help-text { + display: block; + margin-top: 5px; + color: #666; + font-size: 0.9em; +} + +.diagnostics-result { + margin-top: 15px; + padding: 15px; + background: #f5f5f5; + border-radius: 6px; + border-left: 4px solid #ff9800; + font-family: 'Courier New', monospace; + font-size: 0.85em; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +.diagnostics-result.success { + border-left-color: #4CAF50; + background: #e8f5e9; +} + +.diagnostics-result.error { + border-left-color: #f44336; + background: #ffebee; +} + +/* ── Batch Processing Status Panel ───────────────────────────────────────── */ + +.batch-status-panel { + margin-bottom: 24px; + padding: 18px 20px; + background: linear-gradient(135deg, #e8f0fe 0%, #d2e3fc 100%); + border: 2px solid var(--primary-color); + border-radius: 10px; + box-shadow: 0 3px 10px rgba(0, 96, 223, 0.15); + animation: slideDown 0.3s ease-out; +} + +.batch-status-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.batch-status-icon { + font-size: 1.4em; + animation: batchSpin 2s linear infinite; + display: inline-block; +} + +@keyframes batchSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Stop spinning when paused */ +.batch-status-panel[data-status="paused"] .batch-status-icon { + animation: none; +} + +/* Show done/cancelled state */ +.batch-status-panel[data-status="done"] .batch-status-icon, +.batch-status-panel[data-status="cancelled"] .batch-status-icon { + animation: none; +} + +.batch-status-title { + font-weight: 700; + font-size: 1.05em; + color: #003eaa; + flex: 1; +} + +.batch-provider-badge { + display: inline-block; + padding: 2px 10px; + background: var(--primary-color); + color: white; + border-radius: 12px; + font-size: 0.78em; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +/* Progress bar */ +.batch-progress-bar-wrap { + height: 12px; + background: rgba(255,255,255,0.6); + border-radius: 8px; + overflow: hidden; + margin-bottom: 10px; + border: 1px solid rgba(0, 96, 223, 0.2); +} + +.batch-progress-fill { + height: 100%; + background: linear-gradient(90deg, #0060df 0%, #00d4ff 100%); + border-radius: 8px; + transition: width 0.4s ease; + position: relative; + overflow: hidden; +} + +.batch-progress-fill::after { + content: ''; + position: absolute; + top: 0; left: -100%; + width: 60%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + to { left: 200%; } +} + +.batch-progress-text { + font-size: 0.88em; + color: #003eaa; + margin: 0 0 12px 0; +} + +/* Batch control buttons */ +.batch-controls { + display: flex; + gap: 8px; +} + +.batch-btn-pause { + background: #f59e0b; + color: white; + border: none; + padding: 6px 14px; + border-radius: 5px; + cursor: pointer; + font-size: 0.88em; + font-weight: 600; + transition: background 0.2s; +} + +.batch-btn-pause:hover { background: #d97706; } + +.batch-btn-resume { + background: #10b981; + color: white; + border: none; + padding: 6px 14px; + border-radius: 5px; + cursor: pointer; + font-size: 0.88em; + font-weight: 600; + transition: background 0.2s; +} + +.batch-btn-resume:hover { background: #059669; } + +.batch-btn-cancel { + background: #ef4444; + color: white; + border: none; + padding: 6px 14px; + border-radius: 5px; + cursor: pointer; + font-size: 0.88em; + font-weight: 600; + transition: background 0.2s; +} + +.batch-btn-cancel:hover { background: #dc2626; } + +/* ── Custom Prompt Textarea ─────────────────────────────────────────────── */ + +.prompt-textarea { + width: 100%; + min-height: 150px; + padding: 10px; + font-family: monospace; + font-size: 13px; + border: 1px solid #ccc; + border-radius: 4px; + resize: vertical; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/tests/helpers/fixtures/emails.ts b/tests/helpers/fixtures/emails.ts new file mode 100644 index 0000000..8699db2 --- /dev/null +++ b/tests/helpers/fixtures/emails.ts @@ -0,0 +1,39 @@ +export interface EmailFixture { + subject: string; + author: string; + body: string; + expectedLabel: string; +} + +export const sampleEmails: EmailFixture[] = [ + { + subject: "Your invoice #12345 from Acme Corp", + author: "billing@acme.com", + body: "Dear customer, your invoice #12345 is attached. Please remit payment by 2026-05-15.", + expectedLabel: "Finance", + }, + { + subject: "Meeting tomorrow at 3pm", + author: "boss@company.com", + body: "Hi team, let's sync up tomorrow at 3pm regarding the Q2 roadmap.", + expectedLabel: "Work", + }, + { + subject: "Your order has shipped!", + author: "orders@shop.com", + body: "Great news! Your order #ORD-001 has shipped. Track it here: https://track.shop.com/ORD-001", + expectedLabel: "Shopping", + }, + { + subject: "Newsletter: Weekly Digest", + author: "newsletter@technews.com", + body: "This week in tech: AI breakthroughs, new frameworks, and more.", + expectedLabel: "Newsletters", + }, + { + subject: "Invitation: Team Building Event", + author: "hr@company.com", + body: "You're invited to our quarterly team building event on May 1st.", + expectedLabel: "Work", + }, +]; diff --git a/tests/helpers/mock-messenger.ts b/tests/helpers/mock-messenger.ts new file mode 100644 index 0000000..08cc11e --- /dev/null +++ b/tests/helpers/mock-messenger.ts @@ -0,0 +1,96 @@ +import { vi } from "vitest"; + +const mockStorage: Record = {}; + +const mockBrowser = { + storage: { + local: { + get: vi.fn((keys: string | string[] | null) => { + if (keys === null) return Promise.resolve({ ...mockStorage }); + const keyList = Array.isArray(keys) ? keys : [keys]; + const result: Record = {}; + for (const k of keyList) { + if (k in mockStorage) result[k] = mockStorage[k]; + } + return Promise.resolve(result); + }), + set: vi.fn((items: Record) => { + Object.assign(mockStorage, items); + return Promise.resolve(); + }), + remove: vi.fn((keys: string | string[]) => { + const keyList = Array.isArray(keys) ? keys : [keys]; + for (const k of keyList) delete mockStorage[k]; + return Promise.resolve(); + }), + clear: vi.fn(() => { + Object.keys(mockStorage).forEach((k) => delete mockStorage[k]); + return Promise.resolve(); + }), + }, + }, + runtime: { + getURL: vi.fn((path: string) => `moz-extension://test-id/${path}`), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + sendMessage: vi.fn(() => Promise.resolve()), + onInstalled: { + addListener: vi.fn(), + }, + }, + tabs: { + create: vi.fn(() => Promise.resolve({ id: 1 })), + remove: vi.fn(() => Promise.resolve()), + executeScript: vi.fn(() => Promise.resolve([{ result: null }])), + }, + notifications: { + create: vi.fn(() => Promise.resolve("notif-1")), + update: vi.fn(() => Promise.resolve(true)), + }, + menus: { + create: vi.fn(), + removeAll: vi.fn(() => Promise.resolve()), + onClicked: { + addListener: vi.fn(), + }, + }, + windows: { + create: vi.fn(() => Promise.resolve({ id: 1 })), + }, + accounts: { + list: vi.fn(() => Promise.resolve([])), + }, + messages: { + list: vi.fn(() => Promise.resolve({ messages: [] })), + getFull: vi.fn(() => Promise.resolve(null)), + move: vi.fn(() => Promise.resolve()), + query: vi.fn(() => Promise.resolve({ messages: [] })), + }, + folders: { + query: vi.fn(() => Promise.resolve([])), + create: vi.fn(() => Promise.resolve({})), + }, + mailTabs: { + getCurrent: vi.fn(() => Promise.resolve({})), + }, + i18n: { + getMessage: vi.fn((key: string) => key), + }, +}; + +// Thunderbird uses both `browser` and `messenger` namespaces +(globalThis as any).browser = mockBrowser; +(globalThis as any).messenger = mockBrowser; +(globalThis as any).window = globalThis; + +export function resetStorage(): void { + Object.keys(mockStorage).forEach((k) => delete mockStorage[k]); +} + +export function seedStorage(data: Record): void { + Object.assign(mockStorage, data); +} + +export { mockBrowser }; diff --git a/tests/unit/ai/prompt.test.ts b/tests/unit/ai/prompt.test.ts new file mode 100644 index 0000000..391b54c --- /dev/null +++ b/tests/unit/ai/prompt.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + injectPlaceholders, + stripCodeFences, + matchLabelFromResponse, + DEFAULT_PROMPT, +} from '../../../src/background/ai/prompt'; +import type { EmailContext } from '../../../src/types/email'; + +const makeContext = (overrides?: Partial): EmailContext => ({ + subject: 'Test Subject', + author: 'test@example.com', + attachments: [], + body: 'This is a test email body.', + ...overrides, +}); + +const testLabels = ['Work', 'Personal', 'Finance', 'Newsletters']; + +describe('DEFAULT_PROMPT', () => { + it('should contain all placeholder markers', () => { + expect(DEFAULT_PROMPT).toContain('{labels}'); + expect(DEFAULT_PROMPT).toContain('{subject}'); + expect(DEFAULT_PROMPT).toContain('{author}'); + expect(DEFAULT_PROMPT).toContain('{attachments}'); + expect(DEFAULT_PROMPT).toContain('{body}'); + }); +}); + +describe('injectPlaceholders', () => { + it('should replace all placeholders', () => { + const ctx = makeContext({ + subject: 'Hello', + author: 'alice@example.com', + body: 'Hi there', + }); + const result = injectPlaceholders(DEFAULT_PROMPT, ctx, testLabels); + + expect(result).toContain('Work, Personal, Finance, Newsletters'); + expect(result).toContain('Hello'); + expect(result).toContain('alice@example.com'); + expect(result).toContain('Hi there'); + expect(result).not.toContain('{labels}'); + expect(result).not.toContain('{subject}'); + }); + + it('should show "None" for empty attachments', () => { + const ctx = makeContext({ attachments: [] }); + const result = injectPlaceholders(DEFAULT_PROMPT, ctx, testLabels); + expect(result).toContain('None'); + }); + + it('should join attachment names', () => { + const ctx = makeContext({ + attachments: [ + { name: 'report.pdf', contentType: 'application/pdf', size: 1000 }, + { name: 'photo.jpg', contentType: 'image/jpeg', size: 500 }, + ], + }); + const result = injectPlaceholders(DEFAULT_PROMPT, ctx, testLabels); + expect(result).toContain('report.pdf, photo.jpg'); + }); + + it('should handle missing subject and author with defaults', () => { + const ctx = makeContext({ subject: '', author: '' }); + const result = injectPlaceholders(DEFAULT_PROMPT, ctx, testLabels); + expect(result).toContain('(No subject)'); + }); +}); + +describe('stripCodeFences', () => { + it('should strip markdown code fences', () => { + expect(stripCodeFences('```\nWork\n```')).toBe('Work'); + expect(stripCodeFences('```json\nFinance\n```')).toBe('Finance'); + }); + + it('should return original text if no fences', () => { + expect(stripCodeFences('Personal')).toBe('Personal'); + }); + + it('should handle empty input', () => { + expect(stripCodeFences('')).toBe(''); + }); + + it('should only strip outermost fences', () => { + expect(stripCodeFences('```\ninner ``` code\n```')).toBe( + 'inner ``` code' + ); + }); +}); + +describe('matchLabelFromResponse', () => { + const labels = ['Work', 'Personal', 'Finance', 'Shopping']; + + it('should match exact label', () => { + expect(matchLabelFromResponse('Work', labels)).toBe('Work'); + }); + + it('should match case-insensitive', () => { + expect(matchLabelFromResponse('WORK', labels)).toBe('Work'); + expect(matchLabelFromResponse('personal', labels)).toBe('Personal'); + }); + + it('should match via substring', () => { + expect( + matchLabelFromResponse( + 'I think this should be Finance related', + labels + ) + ).toBe('Finance'); + }); + + it('should strip code fences before matching', () => { + expect(matchLabelFromResponse('```\nShopping\n```', labels)).toBe( + 'Shopping' + ); + }); + + it('should return cleaned text if no label matches', () => { + expect(matchLabelFromResponse('Unknown Category', labels)).toBe( + 'Unknown Category' + ); + }); + + it('should return "null" sentinel for empty response', () => { + // `cleaned || 'null'` returns the string 'null' for falsy values + expect(matchLabelFromResponse('', labels)).toBe('null'); + }); +}); diff --git a/tests/unit/ai/providers.test.ts b/tests/unit/ai/providers.test.ts new file mode 100644 index 0000000..9b8222b --- /dev/null +++ b/tests/unit/ai/providers.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + injectPlaceholders, + stripCodeFences, + matchLabelFromResponse, +} from '../../../src/background/ai/prompt'; +import type { EmailContext } from '../../../src/types/email'; + +describe('AI provider utilities', () => { + const labels = ['Work', 'Personal', 'Finance', 'Shopping']; + + describe('matchLabelFromResponse edge cases', () => { + it('should handle labels with special characters', () => { + expect(matchLabelFromResponse('Finance/Invoices', ['Finance/Invoices', 'Work'])).toBe('Finance/Invoices'); + }); + + it('should match first label when multiple match via substring', () => { + const result = matchLabelFromResponse('work stuff', ['Work', 'Work-From-Home']); + expect(result).toBe('Work'); + }); + + it('should handle very long response text', () => { + const longText = 'A'.repeat(10000) + ' Finance ' + 'B'.repeat(10000); + expect(matchLabelFromResponse(longText, labels)).toBe('Finance'); + }); + + it('should return cleaned text when no match and input is not null', () => { + expect(matchLabelFromResponse(' Random Text ', labels)).toBe('Random Text'); + }); + }); + + describe('injectPlaceholders edge cases', () => { + const template = 'Labels: {labels}. Subject: {subject}. Body: {body}.'; + + it('should handle empty labels array', () => { + const ctx: EmailContext = { subject: 'Test', author: 'a@b.com', attachments: [], body: 'hello' }; + const result = injectPlaceholders(template, ctx, []); + expect(result).toContain('Labels: .'); + }); + + it('should handle special regex characters in context', () => { + const ctx: EmailContext = { subject: 'Price $50 (urgent)', author: 'a@b.com', attachments: [], body: 'Check this: http://example.com?a=1&b=2' }; + const result = injectPlaceholders(template, ctx, labels); + expect(result).toContain('Price $50 (urgent)'); + expect(result).toContain('http://example.com?a=1&b=2'); + }); + + it('should handle Unicode characters', () => { + const unicodeTemplate = 'Subject: {subject}. Author: {author}. Body: {body}.'; + const ctx: EmailContext = { subject: '中文测试', author: 'テスト@example.com', attachments: [], body: 'Élève übersetzung' }; + const result = injectPlaceholders(unicodeTemplate, ctx, labels); + expect(result).toContain('中文测试'); + expect(result).toContain('テスト@example.com'); + expect(result).toContain('Élève übersetzung'); + }); + }); + + describe('stripCodeFences edge cases', () => { + it('should handle markdown code block with language specifier', () => { + expect(stripCodeFences('```json\n{"key": "value"}\n```')).toBe('{"key": "value"}'); + }); + + it('should handle only opening fence', () => { + expect(stripCodeFences('```\nwork in progress')).toBe('work in progress'); + }); + + it('should handle only closing fence', () => { + expect(stripCodeFences('work in progress\n```')).toBe('work in progress'); + }); + + it('should handle backtick characters in content', () => { + expect(stripCodeFences('```\nUse `const` for variables\n```')).toBe('Use `const` for variables'); + }); + }); +}); diff --git a/tests/unit/ai/rate-limiter.test.ts b/tests/unit/ai/rate-limiter.test.ts new file mode 100644 index 0000000..92c0f70 --- /dev/null +++ b/tests/unit/ai/rate-limiter.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../helpers/mock-messenger'; +import { resetStorage, seedStorage } from '../../helpers/mock-messenger'; + +// We can't easily test the full rate limiter without complex timing, +// but we can test the module exports and basic behavior +describe('rate-limiter', () => { + beforeEach(() => { + resetStorage(); + }); + + it('should export checkAndTrackGeminiRateLimit function', async () => { + const module = await import( + '../../../src/background/ai/rate-limiter' + ); + expect(typeof module.checkAndTrackGeminiRateLimit).toBe('function'); + }); + + it('should export legacy checkGeminiRateLimit function', async () => { + const module = await import( + '../../../src/background/ai/rate-limiter' + ); + expect(typeof module.checkGeminiRateLimit).toBe('function'); + }); + + it('should export deprecated trackGeminiRequest function', async () => { + const module = await import( + '../../../src/background/ai/rate-limiter' + ); + expect(typeof module.trackGeminiRequest).toBe('function'); + }); + + it('should allow request when paid plan is enabled', async () => { + seedStorage({ geminiPaidPlan: true }); + const { checkAndTrackGeminiRateLimit } = await import( + '../../../src/background/ai/rate-limiter' + ); + const result = await checkAndTrackGeminiRateLimit(); + expect(result.allowed).toBe(true); + expect(result.waitTime).toBe(0); + }); + + it('should allow request in legacy mode with fresh storage', async () => { + const { checkAndTrackGeminiRateLimit } = await import( + '../../../src/background/ai/rate-limiter' + ); + const result = await checkAndTrackGeminiRateLimit(); + expect(result.allowed).toBe(true); + expect(result.waitTime).toBe(0); + expect(result.keyIndex).toBeNull(); + }); +}); diff --git a/tests/unit/auto-sort/processor.test.ts b/tests/unit/auto-sort/processor.test.ts new file mode 100644 index 0000000..7ef9321 --- /dev/null +++ b/tests/unit/auto-sort/processor.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; + +describe('auto-sort/processor', () => { + it('should export processWithConcurrency', async () => { + const mod = await import('../../../src/background/auto-sort/processor'); + expect(typeof mod.processWithConcurrency).toBe('function'); + expect(typeof mod.classifyAndMove).toBe('function'); + expect(typeof mod.handleNewMail).toBe('function'); + }); + + it('processWithConcurrency should process all items', async () => { + const { processWithConcurrency } = await import('../../../src/background/auto-sort/processor'); + const items = [1, 2, 3, 4, 5]; + const processed: number[] = []; + + await processWithConcurrency(items, async (n) => { + processed.push(n); + return n * 2; + }, 2); + + expect(processed).toHaveLength(5); + expect(processed.sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it('processWithConcurrency should respect concurrency limit', async () => { + const { processWithConcurrency } = await import('../../../src/background/auto-sort/processor'); + let maxConcurrent = 0; + let current = 0; + + const items = [1, 2, 3, 4, 5, 6]; + await processWithConcurrency(items, async () => { + current++; + maxConcurrent = Math.max(maxConcurrent, current); + await new Promise(r => setTimeout(r, 10)); + current--; + return null; + }, 2); + + expect(maxConcurrent).toBeLessThanOrEqual(2); + }); +}); diff --git a/tests/unit/batch/engine.test.ts b/tests/unit/batch/engine.test.ts new file mode 100644 index 0000000..0707b7a --- /dev/null +++ b/tests/unit/batch/engine.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_BATCH_CONFIG } from '../../../src/background/batch/types'; + +describe('batch/config', () => { + it('should have config for all 7 providers', () => { + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('gemini'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('openai'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('anthropic'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('groq'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('mistral'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('ollama'); + expect(DEFAULT_BATCH_CONFIG).toHaveProperty('openai-compatible'); + }); + + it('should have valid concurrency values', () => { + for (const [, config] of Object.entries(DEFAULT_BATCH_CONFIG)) { + expect(config.concurrency).toBeGreaterThan(0); + expect(config.concurrency).toBeLessThanOrEqual(10); + } + }); + + it('gemini should have concurrency 1', () => { + expect(DEFAULT_BATCH_CONFIG.gemini.concurrency).toBe(1); + }); + + it('groq should have highest concurrency', () => { + const max = Math.max(...Object.values(DEFAULT_BATCH_CONFIG).map(c => c.concurrency)); + expect(DEFAULT_BATCH_CONFIG.groq.concurrency).toBe(max); + }); +}); diff --git a/tests/unit/email/extractor.test.ts b/tests/unit/email/extractor.test.ts new file mode 100644 index 0000000..43b8527 --- /dev/null +++ b/tests/unit/email/extractor.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import '../../helpers/mock-messenger'; + +describe('email/extractor', () => { + it('should export extractEmailContext function', async () => { + const mod = await import('../../../src/background/email/extractor'); + expect(typeof mod.extractEmailContext).toBe('function'); + expect(typeof mod.extractTextFromParts).toBe('function'); + }); + + it('should handle null inputs', async () => { + const { extractEmailContext } = await import('../../../src/background/email/extractor'); + const result = await extractEmailContext({}, {}); + expect(result).toEqual({ + subject: '', + author: '', + attachments: [], + body: '', + }); + }); + + it('should extract basic headers', async () => { + const { extractEmailContext } = await import('../../../src/background/email/extractor'); + const fullMessage = { + headers: { + Subject: ['Hello World'], + From: ['alice@example.com'], + }, + }; + const result = await extractEmailContext(fullMessage, {}); + expect(result.subject).toBe('Hello World'); + expect(result.author).toBe('alice@example.com'); + }); +}); diff --git a/tests/unit/folders/history.test.ts b/tests/unit/folders/history.test.ts new file mode 100644 index 0000000..cdf0f6f --- /dev/null +++ b/tests/unit/folders/history.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../helpers/mock-messenger'; +import { resetStorage, seedStorage } from '../../helpers/mock-messenger'; + +describe('folders/history', () => { + beforeEach(() => { + resetStorage(); + }); + + it('should store and retrieve move history', async () => { + const { storeMoveHistory, getMoveHistory } = await import('../../../src/background/folders/history'); + + await storeMoveHistory({ subject: 'Test Email', status: 'Success', destination: 'Work' }); + + const history = await getMoveHistory(); + expect(history).toHaveLength(1); + expect(history[0].subject).toBe('Test Email'); + expect(history[0].status).toBe('Success'); + expect(history[0].timestamp).toBeDefined(); + }); + + it('should limit history to 100 entries', async () => { + const { storeMoveHistory, getMoveHistory } = await import('../../../src/background/folders/history'); + + for (let i = 0; i < 150; i++) { + await storeMoveHistory({ subject: `Email ${i}`, status: 'Success', destination: 'Work' }); + } + + const history = await getMoveHistory(); + expect(history).toHaveLength(100); + expect(history[0].subject).toBe('Email 149'); + }); + + it('should clear history', async () => { + const { storeMoveHistory, getMoveHistory, clearMoveHistory } = await import('../../../src/background/folders/history'); + + await storeMoveHistory({ subject: 'Test', status: 'Success', destination: 'Work' }); + await clearMoveHistory(); + + const history = await getMoveHistory(); + expect(history).toHaveLength(0); + }); +}); diff --git a/tests/unit/folders/operations.test.ts b/tests/unit/folders/operations.test.ts new file mode 100644 index 0000000..835d555 --- /dev/null +++ b/tests/unit/folders/operations.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import '../../helpers/mock-messenger'; +import { resetStorage } from '../../helpers/mock-messenger'; + +describe('folders/operations', () => { + it('should export applyLabelsToMessages', async () => { + const mod = await import('../../../src/background/folders/operations'); + expect(typeof mod.applyLabelsToMessages).toBe('function'); + }); +}); diff --git a/tests/unit/shared/logger.test.ts b/tests/unit/shared/logger.test.ts new file mode 100644 index 0000000..d7dfb9a --- /dev/null +++ b/tests/unit/shared/logger.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import '../../helpers/mock-messenger'; + +// Ensure window is available before logger module loads (node environment) +(globalThis as Record).window = globalThis; + +const { DebugLogger } = await import('../../../src/shared/logger'); + +describe('DebugLogger', () => { + it('should expose window.debugLogger singleton', () => { + expect((window as Record).debugLogger).toBeDefined(); + expect((window as Record).debugLogger).toBeInstanceOf(DebugLogger); + }); + + it('should have all expected methods', () => { + const logger = new DebugLogger(); + expect(typeof logger.info).toBe('function'); + expect(typeof logger.warn).toBe('function'); + expect(typeof logger.error).toBe('function'); + expect(typeof logger.log).toBe('function'); + expect(typeof logger.apiRequest).toBe('function'); + expect(typeof logger.apiResponse).toBe('function'); + }); + + it('should queue logs when not ready', () => { + const logger = new DebugLogger(); + expect(() => logger.info('Tag', 'queued message')).not.toThrow(); + expect(() => logger.error('Tag', 'error message')).not.toThrow(); + }); + + it('should init without throwing', async () => { + const logger = new DebugLogger(); + await expect(logger.init()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/shared/storage.test.ts b/tests/unit/shared/storage.test.ts new file mode 100644 index 0000000..78fa114 --- /dev/null +++ b/tests/unit/shared/storage.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../helpers/mock-messenger'; +import { resetStorage, seedStorage } from '../../helpers/mock-messenger'; + +describe('storage (mock-messenger)', () => { + beforeEach(() => { + resetStorage(); + }); + + it('should set and get a single key', async () => { + await browser.storage.local.set({ testKey: 'testValue' }); + const result = (await browser.storage.local.get('testKey')) as Record; + expect(result.testKey).toBe('testValue'); + }); + + it('should get multiple keys', async () => { + seedStorage({ a: 1, b: 2, c: 3 }); + const result = (await browser.storage.local.get(['a', 'c'])) as Record; + expect(result.a).toBe(1); + expect(result.c).toBe(3); + expect(result.b).toBeUndefined(); + }); + + it('should get all keys with null', async () => { + seedStorage({ x: 1, y: 2 }); + const result = (await browser.storage.local.get(null)) as Record; + expect(result.x).toBe(1); + expect(result.y).toBe(2); + }); + + it('should remove a key', async () => { + seedStorage({ x: 1, y: 2 }); + await browser.storage.local.remove('x'); + const result = (await browser.storage.local.get(null)) as Record; + expect(result.x).toBeUndefined(); + expect(result.y).toBe(2); + }); + + it('should clear all keys', async () => { + seedStorage({ k1: 'v1', k2: 'v2' }); + await browser.storage.local.clear(); + const result = (await browser.storage.local.get(null)) as Record; + expect(Object.keys(result)).toHaveLength(0); + }); +}); diff --git a/tests/unit/shared/tab-fetch.test.ts b/tests/unit/shared/tab-fetch.test.ts new file mode 100644 index 0000000..867fdf8 --- /dev/null +++ b/tests/unit/shared/tab-fetch.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import '../../helpers/mock-messenger'; + +// In Node test environment, window is not defined. +// The module uses `typeof window !== 'undefined'` guard, so we must +// define window before importing for the singleton to be attached. +(globalThis as Record).window = globalThis; + +describe('tab-fetch', () => { + it('should export window.tabFetchUtils after import', async () => { + await import('../../../src/shared/tab-fetch'); + const utils = (window as unknown as Record).tabFetchUtils as Record; + expect(utils).toBeDefined(); + expect(typeof utils.fetchViaTab).toBe('function'); + expect(typeof utils.ollamaChatViaTabUtil).toBe('function'); + expect(typeof utils.openaiCompatChatViaTabUtil).toBe('function'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b08b233 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "strict": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "ignoreDeprecations": "6.0", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e369e28 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/types/**"], + }, + }, +});