├── README.fa.md ├── README.md ├── background.js ├── content.js ├── fonts ├── Vazirmatn[wght].ttf └── test.txt ├── icons ├── README.txt ├── icon128.png ├── icon128.png.txt ├── icon16.png ├── icon16.png.txt ├── icon48.png └── icon48.png.txt ├── manifest.json ├── popup.html ├── popup.js └── translate.js /README.fa.md: -------------------------------------------------------------------------------- 1 | # Dynamic RTL 2 | 3 | افزونه کروم برای تشخیص خودکار متن فارسی و عربی در صفحات وب و اعمال جهت راست به چپ (RTL) و فونت مناسب. 4 | 5 | ## ویژگی‌ها 6 | 7 | - تشخیص خودکار متن فارسی و عربی در هر صفحه وب 8 | - اعمال جهت راست به چپ و فونت وزیرمتن برای متن‌های تشخیص داده شده 9 | - فقط به متن‌هایی که با کلمات فارسی یا عربی شروع می‌شوند، RTL اعمال می‌کند 10 | - کارکرد با محتوای پویا که پس از بارگذاری اولیه صفحه لود می‌شود 11 | - تشخیص بهبود یافته برای فیلدهای ورودی و ناحیه‌های متنی 12 | - امکان غیرفعال کردن افزونه برای وب‌سایت‌های خاص 13 | - امکان انتخاب حالت پیش‌فرض فعال یا غیرفعال برای همه سایت‌ها 14 | - سازگار با Manifest V3 کروم 15 | 16 | ## نصب 17 | 18 | ### از فروشگاه وب کروم (به زودی) 19 | 20 | 1. به فروشگاه وب کروم بروید 21 | 2. عبارت "Dynamic RTL" را جستجو کنید 22 | 3. روی "افزودن به کروم" کلیک کنید 23 | 24 | ### نصب دستی 25 | 26 | 1. این مخزن را دانلود یا کلون کنید. 27 | - می‌توانید سورس کد را به صورت فایل ZIP از [اینجا](https://github.com/so-roush/Dynamic-RTL/archive/refs/heads/main.zip) دانلود کنید. 28 | 2. کروم را باز کنید و به آدرس `chrome://extensions/` بروید 29 | 3. "حالت توسعه‌دهنده" را در گوشه بالا سمت راست فعال کنید 30 | 4. روی "بارگذاری بسته نصب نشده" کلیک کنید و پوشه حاوی فایل‌های افزونه را انتخاب کنید 31 | 5. افزونه اکنون باید نصب و فعال شده باشد 32 | 33 | ## استفاده 34 | 35 | ### تنظیمات سایت فعلی 36 | 37 | - برای فعال یا غیرفعال کردن افزونه در سایت فعلی، روی آیکون افزونه در نوار ابزار کلیک کنید و کلید "فعال در این سایت" را تغییر دهید 38 | - تغییرات بلافاصله اعمال می‌شوند 39 | 40 | ### تنظیمات کلی 41 | 42 | افزونه دو حالت پیش‌فرض دارد که می‌توانید بین آن‌ها سوییچ کنید: 43 | 44 | 1. **پیش‌فرض فعال در همه سایت‌ها (امکان غیرفعال کردن در سایت‌های دلخواه)**: 45 | - در این حالت، افزونه در همه سایت‌ها فعال است مگر اینکه شما آن را در سایت‌های خاصی غیرفعال کنید 46 | - برای غیرفعال کردن در یک سایت خاص، کلید "فعال در این سایت" را خاموش کنید 47 | 48 | 2. **پیش‌فرض غیرفعال در همه سایت‌ها (امکان فعال کردن در سایت‌های دلخواه)**: 49 | - در این حالت، افزونه در همه سایت‌ها غیرفعال است مگر اینکه شما آن را در سایت‌های خاصی فعال کنید 50 | - برای فعال کردن در یک سایت خاص، کلید "فعال در این سایت" را روشن کنید 51 | 52 | برای تغییر بین این دو حالت: 53 | 1. روی آیکون افزونه در نوار ابزار کلیک کنید 54 | 2. یکی از دو گزینه رادیویی در بخش "تنظیمات کلی" را انتخاب کنید 55 | 56 | ## جزئیات فنی 57 | 58 | - استفاده از MutationObserver برای تشخیص و پردازش محتوای اضافه شده به صورت پویا 59 | - تشخیص بهبود یافته متن برای فیلدهای ورودی و ناحیه‌های متنی 60 | - تشخیص هوشمند که فقط به متن‌هایی که با کلمات فارسی یا عربی شروع می‌شوند، RTL اعمال می‌کند 61 | - پیاده‌سازی Manifest V3 برای سازگاری با افزونه‌های کروم 62 | - استفاده از فونت وزیرمتن برای نمایش بهینه متن فارسی/عربی 63 | 64 | ## بهبودهای اخیر 65 | 66 | ### نسخه 1.1.0 67 | - تغییر انتخاب حالت پیش‌فرض از کلید تغییر وضعیت به گزینه‌های رادیویی 68 | - اضافه کردن لینک پروفایل X (توییتر) 69 | - انتخاب پیش‌فرض گزینه "پیش‌فرض فعال در همه سایت‌ها" 70 | - استفاده از فونت وزیرمتن در رابط کاربری افزونه 71 | 72 | ### نسخه 1.0.0 73 | - اضافه کردن امکان انتخاب حالت پیش‌فرض فعال یا غیرفعال برای همه سایت‌ها 74 | - اضافه کردن تشخیص هوشمند که فقط به متن‌هایی که با کلمات فارسی یا عربی شروع می‌شوند، RTL اعمال می‌کند 75 | - بهبود تشخیص متن فارسی در فیلدهای ورودی و ناحیه‌های متنی 76 | - پشتیبانی از المان‌های contenteditable 77 | - بهبود مدیریت المان‌های ورودی که به صورت پویا اضافه می‌شوند 78 | 79 | ## مجوز 80 | 81 | این پروژه متن‌باز است و تحت مجوز MIT در دسترس است. 82 | 83 | ## اعتبارات 84 | 85 | - اسکریپت اصلی توسط Sorou-sh 86 | - فونت وزیرمتن توسط صابر راستی‌کردار -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic RTL (نسخه 1.3.0) 2 | 3 | افزونه کروم برای تشخیص خودکار متن فارسی و عربی در صفحات وب و اعمال جهت راست به چپ (RTL) و فونت مناسب (پیش‌فرض: وزیرمتن، قابل تنظیم). 4 | 5 | [English README / توضیحات انگلیسی](README.en.md) 6 | 7 |
8 | 9 |

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | دریافت آخرین نسخه 20 | 21 | 22 | 23 |

24 | 25 |
26 | 27 | ## ویژگی‌ها 28 | 29 | - تشخیص خودکار متن فارسی و عربی در هر صفحه وب. 30 | - اعمال جهت راست به چپ (RTL) و فونت مناسب به متن‌های تشخیص داده شده. 31 | - محدود کردن اعمال استایل به متن‌هایی که با کلمات فارسی یا عربی شروع می‌شوند. 32 | - پشتیبانی از محتوای پویا (متنی که بعداً در صفحه بارگذاری می‌شود). 33 | - تشخیص بهبود یافته برای فیلدهای ورودی (`input`, `textarea`) و المان‌های قابل ویرایش (`contenteditable`). 34 | - امکان غیرفعال کردن افزونه برای وب‌سایت‌های خاص. 35 | - امکان انتخاب حالت پیش‌فرض (فعال یا غیرفعال) برای همه سایت‌ها. 36 | - **جدید:** امکان آپلود و استفاده از فونت TTF سفارشی (ترجیحاً متغیر/Variable) به جای فونت پیش‌فرض وزیرمتن. 37 | - سازگار با Manifest V3 کروم. 38 | 39 | ## نصب 40 | 41 | ### روش پیشنهادی: نصب دستی 42 | 43 | **به فارسی:** 44 | 45 | 1. **دانلود:** آخرین نسخه افزونه را از بخش [Releases](https://github.com/so-roush/Dynamic-RTL/releases) دانلود کنید (فایل `.zip`). یا اگر می‌خواهید از کد منبع استفاده کنید، سورس کد را به صورت ZIP از [اینجا](https://github.com/so-roush/Dynamic-RTL/archive/refs/heads/main.zip) دانلود نمایید. 46 | 2. **اکسترکت:** فایل ZIP دانلود شده را در یک پوشه دلخواه از حالت فشرده خارج کنید (Extract کنید). 47 | 3. **باز کردن صفحه افزونه‌ها:** مرورگر کروم (یا مرورگرهای مشابه مانند Edge, Brave, Vivaldi) را باز کرده و به آدرس `chrome://extensions` بروید. 48 | 4. **فعال کردن حالت توسعه‌دهنده:** در گوشه بالا سمت راست صفحه افزونه‌ها، کلید "Developer mode" یا "حالت توسعه‌دهنده" را فعال کنید. 49 | 5. **بارگذاری افزونه:** روی دکمه "Load unpacked" یا "بارگذاری بسته بازشده" کلیک کنید. 50 | 6. **انتخاب پوشه:** پوشه‌ای که در مرحله ۲ اکسترکت کرده بودید را انتخاب کنید. 51 | 7. **تایید:** افزونه نصب شده و آیکون آن باید در نوار ابزار مرورگر شما ظاهر شود. 52 | 53 | **In English:** 54 | 55 | 1. **Download:** Download the latest release (the `.zip` file) from the [Releases](https://github.com/so-roush/Dynamic-RTL/releases) page. Alternatively, if you want the source code, download it as a ZIP file from [here](https://github.com/so-roush/Dynamic-RTL/archive/refs/heads/main.zip). 56 | 2. **Extract:** Extract the downloaded ZIP file into a folder of your choice. 57 | 3. **Open Extensions Page:** Open your Chrome browser (or a similar Chromium-based browser like Edge, Brave, Vivaldi) and navigate to `chrome://extensions`. 58 | 4. **Enable Developer Mode:** In the top right corner of the extensions page, toggle on "Developer mode". 59 | 5. **Load Extension:** Click the "Load unpacked" button. 60 | 6. **Select Folder:** Select the folder where you extracted the extension files in step 2. 61 | 7. **Confirm:** The extension is now installed, and its icon should appear in your browser toolbar. 62 | 63 | ### از فروشگاه وب کروم (به زودی) 64 | 65 | وقتی افزونه در فروشگاه وب کروم منتشر شود، می‌توانید آن را مستقیماً از آنجا نصب کنید. 66 | 67 | ## استفاده 68 | 69 | ### تنظیمات سایت فعلی 70 | 71 | - برای فعال یا غیرفعال کردن افزونه در سایت فعلی، روی آیکون افزونه در نوار ابزار کلیک کنید و کلید "فعال در این سایت" را تغییر دهید. 72 | - تغییرات بلافاصله اعمال می‌شوند. 73 | 74 | ### تنظیمات کلی (داخل پاپ‌آپ افزونه) 75 | 76 | با کلیک روی عنوان "تنظیمات کلی" می‌توانید این بخش را باز یا بسته کنید. 77 | 78 | * **حالت پیش‌فرض:** 79 | * **فعال در همه سایت‌ها:** افزونه در همه جا فعال است مگر اینکه آن را برای سایتی خاص غیرفعال کنید. 80 | * **غیرفعال در همه سایت‌ها:** افزونه در همه جا غیرفعال است مگر اینکه آن را برای سایتی خاص فعال کنید. 81 | * **فونت سفارشی:** 82 | * کلید "استفاده از فونت سفارشی" را فعال کنید. 83 | * روی دکمه انتخاب فایل کلیک کرده و فونت `TTF` مورد نظر خود را انتخاب کنید. 84 | * **توصیه:** برای بهترین نتیجه، از فونت‌های متغیر (Variable Fonts) استفاده کنید. 85 | * با غیرفعال کردن این کلید، افزونه به فونت پیش‌فرض (وزیرمتن) بازمی‌گردد. 86 | 87 | ## مجوز 88 | 89 | این پروژه متن‌باز است و تحت مجوز MIT در دسترس است. 90 | 91 | ## اعتبارات 92 | 93 | - توسعه‌دهنده: [so-roush](https://github.com/so-roush) 94 | - فونت پیش‌فرض: [وزیرمتن](https://github.com/rastikerdar/vazirmatn) توسط صابر راستی‌کردار 95 | 96 | ## Language 97 | 98 | - [فارسی (Persian)](README.fa.md) 99 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Initialize extension settings when installed 4 | chrome.runtime.onInstalled.addListener(() => { 5 | // Set default settings 6 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], function(result) { 7 | // Initialize disabledSites if not exists 8 | if (!result.disabledSites) { 9 | chrome.storage.sync.set({ disabledSites: [] }); 10 | } 11 | 12 | // Initialize enabledSites if not exists 13 | if (!result.enabledSites) { 14 | chrome.storage.sync.set({ enabledSites: [] }); 15 | } 16 | 17 | // Initialize defaultEnabled if not exists (true = enabled by default) 18 | if (result.defaultEnabled === undefined) { 19 | chrome.storage.sync.set({ defaultEnabled: true }); 20 | } 21 | }); 22 | }); 23 | 24 | // Listen for messages from popup or content scripts 25 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 26 | if (message.action === 'getCurrentTabInfo') { 27 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 28 | if (tabs && tabs[0]) { 29 | const url = new URL(tabs[0].url); 30 | const hostname = url.hostname; 31 | 32 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], function(result) { 33 | const defaultEnabled = result.defaultEnabled !== undefined ? result.defaultEnabled : true; 34 | const disabledSites = result.disabledSites || []; 35 | const enabledSites = result.enabledSites || []; 36 | 37 | let isEnabled; 38 | 39 | if (defaultEnabled) { 40 | // Default enabled mode: site is enabled unless in disabledSites 41 | isEnabled = !disabledSites.includes(hostname); 42 | } else { 43 | // Default disabled mode: site is disabled unless in enabledSites 44 | isEnabled = enabledSites.includes(hostname); 45 | } 46 | 47 | sendResponse({ 48 | hostname: hostname, 49 | isEnabled: isEnabled, 50 | defaultEnabled: defaultEnabled 51 | }); 52 | }); 53 | } 54 | }); 55 | return true; // Keep the message channel open for the async response 56 | } 57 | 58 | if (message.action === 'toggleSite') { 59 | const hostname = message.hostname; 60 | const enabled = message.enabled; 61 | 62 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], function(result) { 63 | const defaultEnabled = result.defaultEnabled !== undefined ? result.defaultEnabled : true; 64 | let disabledSites = result.disabledSites || []; 65 | let enabledSites = result.enabledSites || []; 66 | 67 | if (defaultEnabled) { 68 | // Default enabled mode 69 | if (enabled) { 70 | // Remove from disabled sites 71 | disabledSites = disabledSites.filter(site => site !== hostname); 72 | } else { 73 | // Add to disabled sites if not already there 74 | if (!disabledSites.includes(hostname)) { 75 | disabledSites.push(hostname); 76 | } 77 | } 78 | 79 | chrome.storage.sync.set({ disabledSites: disabledSites }, function() { 80 | updateContentScript(enabled, tabs => { 81 | sendResponse({ success: true }); 82 | }); 83 | }); 84 | } else { 85 | // Default disabled mode 86 | if (enabled) { 87 | // Add to enabled sites if not already there 88 | if (!enabledSites.includes(hostname)) { 89 | enabledSites.push(hostname); 90 | } 91 | } else { 92 | // Remove from enabled sites 93 | enabledSites = enabledSites.filter(site => site !== hostname); 94 | } 95 | 96 | chrome.storage.sync.set({ enabledSites: enabledSites }, function() { 97 | updateContentScript(enabled, tabs => { 98 | sendResponse({ success: true }); 99 | }); 100 | }); 101 | } 102 | }); 103 | return true; // Keep the message channel open for the async response 104 | } 105 | 106 | if (message.action === 'toggleDefaultMode') { 107 | const defaultEnabled = message.defaultEnabled; 108 | 109 | chrome.storage.sync.set({ defaultEnabled: defaultEnabled }, function() { 110 | sendResponse({ success: true }); 111 | }); 112 | return true; // Keep the message channel open for the async response 113 | } 114 | }); 115 | 116 | // Helper function to update content script 117 | function updateContentScript(enabled, callback) { 118 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 119 | if (tabs && tabs[0]) { 120 | chrome.tabs.sendMessage(tabs[0].id, { 121 | action: 'toggleSite', 122 | enabled: enabled 123 | }, () => { 124 | if (callback) callback(tabs); 125 | }); 126 | } 127 | }); 128 | } 129 | 130 | // --- Context Menu Setup --- 131 | 132 | // Create context menu item on installation 133 | chrome.runtime.onInstalled.addListener(() => { 134 | chrome.contextMenus.create({ 135 | id: "translatePage", 136 | title: "ترجمه فارسی این صفحه (Dynamic RTL)", 137 | contexts: ["page"] // Show context menu on page itself 138 | }); 139 | 140 | // Set default settings on first install if not already set 141 | chrome.storage.sync.get(['defaultEnabled', 'translationTone'], (result) => { 142 | if (result.defaultEnabled === undefined) { 143 | chrome.storage.sync.set({ defaultEnabled: true }); 144 | } 145 | if (result.translationTone === undefined) { 146 | chrome.storage.sync.set({ translationTone: 'رسمی' }); 147 | } 148 | }); 149 | chrome.storage.local.get(['useCustomFont'], (result) => { 150 | if (result.useCustomFont === undefined) { 151 | chrome.storage.local.set({ useCustomFont: false }); 152 | } 153 | }); 154 | 155 | }); 156 | 157 | // Handle context menu click 158 | chrome.contextMenus.onClicked.addListener((info, tab) => { 159 | if (info.menuItemId === "translatePage" && tab?.id && tab.url?.startsWith('http')) { 160 | chrome.storage.local.get(['geminiApiKey'], (localResult) => { 161 | if (!localResult.geminiApiKey) { 162 | // Notify user to set API key - maybe open popup? 163 | console.warn("Gemini API Key not set."); 164 | // You could potentially open the popup here or send a different message 165 | // For simplicity, we'll rely on the user setting it via popup first. 166 | chrome.tabs.sendMessage(tab.id, { command: 'showApiKeyNeededError' }).catch(e => console.log("Could not send API key error to tab", tab.id, e)); 167 | return; 168 | } 169 | // Also get tone AND model for context menu 170 | chrome.storage.sync.get(['translationTone', 'translationModel'], (syncResult) => { 171 | const apiKey = localResult.geminiApiKey; 172 | const tone = syncResult.translationTone || 'رسمی'; 173 | const model = syncResult.translationModel || 'gemini-2.5-flash-preview-04-17'; // Get model 174 | 175 | chrome.tabs.sendMessage(tab.id, { 176 | command: 'translatePage', 177 | apiKey: apiKey, 178 | tone: tone, 179 | model: model // Send model from context menu too 180 | }).catch(error => { 181 | console.error(`Error sending translatePage command from context menu to tab ${tab.id}:`, error); 182 | }); 183 | }); 184 | }); 185 | } else if (tab && !tab.url?.startsWith('http')) { 186 | console.log("Context menu clicked on non-http page, translation not applicable."); 187 | } 188 | }); 189 | 190 | 191 | // --- Message Handling & API Call --- 192 | 193 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 194 | if (request.command === 'callGeminiTranslate') { 195 | console.log("Background script received texts to translate:", request.texts?.length || 0, "Model:", request.model); 196 | 197 | // Validate essential data, including model 198 | if (!request.apiKey || !request.texts || request.texts.length === 0 || !request.model) { 199 | console.error("Missing API Key, texts, or model for translation."); 200 | sendResponse({ success: false, error: "Missing API Key, texts, or model." }); 201 | return false; 202 | } 203 | 204 | translateTextWithGemini(request.apiKey, request.model, request.tone, request.texts) // Pass model 205 | .then(translations => { 206 | console.log("Background script sending translations back:", translations.length); 207 | sendResponse({ success: true, translations: translations }); 208 | }) 209 | .catch(error => { 210 | console.error("Error during Gemini API call:", error); 211 | // Check if it's a rate limit error to send specific code 212 | if (error.message.includes("429") || error.message.toLowerCase().includes("rate limit")) { 213 | sendResponse({ success: false, error: error.message, errorCode: 'RATE_LIMIT' }); 214 | } else if (error.message.toLowerCase().includes("api key not valid")) { 215 | sendResponse({ success: false, error: "کلید API نامعتبر است.", errorCode: 'INVALID_KEY' }); 216 | } 217 | else { 218 | sendResponse({ success: false, error: error.message || "Unknown error calling Gemini API." }); 219 | } 220 | }); 221 | 222 | return true; // Indicate asynchronous response 223 | } 224 | // --- <<< NEW: API Key Test Handler >>> --- 225 | else if (request.command === 'testApiKey') { 226 | const apiKey = request.apiKey; 227 | if (!apiKey) { 228 | sendResponse({ success: false, error: "No API Key provided." }); 229 | return false; // No key, synchronous response 230 | } 231 | 232 | // Use a simple, free API call to test the key, like listing models 233 | const testUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`; 234 | 235 | fetch(testUrl) 236 | .then(response => { 237 | if (response.ok) { // Status 200-299 238 | // Key is likely valid if we can list models 239 | sendResponse({ success: true }); 240 | } else { 241 | // Key is invalid or other issue 242 | console.warn(`API Key test failed with status: ${response.status}`); 243 | // Try to parse error message from Gemini if possible 244 | response.json().then(errorData => { 245 | const message = errorData?.error?.message || `خطای ${response.status}`; 246 | sendResponse({ success: false, error: message }); 247 | }).catch(() => { 248 | // If parsing JSON fails, send status text 249 | sendResponse({ success: false, error: response.statusText || `خطای ${response.status}` }); 250 | }); 251 | } 252 | }) 253 | .catch(error => { 254 | // Network error or other fetch issue 255 | console.error("Error during API Key test fetch:", error); 256 | sendResponse({ success: false, error: "خطا در ارتباط با سرور گوگل." }); 257 | }); 258 | 259 | return true; // Indicate asynchronous response 260 | } 261 | // --- <<< END NEW SECTION >>> --- 262 | // Handle other potential messages, e.g., from content script to check status 263 | else if (request.action === 'getCurrentTabInfo') { // Handling message used by older popup.js version? Keep for safety or remove if popup.js is fully updated 264 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 265 | if (tabs[0] && tabs[0].id && tabs[0].url) { 266 | try { 267 | const url = new URL(tabs[0].url); 268 | const hostname = url.hostname; 269 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], (result) => { 270 | const defaultEnabled = result.defaultEnabled !== undefined ? result.defaultEnabled : true; 271 | const disabledSites = result.disabledSites || []; 272 | const enabledSites = result.enabledSites || []; 273 | let isEnabled = defaultEnabled ? !disabledSites.includes(hostname) : enabledSites.includes(hostname); 274 | sendResponse({ hostname: hostname, isEnabled: isEnabled, defaultEnabled: defaultEnabled }); 275 | }); 276 | } catch (e) { 277 | sendResponse(null); // Invalid URL 278 | } 279 | } else { 280 | sendResponse(null); // No valid tab 281 | } 282 | }); 283 | return true; // Async response 284 | } 285 | // Default case for unhandled messages 286 | return false; 287 | }); 288 | 289 | // --- Gemini API Call Function --- 290 | async function translateTextWithGemini(apiKey, modelId, tone, texts) { // Added modelId parameter 291 | // Construct API URL using the selected model ID 292 | const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${apiKey}`; 293 | 294 | // --- Prompt Construction --- 295 | // Create a numbered list of texts to translate. 296 | // Including original text in the prompt helps the model maintain context, 297 | // but for very long pages, consider sending only texts. 298 | // Using a clear separator helps in parsing the response. 299 | const separator = "|||---|||"; // Unique separator 300 | // Simplified instruction focusing on core task 301 | let promptContent = `Translate the following English texts to Persian using a "${tone}" tone. Provide ONLY the Persian translation for each text, separated by "${separator}". 302 | 303 | Input Texts: 304 | `; 305 | 306 | texts.forEach((textObj, index) => { 307 | // Adding index helps verify response order, though model should follow sequence 308 | // Clean up excessive newlines in input text for better processing 309 | const cleanedText = textObj.text.replace(/\n\s*\n/g, '\n').trim(); 310 | promptContent += `${index + 1}. ${cleanedText}\n`; // Use \n as newline marker 311 | }); 312 | 313 | // Remove trailing newline from the list 314 | promptContent = promptContent.trim(); 315 | promptContent += ` 316 | 317 | Persian Translations (separated by "${separator}"): 318 | `; 319 | 320 | 321 | console.log(`Calling Gemini API: ${API_URL}`); 322 | console.log("Generated Prompt (first 500 chars):", promptContent.substring(0, 500)); 323 | // console.log("Full Prompt:", promptContent); // DEBUG: Log full prompt if needed 324 | 325 | // --- API Request --- 326 | try { 327 | const response = await fetch(API_URL, { 328 | method: 'POST', 329 | headers: { 330 | 'Content-Type': 'application/json', 331 | }, 332 | body: JSON.stringify({ 333 | contents: [{ 334 | parts: [{ text: promptContent }] 335 | }], 336 | // Optional: Add safety settings or generation config if needed 337 | generationConfig: { 338 | // temperature: 0.7, // Adjust creativity vs. predictability 339 | // topP: 0.95, 340 | // topK: 40, 341 | maxOutputTokens: 8192, // Set a reasonable limit for output tokens based on flash model 342 | }, 343 | safetySettings: [ 344 | // Example: Block harmful content with higher thresholds 345 | { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, 346 | { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, 347 | { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, 348 | { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, 349 | ] 350 | }) 351 | }); 352 | 353 | if (!response.ok) { 354 | const errorBody = await response.json().catch(() => ({ message: "Could not parse error response body." })); 355 | console.error("Gemini API Error Response:", response.status, errorBody); 356 | let errorMessage = `Gemini API request failed with status ${response.status}`; 357 | if (errorBody?.error?.message) { 358 | errorMessage += `: ${errorBody.error.message}`; 359 | } else if (typeof errorBody === 'string') { 360 | errorMessage += `: ${errorBody}`; 361 | } else { 362 | errorMessage += `: ${response.statusText}`; 363 | } 364 | // Check for common API key error 365 | if (response.status === 400 && errorBody?.error?.message?.toLowerCase().includes("api key not valid")) { 366 | errorMessage = "کلید API Gemini نامعتبر است. لطفاً آن را در تنظیمات بررسی کنید."; 367 | } else if (response.status === 429) { 368 | errorMessage = "محدودیت تعداد درخواست API (Rate Limit). لطفاً کمی صبر کنید و دوباره تلاش کنید."; // Keep specific message 369 | // Throw error that includes 429 info if possible 370 | throw new Error(`429: ${errorMessage}`); 371 | } 372 | 373 | throw new Error(errorMessage); 374 | } 375 | 376 | const data = await response.json(); 377 | // console.log("Full Gemini Response:", JSON.stringify(data, null, 2)); // DEBUG: Log full response if needed 378 | 379 | 380 | // --- Response Processing --- 381 | if (!data.candidates || data.candidates.length === 0 || !data.candidates[0].content?.parts || data.candidates[0].content.parts.length === 0) { 382 | // Handle cases where the model might have blocked the request or returned empty 383 | let blockReason = "Unknown reason"; 384 | if (data.candidates && data.candidates[0]?.finishReason === 'SAFETY') { 385 | blockReason = "Content blocked by safety settings"; 386 | } else if (data.promptFeedback?.blockReason) { 387 | blockReason = `Prompt blocked due to: ${data.promptFeedback.blockReason}`; 388 | console.error("Gemini prompt blocked:", data.promptFeedback.blockReason, data.promptFeedback.safetyRatings); 389 | } else { 390 | console.error("Invalid or empty response structure from Gemini:", data); 391 | } 392 | throw new Error(`ترجمه توسط Gemini انجام نشد: ${blockReason}`); 393 | } 394 | 395 | const combinedTranslations = data.candidates[0].content.parts[0].text; 396 | // console.log("Received raw combined translations:", combinedTranslations.substring(0, 500)); 397 | 398 | const translationParts = combinedTranslations.split(separator).map(t => t.trim().replace(/^\d+\.\s*/, '')); // Remove leading numbers/dots if model added them 399 | 400 | // --- Mapping results --- 401 | // Ensure the number of translations matches the number of inputs - crucial! 402 | if (translationParts.length !== texts.length) { 403 | console.warn(`Mismatch in translation count: Expected ${texts.length}, Got ${translationParts.length}. Mapping might be incorrect.`); 404 | // Attempt a best-effort mapping, padding with empty strings for missing ones 405 | const adjustedTranslations = new Array(texts.length).fill(""); 406 | for(let i = 0; i < Math.min(translationParts.length, texts.length); i++) { 407 | adjustedTranslations[i] = translationParts[i]; 408 | } 409 | const results = texts.map((textObj, index) => ({ 410 | id: textObj.id, 411 | translation: adjustedTranslations[index] 412 | })); 413 | console.warn("Returning potentially incomplete/misaligned results due to count mismatch."); 414 | return results; 415 | 416 | } else { 417 | // Map translations back using the original IDs if counts match 418 | const results = texts.map((textObj, index) => ({ 419 | id: textObj.id, 420 | translation: translationParts[index] || "" // Use empty string if translation is missing 421 | })); 422 | return results; 423 | } 424 | 425 | 426 | } catch (error) { 427 | console.error(`Fetch or processing error in translateTextWithGemini for model ${modelId}:`, error); 428 | throw error; 429 | } 430 | } 431 | 432 | console.log("background.js loaded and listener attached."); -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Debounce function 4 | function debounce(func, wait) { 5 | let timeout; 6 | return function executedFunction(...args) { 7 | const later = () => { 8 | clearTimeout(timeout); 9 | func(...args); 10 | }; 11 | clearTimeout(timeout); 12 | timeout = setTimeout(later, wait); 13 | }; 14 | } 15 | 16 | // Global variables 17 | let isEnabled = true; 18 | const SCRIPT_ID = 'dynamic-rtl-styles'; 19 | // const RTL_PROCESSED_ATTR = 'data-rtl-processed'; // REMOVED 20 | const RTL_LISTENER_ATTR = 'data-rtl-listener'; // Attribute for input listeners 21 | 22 | // --- Debounced Processing Functions --- 23 | // Debounce document processing to avoid excessive runs on dynamic pages 24 | const debouncedProcessDocument = debounce(processDocument, 300); 25 | // Debounce input processing 26 | const debouncedProcessInputs = debounce(processInputs, 300); 27 | 28 | // --- Font Settings --- (Global scope for easy access) 29 | let currentFontFamily = 'Vazirmatn'; // Default 30 | let currentFontSrc = `url('${chrome.runtime.getURL('fonts/Vazirmatn[wght].ttf')}') format('truetype-variations')`; 31 | 32 | // Initialize the extension 33 | function initExtension() { 34 | // Check if the page already has RTL language set 35 | if (document.documentElement.lang === "fa-IR" || document.documentElement.lang === "ar") { 36 | return; 37 | } 38 | 39 | // Get font settings first, then proceed with enabling logic 40 | loadFontSettings(() => { 41 | // Check if the extension is enabled for this domain 42 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], (syncResult) => { 43 | const currentHost = window.location.hostname; 44 | const defaultEnabled = syncResult.defaultEnabled !== undefined ? syncResult.defaultEnabled : true; 45 | const disabledSites = syncResult.disabledSites || []; 46 | const enabledSites = syncResult.enabledSites || []; 47 | 48 | if (defaultEnabled) { 49 | isEnabled = !disabledSites.includes(currentHost); 50 | } else { 51 | isEnabled = enabledSites.includes(currentHost); 52 | } 53 | 54 | if (isEnabled) { 55 | // Add styles (which now uses global font settings) 56 | addOrUpdateStyles(); 57 | setupInputObservers(); 58 | setupObservers(); 59 | processDocument(); 60 | processInputs(); 61 | } 62 | }); 63 | }); 64 | } 65 | 66 | // Load font settings from storage 67 | function loadFontSettings(callback) { 68 | chrome.storage.local.get(['useCustomFont', 'customFontData', 'customFontFamily'], (localResult) => { 69 | if (localResult.useCustomFont && localResult.customFontData && localResult.customFontFamily) { 70 | currentFontFamily = localResult.customFontFamily; 71 | // Use the Base64 data URL directly 72 | currentFontSrc = `url('${localResult.customFontData}') format('truetype')`; 73 | // Note: format('truetype') might be better for Data URLs than variations 74 | console.log('Using custom font:', currentFontFamily); 75 | } else { 76 | // Reset to default if custom font is disabled or data is missing 77 | currentFontFamily = 'Vazirmatn'; 78 | currentFontSrc = `url('${chrome.runtime.getURL('fonts/Vazirmatn[wght].ttf')}') format('truetype-variations')`; 79 | console.log('Using default font: Vazirmatn'); 80 | } 81 | if (callback) callback(); 82 | }); 83 | } 84 | 85 | // Add or Update CSS rules (Font applied only with direction) 86 | function addOrUpdateStyles() { 87 | let style = document.getElementById(SCRIPT_ID); 88 | if (!style) { 89 | style = document.createElement('style'); 90 | style.id = SCRIPT_ID; 91 | document.head.appendChild(style); 92 | } 93 | 94 | // Determine the correct format string based on the source 95 | const formatString = currentFontSrc.startsWith('url(\'data:') ? 'truetype' : 'truetype-variations'; 96 | 97 | style.textContent = ` 98 | /* Load the font */ 99 | @font-face { 100 | font-family: '${currentFontFamily}'; 101 | src: ${currentFontSrc}; 102 | font-weight: 100 900; 103 | font-display: block; /* Still use block to prevent FOUT */ 104 | /* Explicitly state format */ 105 | /* format('${formatString}'); */ /* Commented out: format in src is preferred */ 106 | } 107 | 108 | /* Remove broad application of font */ 109 | /* 110 | body, 111 | p, span, div, li, a, h1, h2, h3, h4, h5, h6, 112 | input, textarea, [contenteditable] { 113 | font-family: '${currentFontFamily}', Arial, sans-serif; 114 | } 115 | */ 116 | 117 | /* Apply direction, alignment, AND font family ONLY when RTL is detected */ 118 | [data-rtl="true"], 119 | input[data-rtl="true"], 120 | textarea[data-rtl="true"], 121 | [contenteditable][data-rtl="true"] { 122 | direction: rtl !important; 123 | text-align: right !important; 124 | /* Apply the correct font family here */ 125 | font-family: '${currentFontFamily}', Arial, sans-serif !important; 126 | } 127 | 128 | /* Rule for focused inputs (auto direction) */ 129 | input[type="text"]:focus, textarea:focus { 130 | direction: auto !important; 131 | /* Font is handled by the general input rule or data-rtl */ 132 | } 133 | 134 | /* Rule for active RTL inputs (ensures styles stick) */ 135 | .rtl-input-active { 136 | direction: rtl !important; 137 | text-align: right !important; 138 | /* Apply the correct font family here */ 139 | font-family: '${currentFontFamily}', Arial, sans-serif !important; 140 | } 141 | `; 142 | } 143 | 144 | // Regular expression to detect Persian or Arabic text 145 | function isPersianOrArabic(text) { 146 | if (!text) return false; 147 | // Enhanced regex to better detect Persian/Arabic text 148 | const persianArabicRegex = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/; 149 | return persianArabicRegex.test(text); 150 | } 151 | 152 | // Check if the first word of text is Persian or Arabic 153 | function startsWithPersianOrArabic(text) { 154 | if (!text) return false; 155 | 156 | // Remove leading whitespace and get the first word 157 | const trimmedText = text.trim(); 158 | const firstWord = trimmedText.split(/\s+/)[0]; 159 | 160 | // Check if the first word contains Persian/Arabic characters 161 | return isPersianOrArabic(firstWord); 162 | } 163 | 164 | // Function to process a single text node (More Conservative: Apply to direct parent) 165 | function processNode(node) { 166 | const parent = node.parentElement; 167 | // Basic checks 168 | if (!parent || parent.tagName === 'BODY' || parent.tagName === 'HTML') { 169 | // Avoid applying directly to body/html or if no parent 170 | return; 171 | } 172 | // Also skip if parent is already RTL (avoids redundant checks/setting) 173 | if (parent.hasAttribute('data-rtl')) { 174 | return; 175 | } 176 | 177 | const text = node.textContent; 178 | 179 | // Check for Persian/Arabic starting text 180 | if (text && text.trim() !== '' && isPersianOrArabic(text) && startsWithPersianOrArabic(text)) { 181 | // Apply data-rtl directly to the immediate parent element 182 | // This is more conservative and less likely to affect large page sections. 183 | parent.setAttribute('data-rtl', 'true'); 184 | // console.log('Applied data-rtl to:', parent.tagName, parent.id ? `#${parent.id}` : '', parent.className ? `.${parent.className}` : ''); // Debugging 185 | } 186 | // If text is not RTL, we currently do nothing (don't remove attributes applied by other text nodes) 187 | } 188 | 189 | // New function to walk and process a specific subtree (Uses the conservative processNode) 190 | function walkAndProcessSubtree(rootElement) { 191 | if (!rootElement || !isEnabled) return; 192 | // Ensure we don't process inside elements that should be ignored 193 | if (rootElement.closest('script, style, noscript, textarea, input, [contenteditable]')){ 194 | return; 195 | } 196 | 197 | const walker = document.createTreeWalker( 198 | rootElement, 199 | NodeFilter.SHOW_TEXT, 200 | { 201 | acceptNode: function(node) { 202 | const parent = node.parentElement; 203 | // Basic filter: ignore scripts/styles/inputs/etc. 204 | if (parent?.closest('script, style, noscript, textarea, input, [contenteditable]')) { 205 | return NodeFilter.FILTER_REJECT; 206 | } 207 | // Skip nodes whose direct parent already got RTL (optimization) 208 | if (parent?.hasAttribute('data-rtl')) { 209 | return NodeFilter.FILTER_REJECT; 210 | } 211 | if (!node.textContent || node.textContent.trim() === '') { 212 | return NodeFilter.FILTER_REJECT; 213 | } 214 | return NodeFilter.FILTER_ACCEPT; 215 | } 216 | }, 217 | false 218 | ); 219 | let node; 220 | while (node = walker.nextNode()) { 221 | processNode(node); // Uses the conservative version now 222 | } 223 | } 224 | 225 | // Setup observers for general page content changes (Targeted Processing) 226 | function setupObservers() { 227 | const mainObserver = new MutationObserver(mutations => { 228 | if (!isEnabled) return; 229 | // console.log('Mutations observed:', mutations.length); // Debugging 230 | 231 | for (const mutation of mutations) { 232 | if (mutation.type === 'characterData') { 233 | // Process the text node that changed 234 | // console.log('Processing characterData mutation'); 235 | processNode(mutation.target); 236 | } else if (mutation.type === 'childList') { 237 | // Process added nodes 238 | if (mutation.addedNodes.length > 0) { 239 | // console.log('Processing added nodes:', mutation.addedNodes.length); 240 | mutation.addedNodes.forEach(node => { 241 | if (node.nodeType === Node.TEXT_NODE) { 242 | processNode(node); 243 | } else if (node.nodeType === Node.ELEMENT_NODE) { 244 | // Check the element itself and its descendants 245 | // Avoid processing if element is inside an already RTL element 246 | if (!node.closest('[data-rtl="true"]')) { 247 | // Check text within the added element 248 | if (node.matches(':not(script, style, noscript, textarea, input, [contenteditable])')) { 249 | walkAndProcessSubtree(node); 250 | } 251 | } 252 | } 253 | }); 254 | } 255 | // Handling removed nodes is complex and often not necessary 256 | // If an RTL element is removed, its effect is gone naturally. 257 | } 258 | } 259 | }); 260 | 261 | mainObserver.observe(document.documentElement, { 262 | childList: true, 263 | subtree: true, 264 | characterData: true 265 | }); 266 | } 267 | 268 | // Setup observers specifically for input fields (Uses debouncedProcessInputs) 269 | function setupInputObservers() { 270 | // Process initial inputs 271 | processInputs(); 272 | 273 | const inputObserver = new MutationObserver(mutations => { 274 | if (!isEnabled) return; 275 | let shouldProcessInputs = false; 276 | for (const mutation of mutations) { 277 | if (mutation.type === 'childList' && mutation.addedNodes.length) { 278 | for (const node of mutation.addedNodes) { 279 | if (node.nodeType === Node.ELEMENT_NODE) { 280 | if ( 281 | (node.tagName === 'INPUT' && (node.type === 'text' || node.type === 'search' || !node.hasAttribute('type'))) || 282 | node.tagName === 'TEXTAREA' || 283 | node.hasAttribute('contenteditable') || 284 | node.querySelector('input[type="text"], input[type="search"], input:not([type]), textarea, [contenteditable]') 285 | ) { 286 | shouldProcessInputs = true; 287 | break; // Break inner loop 288 | } 289 | } 290 | } 291 | } 292 | if (shouldProcessInputs) break; // Break outer loop 293 | } 294 | 295 | if (shouldProcessInputs) { 296 | debouncedProcessInputs(); // Call the debounced function 297 | } 298 | }); 299 | 300 | inputObserver.observe(document.documentElement, { 301 | childList: true, 302 | subtree: true 303 | }); 304 | } 305 | 306 | // Process the entire document body using TreeWalker (Uses the conservative processNode) 307 | function processDocument() { 308 | if (!isEnabled) return; 309 | // console.log('Processing document...'); 310 | const walker = document.createTreeWalker( 311 | document.body, 312 | NodeFilter.SHOW_TEXT, 313 | { 314 | acceptNode: function(node) { 315 | const parent = node.parentElement; 316 | // Basic filter: ignore scripts/styles/inputs/etc. 317 | if (parent?.closest('script, style, noscript, textarea, input, [contenteditable]')) { 318 | return NodeFilter.FILTER_REJECT; 319 | } 320 | // Skip nodes whose direct parent already got RTL (optimization) 321 | if (parent?.hasAttribute('data-rtl')) { 322 | return NodeFilter.FILTER_REJECT; 323 | } 324 | // Reject if the text content is purely whitespace 325 | if (!node.textContent || node.textContent.trim() === '') { 326 | return NodeFilter.FILTER_REJECT; 327 | } 328 | return NodeFilter.FILTER_ACCEPT; 329 | } 330 | }, 331 | false 332 | ); 333 | 334 | let node; 335 | while (node = walker.nextNode()) { 336 | processNode(node); // Uses the conservative version now 337 | } 338 | } 339 | 340 | // Process input and textarea elements (Uses debouncedProcessInputs indirectly) 341 | function processInputs() { 342 | if (!isEnabled) return; 343 | // console.log('Processing inputs...'); // For debugging 344 | 345 | // Process input fields 346 | const inputElements = document.querySelectorAll(`input[type="text"]:not([${RTL_LISTENER_ATTR}]), input[type="search"]:not([${RTL_LISTENER_ATTR}]), input:not([type]):not([${RTL_LISTENER_ATTR}]), textarea:not([${RTL_LISTENER_ATTR}])`); 347 | inputElements.forEach(setupInputElement); 348 | 349 | // Process contenteditable elements 350 | const editableElements = document.querySelectorAll(`[contenteditable="true"]:not([${RTL_LISTENER_ATTR}]), [contenteditable=""]:not([${RTL_LISTENER_ATTR}])`); 351 | editableElements.forEach(setupEditableElement); 352 | } 353 | 354 | // Setup event handlers for an input element (uses RTL_LISTENER_ATTR) 355 | function setupInputElement(element) { 356 | if (!element || element.hasAttribute(RTL_LISTENER_ATTR)) return; 357 | element.setAttribute(RTL_LISTENER_ATTR, 'true'); 358 | 359 | // Check if the input already has Persian/Arabic text that starts with Persian/Arabic 360 | if ((isPersianOrArabic(element.value) && startsWithPersianOrArabic(element.value)) || 361 | (element.hasAttribute('placeholder') && isPersianOrArabic(element.getAttribute('placeholder')) && 362 | startsWithPersianOrArabic(element.getAttribute('placeholder')))) { 363 | element.setAttribute('data-rtl', 'true'); 364 | element.classList.add('rtl-input-active'); 365 | } 366 | 367 | // Add input event listener for real-time RTL detection 368 | element.addEventListener('input', function() { 369 | if (isPersianOrArabic(this.value) && startsWithPersianOrArabic(this.value)) { 370 | this.setAttribute('data-rtl', 'true'); 371 | this.classList.add('rtl-input-active'); 372 | } else if (this.value.trim() === '') { 373 | // Only remove RTL if there's no placeholder with Persian/Arabic 374 | if (!(this.hasAttribute('placeholder') && 375 | isPersianOrArabic(this.getAttribute('placeholder')) && 376 | startsWithPersianOrArabic(this.getAttribute('placeholder')))) { 377 | this.removeAttribute('data-rtl'); 378 | this.classList.remove('rtl-input-active'); 379 | } 380 | } else { 381 | // If text doesn't start with Persian/Arabic, remove RTL 382 | this.removeAttribute('data-rtl'); 383 | this.classList.remove('rtl-input-active'); 384 | } 385 | }); 386 | 387 | // Also check on focus and blur 388 | element.addEventListener('focus', function() { 389 | if (isPersianOrArabic(this.value) && startsWithPersianOrArabic(this.value)) { 390 | this.setAttribute('data-rtl', 'true'); 391 | this.classList.add('rtl-input-active'); 392 | } 393 | }); 394 | 395 | element.addEventListener('blur', function() { 396 | if (isPersianOrArabic(this.value) && startsWithPersianOrArabic(this.value)) { 397 | this.setAttribute('data-rtl', 'true'); 398 | this.classList.add('rtl-input-active'); 399 | } else { 400 | this.removeAttribute('data-rtl'); 401 | this.classList.remove('rtl-input-active'); 402 | } 403 | }); 404 | 405 | // Handle paste events 406 | element.addEventListener('paste', function() { 407 | // Use setTimeout to check the value after paste is complete 408 | setTimeout(() => { 409 | if (isPersianOrArabic(this.value) && startsWithPersianOrArabic(this.value)) { 410 | this.setAttribute('data-rtl', 'true'); 411 | this.classList.add('rtl-input-active'); 412 | } else { 413 | this.removeAttribute('data-rtl'); 414 | this.classList.remove('rtl-input-active'); 415 | } 416 | }, 0); 417 | }); 418 | } 419 | 420 | // Setup event handlers for a contenteditable element (uses RTL_LISTENER_ATTR) 421 | function setupEditableElement(element) { 422 | if (!element || element.hasAttribute(RTL_LISTENER_ATTR)) return; 423 | element.setAttribute(RTL_LISTENER_ATTR, 'true'); 424 | 425 | // Check if the element already has Persian/Arabic text that starts with Persian/Arabic 426 | if (isPersianOrArabic(element.textContent) && startsWithPersianOrArabic(element.textContent)) { 427 | element.setAttribute('data-rtl', 'true'); 428 | } 429 | 430 | // Add input event listeners for real-time RTL detection 431 | element.addEventListener('input', function() { 432 | if (isPersianOrArabic(this.textContent) && startsWithPersianOrArabic(this.textContent)) { 433 | this.setAttribute('data-rtl', 'true'); 434 | } else if (this.textContent.trim() === '') { 435 | this.removeAttribute('data-rtl'); 436 | } else { 437 | // If text doesn't start with Persian/Arabic, remove RTL 438 | this.removeAttribute('data-rtl'); 439 | } 440 | }); 441 | 442 | // Handle paste events 443 | element.addEventListener('paste', function() { 444 | // Use setTimeout to check the content after paste is complete 445 | setTimeout(() => { 446 | if (isPersianOrArabic(this.textContent) && startsWithPersianOrArabic(this.textContent)) { 447 | this.setAttribute('data-rtl', 'true'); 448 | } else { 449 | this.removeAttribute('data-rtl'); 450 | } 451 | }, 0); 452 | }); 453 | } 454 | 455 | // Listen for messages from the popup or background script 456 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 457 | let asyncResponse = false; 458 | if (request.command === 'toggleStatus') { 459 | const wasEnabled = isEnabled; 460 | isEnabled = request.status; 461 | console.log('Received toggleStatus:', isEnabled); 462 | if (isEnabled && !wasEnabled) { 463 | // If enabling, reload font settings and apply styles/processing 464 | asyncResponse = true; // Need to wait for font settings 465 | loadFontSettings(() => { 466 | addOrUpdateStyles(); 467 | processDocument(); 468 | processInputs(); 469 | setupObservers(); 470 | setupInputObservers(); 471 | sendResponse({ success: true }); 472 | }); 473 | } else if (!isEnabled && wasEnabled) { 474 | // If disabling, simply stop processing (observers check isEnabled flag) 475 | // Optionally remove styles or attributes here if needed 476 | // const styleElement = document.getElementById(SCRIPT_ID); 477 | // if (styleElement) styleElement.remove(); 478 | sendResponse({ success: true }); 479 | } else { 480 | // No change in state needed 481 | sendResponse({ success: true }); 482 | } 483 | } else if (request.command === 'updateFont') { 484 | console.log('Received updateFont command'); 485 | asyncResponse = true; // Need to wait for font settings 486 | loadFontSettings(() => { 487 | if (isEnabled) { // Only update styles if the extension is active for this tab 488 | addOrUpdateStyles(); 489 | } 490 | sendResponse({ success: true }); 491 | }); 492 | } 493 | 494 | return asyncResponse; // Return true if we used an async callback for sendResponse 495 | }); 496 | 497 | // Initialize when the script loads 498 | if (document.readyState === 'loading') { 499 | document.addEventListener('DOMContentLoaded', initExtension); 500 | } else { 501 | initExtension(); 502 | } 503 | 504 | // Handle dynamic content loading 505 | window.addEventListener('load', processDocument); 506 | document.addEventListener('readystatechange', processDocument); -------------------------------------------------------------------------------- /fonts/Vazirmatn[wght].ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/so-roush/Dynamic-RTL/9b1b4352288e0e09a902871ad6b210544d675f0e/fonts/Vazirmatn[wght].ttf -------------------------------------------------------------------------------- /fonts/test.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/README.txt: -------------------------------------------------------------------------------- 1 | ICON PLACEHOLDER 2 | 3 | Please replace these placeholder files with actual icon images: 4 | 5 | 1. icon16.png - 16x16 pixels 6 | 2. icon48.png - 48x48 pixels 7 | 3. icon128.png - 128x128 pixels 8 | 9 | Suggested icon design: 10 | - A simple "RTL" text with an arrow pointing from right to left 11 | - Use blue (#2196F3) as the primary color to match the toggle switch in the popup 12 | - Keep the design simple and recognizable at small sizes -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/so-roush/Dynamic-RTL/9b1b4352288e0e09a902871ad6b210544d675f0e/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon128.png.txt: -------------------------------------------------------------------------------- 1 | Placeholder for 128x128 icon 2 | Replace with actual 128x128 PNG image -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/so-roush/Dynamic-RTL/9b1b4352288e0e09a902871ad6b210544d675f0e/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon16.png.txt: -------------------------------------------------------------------------------- 1 | Placeholder for 16x16 icon 2 | Replace with actual 16x16 PNG image -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/so-roush/Dynamic-RTL/9b1b4352288e0e09a902871ad6b210544d675f0e/icons/icon48.png -------------------------------------------------------------------------------- /icons/icon48.png.txt: -------------------------------------------------------------------------------- 1 | Placeholder for 48x48 icon 2 | Replace with actual 48x48 PNG image -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Dynamic RTL & Translator", 4 | "version": "1.2.0", 5 | "description": "Automatically applies RTL and translates web pages using Gemini", 6 | "permissions": [ 7 | "storage", 8 | "scripting", 9 | "contextMenus", 10 | "activeTab" 11 | ], 12 | "host_permissions": [ 13 | "" 14 | ], 15 | "background": { 16 | "service_worker": "background.js" 17 | }, 18 | "content_scripts": [ 19 | { 20 | "id": "dynamic-rtl", 21 | "matches": [""], 22 | "js": ["content.js"], 23 | "run_at": "document_start" 24 | }, 25 | { 26 | "id": "translator", 27 | "matches": [""], 28 | "js": ["translate.js"], 29 | "run_at": "document_idle" 30 | } 31 | ], 32 | "action": { 33 | "default_popup": "popup.html", 34 | "default_icon": { 35 | "16": "icons/icon16.png", 36 | "48": "icons/icon48.png", 37 | "128": "icons/icon128.png" 38 | } 39 | }, 40 | "icons": { 41 | "16": "icons/icon16.png", 42 | "48": "icons/icon48.png", 43 | "128": "icons/icon128.png" 44 | }, 45 | "web_accessible_resources": [ 46 | { 47 | "resources": ["fonts/Vazirmatn[wght].ttf"], 48 | "matches": [""] 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dynamic RTL 6 | 303 | 304 | 305 |
306 |

Dynamic RTL

307 |
308 | 309 | 310 | 311 |
312 |
فعال‌سازی در سایت فعلی (Loading...)
313 |
314 | 318 |
319 |
320 | 321 |
322 |
تنظیمات کلی و مترجم
323 |
324 |
325 | 326 |
327 |
328 | 329 |
330 | 331 | (غیرفعال‌سازی دستی برای سایت دلخواه) 332 |
333 |
334 |
335 | 336 |
337 | 338 | (فعال‌سازی دستی برای سایت دلخواه) 339 |
340 |
341 |
342 |
343 | 344 |
345 | 346 |
347 |
فونت سفارشی:
348 |
349 | استفاده از فونت سفارشی 350 | 354 |
355 | 360 |
361 | 362 |
363 | 364 |
365 | 366 |
367 | 368 | 369 | 370 | 371 |
372 | برای استفاده از مترجم، نیاز به کلید API از Google AI Studio دارید. 373 | کلید خود را از اینجا دریافت کنید. 374 | کلید شما به صورت محلی ذخیره می‌شود. 375 |
376 |
377 |
378 | 379 | 385 |
386 |
387 | 388 | 394 |
395 | محدودیت روزانه تقریبی است. مدل پیش‌فرض Flash Preview است (سریع و محدودیت بالا). 396 |
397 |
398 |
399 | 400 |
401 |
402 | 403 | 425 | 426 | 427 | 428 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Function to get the font family name from a TTF file name 4 | function getFontFamilyName(fileName) { 5 | // Remove TTF extension and potential variable axes in brackets 6 | let name = fileName.replace(/\.(ttf|otf)$/i, '').replace(/\[.*?\]/g, ''); 7 | // Replace common separators and non-alphanumeric (keep spaces) with space, trim 8 | name = name.replace(/[_-]+/g, ' ').replace(/[^a-zA-Z0-9\s\-]/g, '').replace(/\s+/g, ' ').trim(); 9 | // Capitalize first letter of each word (optional, for better display) 10 | name = name.split(' ').filter(Boolean).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); 11 | // Common case: append 'VF' or 'Variable' if it was likely stripped but might be needed 12 | // This is heuristic and might not always be correct 13 | // if (fileName.toLowerCase().includes('variable') || fileName.includes('[')) { 14 | // if (!name.toLowerCase().includes('variable') && !name.toLowerCase().includes(' vf')) { 15 | // name += ' Variable'; // Or VF? 16 | // } 17 | // } 18 | return name || 'Custom Font'; // Fallback name 19 | } 20 | 21 | document.addEventListener('DOMContentLoaded', () => { 22 | const siteToggle = document.getElementById('site-toggle'); 23 | const defaultEnabledRadio = document.getElementById('default-enabled'); 24 | const defaultDisabledRadio = document.getElementById('default-disabled'); 25 | const currentSiteSpan = document.getElementById('current-site'); 26 | 27 | // Collapsible General Settings 28 | const generalSettingsHeader = document.getElementById('general-settings-header'); 29 | const generalSettingsContent = document.getElementById('general-settings-content'); 30 | 31 | // Custom Font Elements 32 | const customFontToggle = document.getElementById('custom-font-toggle'); 33 | const customFontControls = document.getElementById('custom-font-controls'); 34 | const customFontInput = document.getElementById('custom-font-input'); 35 | const customFontStatus = document.getElementById('current-font-name'); 36 | 37 | // New Translator elements 38 | const apiKeyInput = document.getElementById('api-key-input'); 39 | const testApiButton = document.getElementById('test-api-button'); 40 | const apiTestResultSpan = document.getElementById('api-test-result'); 41 | const toneSelect = document.getElementById('translation-tone'); 42 | const modelSelect = document.getElementById('translation-model'); 43 | const translateButton = document.getElementById('translate-button'); 44 | 45 | let currentHost = ''; 46 | let currentTabId = null; 47 | 48 | // --- Load initial state --- 49 | function loadInitialState() { 50 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 51 | if (tabs[0]) { 52 | currentTabId = tabs[0].id; 53 | if (tabs[0].url) { 54 | try { 55 | const url = new URL(tabs[0].url); 56 | currentHost = url.hostname; 57 | currentSiteSpan.textContent = currentHost; 58 | // Load sync settings (RTL) 59 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], (syncResult) => { 60 | const defaultEnabled = syncResult.defaultEnabled !== undefined ? syncResult.defaultEnabled : true; 61 | const disabledSites = syncResult.disabledSites || []; 62 | const enabledSites = syncResult.enabledSites || []; 63 | 64 | defaultEnabledRadio.checked = defaultEnabled; 65 | defaultDisabledRadio.checked = !defaultEnabled; 66 | 67 | siteToggle.checked = defaultEnabled ? !disabledSites.includes(currentHost) : enabledSites.includes(currentHost); 68 | siteToggle.disabled = false; 69 | }); 70 | } catch(e) { 71 | currentSiteSpan.textContent = 'Invalid URL'; 72 | siteToggle.disabled = true; 73 | translateButton.disabled = true; // Disable translate on invalid URL 74 | } 75 | } else { 76 | currentSiteSpan.textContent = 'N/A'; 77 | siteToggle.disabled = true; 78 | translateButton.disabled = true; // Disable translate on non-http pages 79 | } 80 | // Enable translate button only for valid http/https tabs 81 | if (!tabs[0].url?.startsWith('http')) { 82 | translateButton.disabled = true; 83 | } 84 | } else { 85 | currentSiteSpan.textContent = 'N/A'; 86 | siteToggle.disabled = true; 87 | translateButton.disabled = true; // Disable if no active tab 88 | } 89 | }); 90 | 91 | // Load local settings (custom font and API key) 92 | chrome.storage.local.get(['useCustomFont', 'customFontName', 'geminiApiKey'], (localResult) => { 93 | // Custom font part 94 | const useCustomFont = localResult.useCustomFont || false; 95 | const fontName = localResult.customFontName || 'پیش‌فرض (وزیرمتن)'; 96 | customFontToggle.checked = useCustomFont; 97 | customFontStatus.textContent = fontName; 98 | customFontControls.style.display = useCustomFont ? 'block' : 'none'; 99 | // API Key part 100 | if (localResult.geminiApiKey) { 101 | apiKeyInput.value = localResult.geminiApiKey; 102 | } 103 | }); 104 | 105 | // Load sync settings (tone preference and model preference) 106 | chrome.storage.sync.get(['translationTone', 'translationModel'], (syncResult) => { 107 | toneSelect.value = syncResult.translationTone || 'رسمی'; 108 | modelSelect.value = syncResult.translationModel || 'gemini-2.5-flash-preview-04-17'; 109 | // Remove redundant font setting - handled by CSS now 110 | // document.querySelectorAll('.setting-description').forEach(el => el.style.fontFamily = '"Vazirmatn", sans-serif'); 111 | }); 112 | } 113 | 114 | // --- Event Listeners --- 115 | 116 | // General Settings Collapsible Toggle 117 | generalSettingsHeader.addEventListener('click', () => { 118 | generalSettingsHeader.classList.toggle('active'); 119 | if (generalSettingsContent.style.display === "block") { 120 | generalSettingsContent.style.display = "none"; 121 | } else { 122 | generalSettingsContent.style.display = "block"; 123 | } 124 | }); 125 | 126 | // Site-specific toggle 127 | siteToggle.addEventListener('change', () => { 128 | if (!currentHost) return; 129 | const isEnabled = siteToggle.checked; 130 | 131 | chrome.storage.sync.get(['disabledSites', 'enabledSites', 'defaultEnabled'], (result) => { 132 | const defaultEnabled = result.defaultEnabled !== undefined ? result.defaultEnabled : true; 133 | let disabledSites = result.disabledSites || []; 134 | let enabledSites = result.enabledSites || []; 135 | 136 | if (defaultEnabled) { 137 | disabledSites = disabledSites.filter(site => site !== currentHost); 138 | if (!isEnabled) disabledSites.push(currentHost); 139 | } else { 140 | enabledSites = enabledSites.filter(site => site !== currentHost); 141 | if (isEnabled) enabledSites.push(currentHost); 142 | } 143 | 144 | chrome.storage.sync.set({ disabledSites, enabledSites }, () => { 145 | notifyContentScript( 'toggleStatus', { status: isEnabled }); 146 | }); 147 | }); 148 | }); 149 | 150 | // Default mode radio buttons 151 | defaultEnabledRadio.addEventListener('change', setDefaultMode); 152 | defaultDisabledRadio.addEventListener('change', setDefaultMode); 153 | 154 | function setDefaultMode() { 155 | const defaultEnabled = defaultEnabledRadio.checked; 156 | chrome.storage.sync.set({ defaultEnabled }, () => { 157 | // Update site toggle based on new default 158 | chrome.storage.sync.get(['disabledSites', 'enabledSites'], (result) => { 159 | const disabledSites = result.disabledSites || []; 160 | const enabledSites = result.enabledSites || []; 161 | const siteShouldBeEnabled = defaultEnabled ? !disabledSites.includes(currentHost) : enabledSites.includes(currentHost); 162 | 163 | if (siteToggle.checked !== siteShouldBeEnabled) { 164 | siteToggle.checked = siteShouldBeEnabled; 165 | notifyContentScript( 'toggleStatus', { status: siteShouldBeEnabled }); 166 | } 167 | }); 168 | console.log(`Default mode set to: ${defaultEnabled ? 'Enabled' : 'Disabled'}`); 169 | }); 170 | } 171 | 172 | // Custom Font Toggle 173 | customFontToggle.addEventListener('change', () => { 174 | const useCustom = customFontToggle.checked; 175 | customFontControls.style.display = useCustom ? 'block' : 'none'; 176 | chrome.storage.local.set({ useCustomFont: useCustom }, () => { 177 | if (!useCustom) { 178 | // Clear custom font data when disabling 179 | clearCustomFontData(true); // true = also notify content scripts 180 | } else { 181 | // If enabling, check if font data exists and trigger update if it does 182 | chrome.storage.local.get(['customFontData'], (result) => { 183 | if (result.customFontData) { 184 | triggerFontUpdate(); 185 | } 186 | // If no data, user must select a file 187 | }); 188 | } 189 | console.log(`Use custom font: ${useCustom}`); 190 | }); 191 | }); 192 | 193 | // Custom Font File Input 194 | customFontInput.addEventListener('change', (event) => { 195 | const file = event.target.files[0]; 196 | customFontInput.disabled = true; // Disable input while processing 197 | 198 | if (!file) { 199 | customFontInput.disabled = false; 200 | return; 201 | } 202 | 203 | if (!file.name.toLowerCase().endsWith('.ttf')) { 204 | alert('لطفا یک فایل با فرمت TTF انتخاب کنید.'); 205 | customFontInput.value = ''; 206 | customFontInput.disabled = false; 207 | return; 208 | } 209 | 210 | const maxSize = 5 * 1024 * 1024; // 5MB limit 211 | if (file.size > maxSize) { 212 | alert(`فایل فونت بزرگتر از 5MB است (${(file.size / 1024 / 1024).toFixed(1)}MB). لطفا فونت کوچکتری انتخاب کنید.`); 213 | customFontInput.value = ''; 214 | customFontInput.disabled = false; 215 | return; 216 | } 217 | 218 | const reader = new FileReader(); 219 | 220 | reader.onload = function(e) { 221 | try { 222 | // e.target.result now directly contains the Data URL (Base64 encoded) 223 | const dataUrl = e.target.result; 224 | 225 | // Validate if it's actually a TTF data url (basic check) 226 | if (!dataUrl.startsWith('data:font/ttf;base64,') && !dataUrl.startsWith('data:application/font-sfnt;base64,') && !dataUrl.startsWith('data:application/octet-stream;base64,')) { // Some systems might use octet-stream for ttf 227 | console.warn('FileReader result does not look like a TTF Data URL:', dataUrl.substring(0, 50)); 228 | // We might still try saving it, but it's suspicious. 229 | // Alternatively, throw an error: 230 | // throw new Error('Invalid data URL format received from FileReader.'); 231 | } 232 | 233 | const fontFamilyName = getFontFamilyName(file.name); 234 | 235 | chrome.storage.local.set({ 236 | customFontData: dataUrl, // Save the Data URL directly 237 | customFontName: file.name, 238 | customFontFamily: fontFamilyName 239 | }, () => { 240 | customFontStatus.textContent = file.name; 241 | alert(`فونت '${file.name}' با موفقیت ذخیره شد.`); 242 | triggerFontUpdate(); 243 | console.log(`Custom font stored: ${file.name}, Family: ${fontFamilyName}`); 244 | customFontInput.disabled = false; 245 | customFontInput.value = ''; // Clear input after success 246 | }); 247 | } catch (error) { 248 | console.error("Error processing font file:", error); 249 | alert('خطا در پردازش فایل فونت. ممکن است فایل معتبر نباشد.'); 250 | customFontInput.value = ''; 251 | customFontInput.disabled = false; 252 | } 253 | }; 254 | 255 | reader.onerror = function(e) { 256 | console.error("Error reading font file:", e); 257 | alert('خطا در خواندن فایل فونت.'); 258 | customFontInput.value = ''; 259 | customFontInput.disabled = false; 260 | }; 261 | 262 | // Read the file directly as a Data URL 263 | reader.readAsDataURL(file); 264 | }); 265 | 266 | // API Key Input 267 | apiKeyInput.addEventListener('input', () => { 268 | apiTestResultSpan.textContent = ''; 269 | }); 270 | apiKeyInput.addEventListener('change', () => { 271 | const apiKey = apiKeyInput.value.trim(); 272 | if (apiKey) { 273 | chrome.storage.local.set({ geminiApiKey: apiKey }, () => { 274 | console.log('Gemini API Key saved.'); 275 | // Optional: Add visual feedback (e.g., a temporary checkmark) 276 | }); 277 | } else { 278 | // If cleared, remove from storage 279 | chrome.storage.local.remove('geminiApiKey', () => { 280 | console.log('Gemini API Key removed.'); 281 | }); 282 | } 283 | }); 284 | 285 | // Test API Key Button 286 | testApiButton.addEventListener('click', () => { 287 | const apiKey = apiKeyInput.value.trim(); 288 | if (!apiKey) { 289 | apiTestResultSpan.textContent = 'کلید خالی است'; 290 | apiTestResultSpan.style.color = 'red'; 291 | return; 292 | } 293 | apiTestResultSpan.textContent = 'درحال تست...'; 294 | apiTestResultSpan.style.color = 'grey'; 295 | testApiButton.disabled = true; 296 | 297 | // Send message to background script to test the key 298 | chrome.runtime.sendMessage({ command: 'testApiKey', apiKey: apiKey }) 299 | .then(response => { 300 | if (response.success) { 301 | apiTestResultSpan.textContent = 'معتبر ✅'; 302 | apiTestResultSpan.style.color = 'green'; 303 | } else { 304 | // Display specific error from background script 305 | const errorMessage = response.error || 'خطای نامشخص'; 306 | apiTestResultSpan.textContent = `نامعتبر ❌ (${errorMessage})`; 307 | apiTestResultSpan.style.color = 'red'; 308 | } 309 | }) 310 | .catch(error => { 311 | console.error("Error testing API key (message sending failed):", error); 312 | // Error sending the message itself 313 | apiTestResultSpan.textContent = 'خطا در ارسال پیام تست'; 314 | apiTestResultSpan.style.color = 'red'; 315 | }) 316 | .finally(() => { 317 | testApiButton.disabled = false; 318 | }); 319 | }); 320 | 321 | // Tone Selection 322 | toneSelect.addEventListener('change', () => { 323 | const selectedTone = toneSelect.value; 324 | chrome.storage.sync.set({ translationTone: selectedTone }, () => { 325 | console.log(`Translation tone set to: ${selectedTone}`); 326 | }); 327 | }); 328 | 329 | // Model Selection 330 | modelSelect.addEventListener('change', () => { 331 | const selectedModel = modelSelect.value; 332 | chrome.storage.sync.set({ translationModel: selectedModel }, () => { 333 | console.log(`Translation model set to: ${selectedModel}`); 334 | }); 335 | }); 336 | 337 | // Translate Button 338 | translateButton.addEventListener('click', () => { 339 | if (translateButton.disabled) return; 340 | 341 | // 1. Get API Key from storage 342 | chrome.storage.local.get('geminiApiKey', (localResult) => { 343 | const apiKey = localResult.geminiApiKey; 344 | if (!apiKey) { 345 | alert('لطفا ابتدا کلید API گوگل خود را در تنظیمات وارد کنید.'); 346 | // Optionally focus the API key input 347 | apiKeyInput.focus(); 348 | // Open the settings if collapsed 349 | if (generalSettingsContent.style.display !== 'block') { 350 | generalSettingsHeader.click(); 351 | } 352 | return; 353 | } 354 | 355 | // 2. Get Tone and Model 356 | chrome.storage.sync.get(['translationTone'], (syncResult) => { // Only get tone from storage here 357 | const tone = syncResult.translationTone || 'رسمی'; 358 | // *** Get the currently selected model DIRECTLY from the dropdown *** 359 | const model = modelSelect.value; 360 | 361 | // 3. Send message to content script 362 | if (currentTabId) { 363 | notifyContentScript('translatePage', { apiKey, tone, model }); 364 | // Optional: Close popup after initiating translation 365 | // window.close(); 366 | } else { 367 | console.error("No active tab ID found to send translation request."); 368 | alert("خطا: تب فعالی برای ارسال درخواست ترجمه یافت نشد."); 369 | } 370 | }); 371 | }); 372 | }); 373 | 374 | // --- Helper Functions --- 375 | 376 | function clearCustomFontData(notifyContent) { 377 | chrome.storage.local.remove(['customFontData', 'customFontName', 'customFontFamily'], () => { 378 | customFontStatus.textContent = 'پیش‌فرض (وزیرمتن)'; 379 | customFontInput.value = ''; // Clear file input visually 380 | console.log('Custom font data cleared.'); 381 | if (notifyContent) { 382 | triggerFontUpdate(); // Notify content script to revert to default 383 | } 384 | }); 385 | } 386 | 387 | function triggerFontUpdate() { 388 | console.log('Triggering font update in content scripts...'); 389 | notifyContentScript('updateFont', {}); // Send empty object, content script will fetch from storage 390 | } 391 | 392 | function notifyContentScript(command, data) { 393 | if (currentTabId) { 394 | chrome.tabs.sendMessage(currentTabId, { command: command, data: data }) 395 | .then(response => { 396 | // Handle potential response from content script if needed 397 | if (response && response.status) { 398 | console.log(`Content script responded to ${command}:`, response.status); 399 | } else if(response && response.error) { 400 | console.error(`Content script error for ${command}:`, response.error); 401 | } 402 | }) 403 | .catch(error => { 404 | // Check if the error is due to no receiving end (content script not injected/ready) 405 | if (error.message?.includes("Could not establish connection") || error.message?.includes("Receiving end does not exist")) { 406 | console.warn(`Could not send message '${command}' to content script (tab ID: ${currentTabId}). It might not be injected or ready yet.`); 407 | } else { 408 | console.error(`Error sending message '${command}' to content script (tab ID: ${currentTabId}):`, error); 409 | } 410 | }); 411 | } else { 412 | console.warn("Cannot notify content script: No active tab ID."); 413 | } 414 | } 415 | 416 | // Initialize 417 | loadInitialState(); 418 | 419 | }); // Closing parenthesis for DOMContentLoaded listener -------------------------------------------------------------------------------- /translate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TRANSLATION_CONTAINER_CLASS = 'gemini-translation-container'; 4 | const TRANSLATION_TEXT_CLASS = 'gemini-translation-text'; 5 | const MIN_WORDS_TO_TRANSLATE = 6; // Translate if 6 or more words 6 | const PROCESSED_ATTR = 'data-gemini-translated'; 7 | 8 | let isTranslating = false; // Flag to prevent concurrent translations 9 | 10 | // --- Listener for translation command --- 11 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 12 | if (request.command === 'translatePage') { 13 | if (isTranslating) { 14 | console.warn("Translation already in progress."); 15 | // Optionally alert the user 16 | // alert("Translation is already running."); 17 | sendResponse({ success: false, message: "Translation already running." }); 18 | return false; 19 | } 20 | 21 | // --- Extract data correctly --- 22 | // Data might be nested under 'data' if sent via notifyContentScript from popup.js 23 | // Or at the top level if sent directly from background.js (context menu) 24 | const apiKey = request.apiKey || request.data?.apiKey; 25 | const tone = request.tone || request.data?.tone; 26 | const model = request.model || request.data?.model; 27 | 28 | // Check if data is actually present 29 | if (!apiKey || !tone || !model) { 30 | console.error("Translate command received, but missing apiKey, tone, or model.", request); 31 | alert("خطای داخلی: اطلاعات لازم برای ترجمه (کلید، لحن، مدل) یافت نشد."); 32 | sendResponse({ success: false, error: "Missing essential data in translatePage request." }); 33 | return false; 34 | } 35 | 36 | console.log(`translate.js received translatePage command. Tone: ${tone}, Model: ${model}`); 37 | isTranslating = true; 38 | // Show some visual feedback that translation has started 39 | showTranslationIndicator(true); 40 | 41 | // Clear previous translations before starting new ones 42 | clearPreviousTranslations(); 43 | 44 | let translationError = null; // Define error variable outside catch 45 | 46 | extractAndTranslate(apiKey, tone, model) 47 | .then(() => { 48 | console.log("Translation process completed successfully."); 49 | sendResponse({ success: true }); 50 | }) 51 | .catch(error => { // Catch the error here 52 | translationError = error; // Assign caught error to the outer variable 53 | console.error("Translation process failed:", error); 54 | // Check for specific error codes passed from background 55 | if (error.message.includes("RATE_LIMIT")) { // Check if background sent RATE_LIMIT code 56 | alert("تعداد درخواست‌های شما از حد مجاز Gemini فراتر رفته است. لطفاً بعداً دوباره تلاش کنید."); 57 | updateTranslationIndicator("محدودیت تعداد درخواست API"); 58 | } else { 59 | alert(`خطا در فرآیند ترجمه: ${error.message}`); 60 | updateTranslationIndicator(`خطا: ${error.message}`); 61 | } 62 | sendResponse({ success: false, error: error.message }); 63 | }) 64 | .finally(() => { 65 | isTranslating = false; 66 | // Hide visual feedback, checking the captured error 67 | if (!translationError?.message.includes("RATE_LIMIT")) { // Use translationError here 68 | showTranslationIndicator(false); 69 | } 70 | }); 71 | return true; // Indicate asynchronous response 72 | } else if (request.command === 'showApiKeyNeededError') { 73 | alert('لطفاً ابتدا کلید API Gemini خود را در تنظیمات افزونه وارد کنید.'); 74 | // Optionally try to open the popup 75 | // chrome.runtime.sendMessage({ command: 'openPopup' }); // Needs handling in background 76 | return false; 77 | } 78 | }); 79 | 80 | // --- Text Extraction and Processing --- 81 | async function extractAndTranslate(apiKey, tone, model) { 82 | console.log("Starting text extraction..."); 83 | const elementsToTranslate = []; 84 | let uniqueIdCounter = 0; 85 | 86 | // Select potential text-bearing elements, excluding those already processed or interactive elements 87 | const selectors = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, caption, dd, dt, figcaption, summary, td:not(:has(p, div, ul, ol, blockquote))'; // Selectors for common text blocks, exclude table cells that contain other blocks 88 | // Consider adding span if needed, but be careful as it can select too much 89 | const potentialElements = document.querySelectorAll(selectors); 90 | 91 | console.log(`Found ${potentialElements.length} potential elements.`); 92 | 93 | potentialElements.forEach(element => { 94 | // Basic filtering 95 | // Skip if inside problematic elements, already processed, or invisible 96 | if (element.closest(`.${TRANSLATION_CONTAINER_CLASS}, script, style, noscript, textarea, input, [contenteditable], code, pre, a`) || 97 | element.hasAttribute(PROCESSED_ATTR) || 98 | !isVisible(element)) { // Check if element is visible 99 | return; 100 | } 101 | 102 | // Get direct text content, cleaning whitespace and ignoring empty strings 103 | let directText = ''; 104 | for (const node of element.childNodes) { 105 | // Collect only direct text nodes of the element 106 | if (node.nodeType === Node.TEXT_NODE) { 107 | directText += node.textContent; 108 | } 109 | } 110 | directText = directText.replace(/\s+/g, ' ').trim(); 111 | 112 | // Check word count and if it looks like it might need translation (heuristic: contains letters) 113 | const wordCount = directText.split(/\s+/).filter(Boolean).length; 114 | // Additional check: Avoid translating text that is mostly numbers or symbols 115 | const hasEnoughLetters = (directText.match(/[a-zA-Z]/g) || []).length > directText.length / 4; 116 | 117 | if (wordCount >= MIN_WORDS_TO_TRANSLATE && hasEnoughLetters) { 118 | 119 | // Ensure element has a unique ID for mapping back results 120 | let elementId = element.id; 121 | if (!elementId) { 122 | elementId = `gemini-translate-id-${uniqueIdCounter++}`; 123 | element.id = elementId; 124 | } 125 | 126 | elementsToTranslate.push({ 127 | id: elementId, 128 | text: directText, 129 | originalElement: element // Keep reference for insertion 130 | }); 131 | 132 | // Mark as potentially processed to avoid adding duplicates in this run 133 | // element.setAttribute(PROCESSED_ATTR, 'pending'); // Can skip this if race condition unlikely 134 | } 135 | }); 136 | 137 | // Remove the temporary 'pending' marker if used 138 | // document.querySelectorAll(`[${PROCESSED_ATTR}="pending"]`).forEach(el => el.removeAttribute(PROCESSED_ATTR)); 139 | 140 | console.log(`Found ${elementsToTranslate.length} text segments to translate.`); 141 | 142 | if (elementsToTranslate.length === 0) { 143 | // Use the indicator to show the message 144 | updateTranslationIndicator("متنی برای ترجمه در این صفحه پیدا نشد."); 145 | setTimeout(() => showTranslationIndicator(false), 3000); // Hide after 3s 146 | // We throw an error here to stop the process cleanly in the caller 147 | throw new Error("No text found to translate."); 148 | } 149 | 150 | // --- Send to Background for Translation --- 151 | // Stage 1: Sending Request 152 | updateTranslationIndicator(`در حال ترجمه صفحه: ارسال درخواست به Gemini...`); 153 | try { 154 | console.log("Sending texts to background script..."); 155 | const response = await chrome.runtime.sendMessage({ 156 | command: 'callGeminiTranslate', 157 | texts: elementsToTranslate.map(el => ({ id: el.id, text: el.text })), // Send only id and text 158 | apiKey: apiKey, 159 | tone: tone, 160 | model: model // Pass model to background 161 | }); 162 | 163 | // Stage 2: Received Response 164 | updateTranslationIndicator(`در حال ترجمه صفحه: دریافت پاسخ...`); 165 | console.log("Received response from background:", response); 166 | 167 | if (response?.success && response.translations) { 168 | // Pass the total count to displayTranslations for progress update 169 | const totalSent = elementsToTranslate.length; 170 | displayTranslations(elementsToTranslate, response.translations, totalSent); 171 | // Stage 5: Completion message 172 | updateTranslationIndicator(`ترجمه صفحه انجام شد (${response.translations.filter(t=>t.translation).length} بخش).`); 173 | setTimeout(() => showTranslationIndicator(false), 2500); // Hide after success message 174 | } else { 175 | // Throw error with message/code from background response 176 | const errorMessage = response?.error || "ترجمه از اسکریپت پس‌زمینه دریافت نشد."; 177 | const errorCode = response?.errorCode; // Pass error code if present 178 | const error = new Error(errorMessage); 179 | if (errorCode) error.code = errorCode; // Attach code to error object 180 | throw error; 181 | } 182 | } catch (error) { // Catch errors from sendMessage or translation process 183 | console.error("Error during message passing or translation:", error); 184 | // Show error in indicator 185 | updateTranslationIndicator(`خطا: ${error.message}`); 186 | // Don't auto-hide indicator on error 187 | // Re-throw to be caught by the initial caller if needed, but alert is already shown there. 188 | throw error; 189 | } 190 | } 191 | 192 | // --- Displaying Translations --- 193 | function displayTranslations(originalElementsMap, translations, totalSent) { 194 | console.log(`Displaying ${translations.length} translations.`); 195 | const elementsById = new Map(originalElementsMap.map(el => [el.id, el.originalElement])); 196 | let displayedCount = 0; 197 | 198 | // --- Batch DOM operations: Step 1: Prepare elements to insert --- 199 | const insertions = []; // Array to hold [originalElement, translationContainer] pairs 200 | 201 | translations.forEach((result, index) => { 202 | const originalElement = elementsById.get(result.id); 203 | // Ensure we have an element, a translation, and it hasn't been processed somehow else 204 | if (originalElement && result.translation && !originalElement.hasAttribute(PROCESSED_ATTR)) { 205 | 206 | // Double-check if a translation container already exists (belt-and-suspenders) 207 | if (originalElement.nextElementSibling?.classList.contains(TRANSLATION_CONTAINER_CLASS)) { 208 | console.log(`Skipping already displayed translation for: ${result.id}`); 209 | return; 210 | } 211 | 212 | // Create the container and text elements 213 | const translationContainer = document.createElement('div'); 214 | translationContainer.className = TRANSLATION_CONTAINER_CLASS; 215 | translationContainer.setAttribute('lang', 'fa'); 216 | translationContainer.style.cssText = ` 217 | border-top: 1px dashed #dee2e6; 218 | margin-top: 0.5em; 219 | padding-top: 0.4em; 220 | margin-bottom: 0.5em; 221 | font-size: 0.9em; 222 | color: #555; 223 | direction: rtl; 224 | text-align: right; 225 | font-family: var(--dynamic-rtl-font-family, 'Vazirmatn', Arial, sans-serif); 226 | line-height: 1.6; 227 | `; 228 | 229 | const translationText = document.createElement('span'); 230 | translationText.className = TRANSLATION_TEXT_CLASS; 231 | translationText.textContent = result.translation; 232 | translationContainer.appendChild(translationText); 233 | 234 | // Add to the list for batch insertion later 235 | insertions.push({ original: originalElement, container: translationContainer }); 236 | 237 | // Mark the original element as translated *now* to prevent issues if processed again quickly 238 | originalElement.setAttribute(PROCESSED_ATTR, 'true'); 239 | displayedCount++; 240 | 241 | // --- Update Progress Indicator --- 242 | // Stage 3: Processing each translation 243 | updateTranslationIndicator(`در حال ترجمه صفحه: پردازش ترجمه (${index + 1}/${totalSent})...`); 244 | 245 | } else if (!originalElement) { 246 | console.warn(`Original element not found for ID: ${result.id}`); 247 | } else if (!result.translation) { 248 | console.warn(`Empty translation received for ID: ${result.id}`); 249 | if (originalElement) originalElement.setAttribute(PROCESSED_ATTR, 'true'); 250 | } 251 | }); 252 | 253 | // --- Batch DOM operations: Step 2: Insert all prepared elements --- 254 | console.log(`Batch inserting ${insertions.length} translation containers...`); 255 | // Stage 4: Inserting into page 256 | updateTranslationIndicator(`در حال ترجمه صفحه: درج ترجمه‌ها (${insertions.length})...`); 257 | 258 | // Use requestAnimationFrame to potentially make insertion smoother? 259 | requestAnimationFrame(() => { 260 | insertions.forEach(item => { 261 | // Check if parent still exists before inserting 262 | if (item.original.parentNode) { 263 | item.original.parentNode.insertBefore(item.container, item.original.nextSibling); 264 | } else { 265 | console.warn("Original element's parent node disappeared before insertion:", item.original); 266 | } 267 | }); 268 | console.log(`Finished batch inserting ${insertions.length} containers.`); 269 | // Final update message handled in the calling function `extractAndTranslate` 270 | }); 271 | 272 | console.log(`Prepared ${displayedCount} new translations for insertion.`); // Log count prepared 273 | } 274 | 275 | // --- Utility Functions --- 276 | function clearPreviousTranslations() { 277 | console.log("Clearing previous translations..."); 278 | const containers = document.querySelectorAll(`.${TRANSLATION_CONTAINER_CLASS}`); 279 | containers.forEach(container => container.remove()); 280 | const processedElements = document.querySelectorAll(`[${PROCESSED_ATTR}]`); 281 | processedElements.forEach(el => { 282 | el.removeAttribute(PROCESSED_ATTR); 283 | // Remove generated IDs? 284 | if (el.id.startsWith('gemini-translate-id-')) { 285 | // el.removeAttribute('id'); // Removing ID might break things if site relies on it later 286 | } 287 | }); 288 | } 289 | 290 | // Check if an element is visible (basic check) 291 | function isVisible(elem) { 292 | if (!(elem instanceof Element)) return false; 293 | return !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length); 294 | } 295 | 296 | // --- Visual Indicator --- 297 | let indicatorDiv = null; 298 | function showTranslationIndicator(show, message = "در حال ترجمه...") { 299 | if (show) { 300 | if (!indicatorDiv) { 301 | indicatorDiv = document.createElement('div'); 302 | indicatorDiv.id = 'gemini-translation-indicator'; 303 | // Apply styles for visibility, positioning, etc. 304 | Object.assign(indicatorDiv.style, { 305 | position: 'fixed', 306 | bottom: '10px', 307 | left: '10px', 308 | backgroundColor: 'rgba(0, 0, 0, 0.7)', 309 | color: 'white', 310 | padding: '8px 15px', 311 | borderRadius: '5px', 312 | zIndex: '999999', 313 | fontSize: '13px', 314 | fontFamily: 'Vazirmatn, sans-serif', // Ensure Vazirmatn for indicator 315 | direction: 'rtl', // Right-to-left text 316 | textAlign: 'right', 317 | boxShadow: '0 2px 5px rgba(0,0,0,0.2)' 318 | }); 319 | document.body.appendChild(indicatorDiv); 320 | } 321 | indicatorDiv.textContent = message; 322 | indicatorDiv.style.display = 'block'; 323 | } else { 324 | if (indicatorDiv) { 325 | indicatorDiv.style.display = 'none'; 326 | } 327 | } 328 | } 329 | 330 | function updateTranslationIndicator(message) { 331 | if (indicatorDiv) { 332 | indicatorDiv.textContent = message; 333 | // Ensure it's visible if we update it 334 | if (indicatorDiv.style.display === 'none') { 335 | indicatorDiv.style.display = 'block'; 336 | } 337 | } 338 | } 339 | 340 | console.log("Dynamic RTL Translator content script (translate.js) loaded."); --------------------------------------------------------------------------------