Upload 23 files
#1
by maxza258 - opened
- 16.png +0 -0
- 32.png +0 -0
- 48.png +1158 -0
- Dockerfile +17 -17
- README (1).md +40 -0
- README.md +2263 -31
- __init__.py +103 -0
- ai_prompt.js +1074 -0
- background-service.js +356 -0
- content-service.js +64 -0
- defaults_api.json +709 -0
- local_gallery.js +16 -0
- logger.js +110 -0
- manifest.json +2321 -0
- popup.html +276 -0
- popup.js +159 -0
- prompt.js +108 -0
- requirements.txt +31 -7
- server.py +17 -0
- settings_defaults.js +44 -0
- url.js +40 -0
- viewer.css +17 -0
- viewer.html +24 -0
16.png
ADDED
|
32.png
ADDED
|
48.png
ADDED
|
Dockerfile
CHANGED
|
@@ -1,17 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"defaultApiUrl": "https://plan291037-textphantom-ocr-api2.hf.space",
|
| 3 |
+
"resetApiUrl": "https://plan291037-textphantom-ocr-api2.hf.space",
|
| 4 |
+
"AI_PROMPT_SYSTEM_BASE": "You are a professional manga translator and dialogue localizer.\nRewrite each paragraph as natural dialogue in the target language while preserving meaning, tone, intent, and character voice.\nKeep lines concise for speech bubbles. Do not add new information. Do not omit meaning. Do not explain.\nPreserve emphasis (… ! ?). Avoid excessive punctuation.\nIf the input is already in the target language, improve it (dialogue polish) without changing meaning.",
|
| 5 |
+
"AI_LANG_STYLE": {
|
| 6 |
+
"th": "Target language: Thai\\nWrite natural Thai manga/anime dialogue: short, punchy, in-character.\\nKeep lines concise for speech bubbles. Preserve emotion and emphasis (… ! ?).\\nOutput only Thai text. No explanations.\\n\\nZero-subject / Zero-pronoun (VERY IMPORTANT)\\n- Default: omit subjects/objects when clear. Thai can drop pronouns.\\n- Do NOT automatically translate English pronouns.\\n- Avoid \\\"ฉัน/ชั้น\\\" and \\\"นาย\\\" as much as possible.\\n- If first-person is truly required (identity contrast, ambiguous multi-speaker, or to avoid misunderstanding): prefer \\\"เรา\\\". Use \\\"ข้า\\\" only when fantasy/archaic tone is clearly indicated. Do not guess gender.\\n- If second-person is truly required: prefer name/role/title. Otherwise use tone-matching neutral/casual (e.g., \\\"แก\\\", \\\"เธอ\\\", \\\"คุณ\\\", \\\"พวกแก\\\", \\\"พวกเจ้า\\\") and keep consistent. Never use \\\"นาย\\\".\\n\\nEnglish -> Thai patterns (drop pronouns)\\n- \\\"I + verb\\\" -> drop subject: just the verb/phrase.\\n- \\\"I'm / I am + adj\\\" -> \\\"...อยู่ / ...นี่ / ...แล้ว\\\" (pick by tone).\\n- \\\"I will\\\" -> \\\"จะ...\\\"\\n- \\\"I can't\\\" -> \\\"ทำไม่ได้/ไม่ไหว\\\"\\n- \\\"I need to\\\" -> \\\"ต้อง...\\\"\\n- \\\"I want to\\\" -> \\\"อยาก...\\\"\\n- \\\"my + noun\\\" -> avoid \\\"ของฉัน\\\"; prefer dropping possession or using context (\\\"...ของ\\\" only when necessary).\\n- \\\"you + verb\\\" -> drop \\\"you\\\"; use direct imperative/statement.\\n\\nFinal rewrite check\\n- Before output, rewrite once to remove unnecessary \\\"ฉัน/ชั้น\\\" and \\\"นาย\\\". Keep them only if meaning becomes unclear without them.",
|
| 7 |
+
"en": "Target language: English\nWrite natural English manga dialogue: concise, conversational, with contractions where natural.\nLocalize tone and character voice; keep emotion and emphasis.\nKeep proper nouns consistent; do not over-explain.",
|
| 8 |
+
"ja": "Target language: Japanese\nWrite natural Japanese manga dialogue: concise, spoken.\nChoose 丁寧語/タメ口 to match context; keep emotion and emphasis.\nKeep proper nouns consistent; keep SFX natural in Japanese.",
|
| 9 |
+
"default": "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
|
| 10 |
+
},
|
| 11 |
+
"AI_PROMPT_USER_BY_LANG": {
|
| 12 |
+
"th": "แนวทางแปลภาษาไทย\\n\\nเป้าหมาย\\n- แปลให้เป็นบทพูด/คำบรรยายแบบนักแปลอนิเมะ-มังงะมืออาชีพ: ธรรมชาติ กระชับ เข้าปาก เข้าคาแรกเตอร์\\n- รักษาความหมาย โทน อารมณ์ และจังหวะคำเดิม\\n- ห้ามอธิบาย ห้ามขยายความ ห้ามเติมข้อมูล\\n\\nกฎสำคัญที่สุด: ตัดสรรพนามเป็นค่าเริ่มต้น (Zero-subject)\\n- ภาษาไทยไม่จำเป็นต้องมีประธาน/กรรมเสมอไป ให้ตัดได้ถ้าเข้าใจ\\n- ห้ามแปล \\\"I/me/my\\\" เป็น \\\"ฉัน/ชั้น/ผม/ดิฉัน\\\" อัตโนมัติ\\n- หลีกเลี่ยง \\\"ฉัน/ชั้น\\\" และ \\\"นาย\\\" ให้มากที่สุด\\n- ถ้าจำเป็นต้องมีบุรุษที่ 1 จริง ๆ: ใช้ \\\"เรา\\\" เป็นตัวเลือกแรก และคงให้สม่ำเสมอ\\n- ใช้ \\\"ข้า\\\" เฉพาะเมื่อโทนแฟนตาซี/โบราณชัดเจนเท่านั้น\\n- ถ้าจำเ��็นต้องมีบุรุษที่ 2: ใช้ชื่อ/ตำแหน่งก่อน (อย่าเติมสรรพนามตามหลังชื่อ) ถ้าต้องใช้จริง ๆ ให้เลือกคำกลาง/กันเองตามโทน (\\\"แก/เธอ/คุณ/พวกแก/พวกเจ้า\\\") และคงให้สม่ำเสมอ\\n- ห้ามเดาเพศผู้พูด/ผู้ถูกพูดถึง\\n\\nแพทเทิร์นแปลงจากอังกฤษ (ห้ามยัด \\\"ฉัน\\\" เพราะมี I)\\n- I think... -> \\\"ว่า...\\\" / \\\"คิดว่า...\\\" (ตัด I ได้)\\n- I’m / I am + adj -> \\\"...อยู่\\\" / \\\"...นี่\\\" / \\\"...แล้ว\\\"\\n- I will... -> \\\"จะ...\\\"\\n- I can’t... -> \\\"ทำไม่ได้\\\" / \\\"ไม่ไหว\\\"\\n- I need to... -> \\\"ต้อง...\\\"\\n- I want to... -> \\\"อยาก...\\\"\\n- It’s my... / my + noun -> ตัดความเป็นเจ้าของถ้าไม่จำเป็น หลีกเลี่ยง \\\"ของฉัน\\\"\\n- You + verb -> ตัด you แล้วใช้ประโยคสั่ง/บอกตรง ๆ (เช่น \\\"ถอยไปซะ\\\")\\n\\nระดับภาษา & โทน\\n- ไม่เน้นทางการ: เลี่ยง \\\"ครับ/ค่ะ\\\" และคำสุภาพจัด ๆ เว้นแต่ต้นฉบับบังคับชัดเจน\\n- เลี่ยงคำลงท้ายที่บอกเพศโดยไม่จำเป็น\\n- ใช้คำอุทาน/คำลงท้ายเท่าที่ช่วยโทน ไม่พร่ำ\\n\\nประเภทข้อความ\\n- บทพูด: ภาษาพูดไทยธรรมชาติ สั้น ชัด\\n- คำบรรยาย/ความคิด: ลื่นไหล แต่ยังสั้น\\n- ป้าย/สถานะ/UI: สั้น ชัด จัดรูปแบบเป็นรายการได้\\n\\nกรณีข้อความเพี้ยน/พิมพ์ผิด (เช่นจาก OCR)\\n- แก้เฉพาะที่มั่นใจสูงและชัด (ผิด 1–2 ตัวและเดาได้แน่)\\n- ไม่เดาคำใหม่ ไม่แก้ชื่อเฉพาะ/คำทับศัพท์มั่ว\\n\\nตรวจรอบสุดท้ายก่อนส่ง\\n- เขียนใหม่ 1 รอบเพื่อตัด \\\"ฉัน/ชั้น\\\" และ \\\"นาย\\\" ที่ไม่จำเป็น\\n- ตอบเฉพาะข้อความแปลไทย\\n- รักษาการขึ้นบรรทัด/ย่อหน้าเดิม และเรียงตามต้นฉบับ",
|
| 13 |
+
"en": "Style preferences:\n- Keep English dialogue concise and conversational.\n- Keep lines short for speech bubbles.\n- Keep names and recurring terms consistent.\n- Keep SFX short; avoid very long repeated characters.",
|
| 14 |
+
"ja": "Style preferences:\n- Keep Japanese dialogue concise and natural for manga.\n- Keep lines short for speech bubbles.\n- Keep names and recurring terms consistent.\n- Keep SFX short; avoid very long repeated characters.",
|
| 15 |
+
"default": "Style preferences:\n- Keep dialogue concise, spoken, and faithful to tone.\n- Keep lines short for speech bubbles.\n- Keep names and recurring terms consistent.\n- Keep SFX short; avoid very long repeated characters."
|
| 16 |
+
}
|
| 17 |
+
}
|
README (1).md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"manifest_version": 3,
|
| 3 |
+
"name": "TextPhantom - OCR Overlay",
|
| 4 |
+
"version": "2026.03.15",
|
| 5 |
+
"description": "🧠 Right-click any image → translate images",
|
| 6 |
+
"permissions": [
|
| 7 |
+
"contextMenus",
|
| 8 |
+
"storage",
|
| 9 |
+
"activeTab"
|
| 10 |
+
],
|
| 11 |
+
"host_permissions": [
|
| 12 |
+
"<all_urls>"
|
| 13 |
+
],
|
| 14 |
+
"background": {
|
| 15 |
+
"service_worker": "background-service.js",
|
| 16 |
+
"type": "module"
|
| 17 |
+
},
|
| 18 |
+
"action": {
|
| 19 |
+
"default_popup": "popup/popup.html",
|
| 20 |
+
"default_icon": {
|
| 21 |
+
"48": "icons/48.png"
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
"content_scripts": [
|
| 25 |
+
{
|
| 26 |
+
"matches": [
|
| 27 |
+
"<all_urls>"
|
| 28 |
+
],
|
| 29 |
+
"js": [
|
| 30 |
+
"content-service.js"
|
| 31 |
+
],
|
| 32 |
+
"run_at": "document_start",
|
| 33 |
+
"all_frames": true
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"icons": {
|
| 37 |
+
"48": "icons/48.png",
|
| 38 |
+
"128": "icons/128.png"
|
| 39 |
+
}
|
| 40 |
+
}
|
README.md
CHANGED
|
@@ -1,31 +1,2263 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 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 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
if (globalThis.__TextPhantomContentLoaded) return;
|
| 3 |
+
globalThis.__TextPhantomContentLoaded = true;
|
| 4 |
+
|
| 5 |
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
| 6 |
+
const CURRENT_LEVEL_NAME = "debug";
|
| 7 |
+
const CURRENT_LEVEL = LEVELS[CURRENT_LEVEL_NAME] ?? 0;
|
| 8 |
+
|
| 9 |
+
function formatTimestamp() {
|
| 10 |
+
return new Date().toISOString();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function createLogger(namespace) {
|
| 14 |
+
function _log(level, ...args) {
|
| 15 |
+
if (LEVELS[level] >= CURRENT_LEVEL) {
|
| 16 |
+
const prefix = `[${formatTimestamp()}][${namespace}][${level.toUpperCase()}]`;
|
| 17 |
+
if (level === "debug") console.debug(prefix, ...args);
|
| 18 |
+
else if (level === "info") console.info(prefix, ...args);
|
| 19 |
+
else if (level === "warn") console.warn(prefix, ...args);
|
| 20 |
+
else if (level === "error") console.error(prefix, ...args);
|
| 21 |
+
else console.log(prefix, ...args);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
const logger = (...a) => _log("info", ...a);
|
| 25 |
+
logger.debug = (...a) => _log("debug", ...a);
|
| 26 |
+
logger.info = (...a) => _log("info", ...a);
|
| 27 |
+
logger.warn = (...a) => _log("warn", ...a);
|
| 28 |
+
logger.error = (...a) => _log("error", ...a);
|
| 29 |
+
return logger;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
window.createLogger = window.createLogger || createLogger;
|
| 33 |
+
|
| 34 |
+
const log = window.createLogger
|
| 35 |
+
? window.createLogger("content_bootstrap")
|
| 36 |
+
: console;
|
| 37 |
+
|
| 38 |
+
const KEEPALIVE_PORT_NAME = "TP_KEEPALIVE";
|
| 39 |
+
const KEEPALIVE_PING_MS = 20_000;
|
| 40 |
+
let keepAlivePort = null;
|
| 41 |
+
let keepAliveTimer = null;
|
| 42 |
+
let keepAliveStopAt = 0;
|
| 43 |
+
|
| 44 |
+
function stopKeepAlive() {
|
| 45 |
+
keepAliveStopAt = 0;
|
| 46 |
+
if (keepAliveTimer) {
|
| 47 |
+
clearInterval(keepAliveTimer);
|
| 48 |
+
keepAliveTimer = null;
|
| 49 |
+
}
|
| 50 |
+
if (keepAlivePort) {
|
| 51 |
+
try {
|
| 52 |
+
keepAlivePort.disconnect();
|
| 53 |
+
} catch {}
|
| 54 |
+
keepAlivePort = null;
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function startKeepAlive(ms) {
|
| 59 |
+
const dur = Number(ms) > 0 ? Number(ms) : 10 * 60 * 1000;
|
| 60 |
+
keepAliveStopAt = Math.max(keepAliveStopAt || 0, Date.now() + dur);
|
| 61 |
+
if (!keepAlivePort) {
|
| 62 |
+
try {
|
| 63 |
+
keepAlivePort = chrome.runtime.connect({ name: KEEPALIVE_PORT_NAME });
|
| 64 |
+
keepAlivePort.onDisconnect.addListener(() => stopKeepAlive());
|
| 65 |
+
} catch {
|
| 66 |
+
stopKeepAlive();
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
const ping = () => {
|
| 71 |
+
if (!keepAlivePort) return;
|
| 72 |
+
if (keepAliveStopAt && Date.now() >= keepAliveStopAt) {
|
| 73 |
+
stopKeepAlive();
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
try {
|
| 77 |
+
keepAlivePort.postMessage({ type: "TP_KEEPALIVE", ts: Date.now() });
|
| 78 |
+
} catch {
|
| 79 |
+
stopKeepAlive();
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
ping();
|
| 83 |
+
if (!keepAliveTimer) keepAliveTimer = setInterval(ping, KEEPALIVE_PING_MS);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const __tpVer = (() => {
|
| 87 |
+
try {
|
| 88 |
+
return (chrome?.runtime?.getManifest?.() || {}).version || "";
|
| 89 |
+
} catch {
|
| 90 |
+
return "";
|
| 91 |
+
}
|
| 92 |
+
})();
|
| 93 |
+
const __tpIsTop = (() => {
|
| 94 |
+
try {
|
| 95 |
+
return window.top === window;
|
| 96 |
+
} catch {
|
| 97 |
+
return false;
|
| 98 |
+
}
|
| 99 |
+
})();
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
chrome.runtime.sendMessage(
|
| 103 |
+
{
|
| 104 |
+
type: "TP_CONTENT_READY",
|
| 105 |
+
href: location.href,
|
| 106 |
+
ver: __tpVer,
|
| 107 |
+
top: __tpIsTop,
|
| 108 |
+
},
|
| 109 |
+
() => void chrome.runtime.lastError,
|
| 110 |
+
);
|
| 111 |
+
} catch {}
|
| 112 |
+
|
| 113 |
+
function setupLocationChangeNotifier() {
|
| 114 |
+
if (!__tpIsTop) return;
|
| 115 |
+
if (globalThis.__tpLocationNotifyInstalled) return;
|
| 116 |
+
globalThis.__tpLocationNotifyInstalled = true;
|
| 117 |
+
let lastHref = location.href;
|
| 118 |
+
let canSend = true;
|
| 119 |
+
const send = () => {
|
| 120 |
+
if (!canSend) return;
|
| 121 |
+
const href = location.href;
|
| 122 |
+
if (href === lastHref) return;
|
| 123 |
+
lastHref = href;
|
| 124 |
+
try {
|
| 125 |
+
chrome.runtime.sendMessage(
|
| 126 |
+
{ type: "TP_LOCATION_CHANGED", href, top: true, ver: __tpVer },
|
| 127 |
+
() => void chrome.runtime.lastError,
|
| 128 |
+
);
|
| 129 |
+
} catch {
|
| 130 |
+
canSend = false;
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
const wrap = (name) => {
|
| 134 |
+
const orig = history[name];
|
| 135 |
+
if (typeof orig !== "function") return;
|
| 136 |
+
history[name] = function (...args) {
|
| 137 |
+
const r = orig.apply(this, args);
|
| 138 |
+
Promise.resolve().then(send);
|
| 139 |
+
return r;
|
| 140 |
+
};
|
| 141 |
+
};
|
| 142 |
+
wrap("pushState");
|
| 143 |
+
wrap("replaceState");
|
| 144 |
+
addEventListener("popstate", send, { passive: true });
|
| 145 |
+
addEventListener("hashchange", send, { passive: true });
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
setupLocationChangeNotifier();
|
| 149 |
+
|
| 150 |
+
try {
|
| 151 |
+
console.info("[TextPhantom][content] loaded", {
|
| 152 |
+
href: location.href,
|
| 153 |
+
ver: __tpVer,
|
| 154 |
+
top: __tpIsTop,
|
| 155 |
+
});
|
| 156 |
+
} catch {}
|
| 157 |
+
|
| 158 |
+
function truncate(s, len = 180) {
|
| 159 |
+
if (!s) return s;
|
| 160 |
+
try {
|
| 161 |
+
s = String(s);
|
| 162 |
+
} catch {}
|
| 163 |
+
return s.length > len ? s.slice(0, len) + "…" : s;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const replaceStateByOriginal = new Map();
|
| 167 |
+
|
| 168 |
+
function noteReplaceState(original, state) {
|
| 169 |
+
const key = _normUrl(original);
|
| 170 |
+
if (!key) return;
|
| 171 |
+
replaceStateByOriginal.set(key, { state, ts: Date.now() });
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function shouldShowReplaceError(original) {
|
| 175 |
+
const key = _normUrl(original);
|
| 176 |
+
if (!key) return true;
|
| 177 |
+
const s = replaceStateByOriginal.get(key);
|
| 178 |
+
if (!s) return true;
|
| 179 |
+
if (s.state === "ok") return false;
|
| 180 |
+
if (s.state === "pending" && Date.now() - s.ts < 12000) return false;
|
| 181 |
+
return true;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function markImageError(original, msg) {
|
| 185 |
+
if (!shouldShowReplaceError(original)) {
|
| 186 |
+
log.debug("markImageError skipped (replace ok/pending)", {
|
| 187 |
+
original: truncate(original),
|
| 188 |
+
});
|
| 189 |
+
return;
|
| 190 |
+
}
|
| 191 |
+
const img = findTargetImage(original);
|
| 192 |
+
if (!img || img.dataset.lensError) {
|
| 193 |
+
log.info("markImageError", {
|
| 194 |
+
count: 0,
|
| 195 |
+
original: truncate(original),
|
| 196 |
+
message: msg,
|
| 197 |
+
});
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const alreadyReplaced =
|
| 202 |
+
!!img.dataset.tpBlobUrl ||
|
| 203 |
+
(img.currentSrc || img.src || "").startsWith("blob:") ||
|
| 204 |
+
(img.currentSrc || img.src || "").startsWith("data:");
|
| 205 |
+
if (alreadyReplaced) {
|
| 206 |
+
log.debug("markImageError skipped (image replaced)", {
|
| 207 |
+
original: truncate(original),
|
| 208 |
+
});
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
img.style.outline = "3px solid red";
|
| 213 |
+
|
| 214 |
+
const badge = document.createElement("div");
|
| 215 |
+
badge.textContent = "⚠️";
|
| 216 |
+
badge.title = msg || "OCR error";
|
| 217 |
+
|
| 218 |
+
const r = img.getBoundingClientRect();
|
| 219 |
+
badge.style.position = "absolute";
|
| 220 |
+
badge.style.left = `${r.left + window.scrollX + 4}px`;
|
| 221 |
+
badge.style.top = `${r.top + window.scrollY + 4}px`;
|
| 222 |
+
badge.style.background = "rgba(255,255,255,0.9)";
|
| 223 |
+
badge.style.padding = "2px 4px";
|
| 224 |
+
badge.style.borderRadius = "4px";
|
| 225 |
+
badge.style.zIndex = 9999;
|
| 226 |
+
|
| 227 |
+
document.body.appendChild(badge);
|
| 228 |
+
img.dataset.lensError = "1";
|
| 229 |
+
log.info("markImageError", {
|
| 230 |
+
count: 1,
|
| 231 |
+
original: truncate(original),
|
| 232 |
+
message: msg,
|
| 233 |
+
});
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
async function getSettings() {
|
| 237 |
+
return new Promise((res) => {
|
| 238 |
+
chrome.storage.local.get(
|
| 239 |
+
["mode", "lang", "sources", "aiKey"],
|
| 240 |
+
(items) => {
|
| 241 |
+
res({
|
| 242 |
+
mode: typeof items.mode === "string" ? items.mode : "lens_images",
|
| 243 |
+
lang: typeof items.lang === "string" ? items.lang : "th",
|
| 244 |
+
sources:
|
| 245 |
+
typeof items.sources === "string" ? items.sources : "translated",
|
| 246 |
+
aiKey: typeof items.aiKey === "string" ? items.aiKey : "",
|
| 247 |
+
});
|
| 248 |
+
},
|
| 249 |
+
);
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
function removeLazyScriptsAndForceSrc() {
|
| 254 |
+
const lazyScripts = document.querySelectorAll('script[src*="lazy"]');
|
| 255 |
+
lazyScripts.forEach((script) => script.remove());
|
| 256 |
+
|
| 257 |
+
const lazyImgs = document.querySelectorAll("img[data-src]");
|
| 258 |
+
lazyImgs.forEach((img) => {
|
| 259 |
+
img.removeAttribute("loading");
|
| 260 |
+
img.classList.remove("lazyload", "lazy", "lazyloaded");
|
| 261 |
+
if (img.dataset.src) {
|
| 262 |
+
img.src = img.dataset.src;
|
| 263 |
+
}
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
log.info("lazy removed + src forced from data-src", {
|
| 267 |
+
lazyScripts: lazyScripts.length,
|
| 268 |
+
lazyImgs: lazyImgs.length,
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function buildPipelineEvent(stage) {
|
| 273 |
+
return { stage, at: new Date().toISOString() };
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function buildPositionFromElement(img) {
|
| 277 |
+
const rect = img.getBoundingClientRect();
|
| 278 |
+
return {
|
| 279 |
+
top: rect.top,
|
| 280 |
+
left: rect.left,
|
| 281 |
+
width: rect.width,
|
| 282 |
+
height: rect.height,
|
| 283 |
+
viewport_width: window.innerWidth,
|
| 284 |
+
viewport_height: window.innerHeight,
|
| 285 |
+
scroll_x: window.scrollX,
|
| 286 |
+
scroll_y: window.scrollY,
|
| 287 |
+
};
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function getBestImgUrl(img) {
|
| 291 |
+
const tp =
|
| 292 |
+
img?.dataset?.tpOriginal ||
|
| 293 |
+
(typeof img?.getAttribute === "function"
|
| 294 |
+
? img.getAttribute("data-tp-original")
|
| 295 |
+
: "");
|
| 296 |
+
if (tp && /^https?:/i.test(tp)) return tp;
|
| 297 |
+
return (
|
| 298 |
+
img.currentSrc ||
|
| 299 |
+
img.src ||
|
| 300 |
+
img.getAttribute("data-src") ||
|
| 301 |
+
img.getAttribute("data-original") ||
|
| 302 |
+
img.getAttribute("data-lazy-src") ||
|
| 303 |
+
""
|
| 304 |
+
);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function _normUrl(u) {
|
| 308 |
+
if (!u) return "";
|
| 309 |
+
try {
|
| 310 |
+
u = String(u);
|
| 311 |
+
} catch {
|
| 312 |
+
return "";
|
| 313 |
+
}
|
| 314 |
+
u = u.trim();
|
| 315 |
+
if (!u) return "";
|
| 316 |
+
try {
|
| 317 |
+
const x = new URL(u, location.href);
|
| 318 |
+
x.hash = "";
|
| 319 |
+
return x.toString();
|
| 320 |
+
} catch {
|
| 321 |
+
return u.split("#")[0].split("?")[0];
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
let __tp_lastRightClick = { img: null, ts: 0, urls: [] };
|
| 326 |
+
const __tp_recentImgByUrl = new Map();
|
| 327 |
+
function rememberImgUrls(img, urls) {
|
| 328 |
+
const ts = Date.now();
|
| 329 |
+
for (const u of urls || []) {
|
| 330 |
+
const k = _normUrl(u);
|
| 331 |
+
if (!k) continue;
|
| 332 |
+
__tp_recentImgByUrl.set(k, { img, ts });
|
| 333 |
+
}
|
| 334 |
+
if (__tp_recentImgByUrl.size > 200) {
|
| 335 |
+
const cutoff = ts - 5 * 60 * 1000;
|
| 336 |
+
for (const [k, v] of __tp_recentImgByUrl.entries()) {
|
| 337 |
+
if (!v?.img || !v.img.isConnected || (v.ts || 0) < cutoff)
|
| 338 |
+
__tp_recentImgByUrl.delete(k);
|
| 339 |
+
}
|
| 340 |
+
while (__tp_recentImgByUrl.size > 220)
|
| 341 |
+
__tp_recentImgByUrl.delete(__tp_recentImgByUrl.keys().next().value);
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
document.addEventListener(
|
| 345 |
+
"contextmenu",
|
| 346 |
+
(e) => {
|
| 347 |
+
const img = e?.target?.closest ? e.target.closest("img") : null;
|
| 348 |
+
if (!img) return;
|
| 349 |
+
const urls = [img.currentSrc, img.src, getBestImgUrl(img)]
|
| 350 |
+
.filter(Boolean)
|
| 351 |
+
.map(_normUrl)
|
| 352 |
+
.filter(Boolean);
|
| 353 |
+
if (urls[0] && img?.dataset) img.dataset.tpOriginal = urls[0];
|
| 354 |
+
__tp_lastRightClick = {
|
| 355 |
+
img,
|
| 356 |
+
ts: Date.now(),
|
| 357 |
+
urls,
|
| 358 |
+
};
|
| 359 |
+
rememberImgUrls(img, urls);
|
| 360 |
+
},
|
| 361 |
+
true,
|
| 362 |
+
);
|
| 363 |
+
|
| 364 |
+
function findTargetImage(original) {
|
| 365 |
+
const o = _normUrl(original);
|
| 366 |
+
|
| 367 |
+
const mdKey = mdKeyFromUrl(original);
|
| 368 |
+
if (mdKey) {
|
| 369 |
+
const byKey = Array.from(document.images || []).find(
|
| 370 |
+
(img) => String(img.dataset.tpOriginalKey || "") === mdKey,
|
| 371 |
+
);
|
| 372 |
+
if (byKey) return byKey;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
if (o) {
|
| 376 |
+
const byData = Array.from(document.images || []).find(
|
| 377 |
+
(img) => _normUrl(img.dataset.tpOriginal) === o,
|
| 378 |
+
);
|
| 379 |
+
if (byData) return byData;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
if (o) {
|
| 383 |
+
const rec = __tp_recentImgByUrl.get(o);
|
| 384 |
+
if (rec?.img && rec.img.isConnected) return rec.img;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
const now = Date.now();
|
| 388 |
+
if (__tp_lastRightClick.img && now - __tp_lastRightClick.ts < 8000) {
|
| 389 |
+
const urls = __tp_lastRightClick.urls || [];
|
| 390 |
+
if (!o || urls.includes(o)) return __tp_lastRightClick.img;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
const imgs = Array.from(document.images || []);
|
| 394 |
+
for (const img of imgs) {
|
| 395 |
+
const a = _normUrl(img.currentSrc);
|
| 396 |
+
const b = _normUrl(img.src);
|
| 397 |
+
if (o && (a === o || b === o)) return img;
|
| 398 |
+
}
|
| 399 |
+
return null;
|
| 400 |
+
}
|
| 401 |
+
function isHttpish(u) {
|
| 402 |
+
return typeof u === "string" && /^https?:/i.test(u);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
function isInlineableImageUrl(u) {
|
| 406 |
+
return typeof u === "string" && /^(?:data:|blob:|file:|chrome-extension:)/i.test(u);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
async function blobToDataUri(blob) {
|
| 410 |
+
return await new Promise((resolve) => {
|
| 411 |
+
try {
|
| 412 |
+
const reader = new FileReader();
|
| 413 |
+
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
| 414 |
+
reader.onerror = () => resolve("");
|
| 415 |
+
reader.readAsDataURL(blob);
|
| 416 |
+
} catch {
|
| 417 |
+
resolve("");
|
| 418 |
+
}
|
| 419 |
+
});
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
async function getImageDataUriFromElement(img) {
|
| 423 |
+
const src = _normUrl(getBestImgUrl(img));
|
| 424 |
+
if (!src) return "";
|
| 425 |
+
if (src.startsWith("data:")) return src;
|
| 426 |
+
|
| 427 |
+
try {
|
| 428 |
+
const w = Number(img?.naturalWidth) || Number(img?.width) || 0;
|
| 429 |
+
const h = Number(img?.naturalHeight) || Number(img?.height) || 0;
|
| 430 |
+
if (w > 0 && h > 0) {
|
| 431 |
+
const canvas = document.createElement("canvas");
|
| 432 |
+
canvas.width = w;
|
| 433 |
+
canvas.height = h;
|
| 434 |
+
const ctx = canvas.getContext("2d", { willReadFrequently: false });
|
| 435 |
+
if (ctx) {
|
| 436 |
+
ctx.drawImage(img, 0, 0, w, h);
|
| 437 |
+
const du = canvas.toDataURL("image/png");
|
| 438 |
+
if (du && du.startsWith("data:image/")) return du;
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
} catch {}
|
| 442 |
+
|
| 443 |
+
try {
|
| 444 |
+
const res = await fetch(src, { cache: "force-cache" });
|
| 445 |
+
const blob = await res.blob();
|
| 446 |
+
const du = await blobToDataUri(blob);
|
| 447 |
+
if (du) return du;
|
| 448 |
+
} catch {}
|
| 449 |
+
|
| 450 |
+
return "";
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
async function buildPayloadFromImage(
|
| 454 |
+
img,
|
| 455 |
+
mode,
|
| 456 |
+
lang,
|
| 457 |
+
menuSource = "page_scan",
|
| 458 |
+
customStage,
|
| 459 |
+
includeDataUri = false,
|
| 460 |
+
) {
|
| 461 |
+
const src = _normUrl(getBestImgUrl(img));
|
| 462 |
+
const imageDataUri = includeDataUri && isInlineableImageUrl(src)
|
| 463 |
+
? await getImageDataUriFromElement(img)
|
| 464 |
+
: "";
|
| 465 |
+
if (!src && !imageDataUri) return null;
|
| 466 |
+
return buildPayload(
|
| 467 |
+
{
|
| 468 |
+
original_image_url: src || null,
|
| 469 |
+
position: buildPositionFromElement(img),
|
| 470 |
+
imageDataUri: imageDataUri || null,
|
| 471 |
+
},
|
| 472 |
+
mode,
|
| 473 |
+
lang,
|
| 474 |
+
menuSource,
|
| 475 |
+
customStage,
|
| 476 |
+
);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function emitViewerEvent(type, detail) {
|
| 480 |
+
try {
|
| 481 |
+
window.dispatchEvent(
|
| 482 |
+
new CustomEvent(type, {
|
| 483 |
+
detail: detail && typeof detail === "object" ? detail : {},
|
| 484 |
+
}),
|
| 485 |
+
);
|
| 486 |
+
} catch {}
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
function normalizeLazyImages() {
|
| 490 |
+
document.querySelectorAll("img").forEach((img) => {
|
| 491 |
+
const candSet =
|
| 492 |
+
img.getAttribute("data-srcset") || img.getAttribute("data-lazy-srcset");
|
| 493 |
+
if (candSet && !img.srcset) img.srcset = candSet;
|
| 494 |
+
|
| 495 |
+
const candSrc =
|
| 496 |
+
img.getAttribute("data-original") || img.getAttribute("data-lazy-src");
|
| 497 |
+
if (candSrc && !img.src) img.src = candSrc;
|
| 498 |
+
|
| 499 |
+
img.loading = "eager";
|
| 500 |
+
img.decoding = "sync";
|
| 501 |
+
});
|
| 502 |
+
}
|
| 503 |
+
async function collectImagesForScan(mode, lang, sourceTag) {
|
| 504 |
+
const seen = new Set();
|
| 505 |
+
const imgs = Array.from(document.images || []);
|
| 506 |
+
const out = [];
|
| 507 |
+
for (const img of imgs) {
|
| 508 |
+
const r =
|
| 509 |
+
typeof img.getBoundingClientRect === "function"
|
| 510 |
+
? img.getBoundingClientRect()
|
| 511 |
+
: null;
|
| 512 |
+
const w = Math.max(Number(r?.width) || 0, Number(img.naturalWidth) || 0);
|
| 513 |
+
const h = Math.max(
|
| 514 |
+
Number(r?.height) || 0,
|
| 515 |
+
Number(img.naturalHeight) || 0,
|
| 516 |
+
);
|
| 517 |
+
if (w && h) {
|
| 518 |
+
if (Math.min(w, h) < 120) continue;
|
| 519 |
+
if (w * h < 60000) continue;
|
| 520 |
+
}
|
| 521 |
+
const payload = await buildPayloadFromImage(img, mode, lang, sourceTag);
|
| 522 |
+
const key = _normUrl(payload?.src) || String(payload?.metadata?.image_id || "");
|
| 523 |
+
if (!key || seen.has(key)) continue;
|
| 524 |
+
seen.add(key);
|
| 525 |
+
out.push(payload);
|
| 526 |
+
}
|
| 527 |
+
return out.filter(Boolean);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
const OVERLAY_STYLE_ID = "textphantom_overlay_css";
|
| 531 |
+
|
| 532 |
+
const sendBg = (msg) =>
|
| 533 |
+
new Promise((resolve) => {
|
| 534 |
+
try {
|
| 535 |
+
chrome.runtime.sendMessage(msg, (r) => resolve(r || null));
|
| 536 |
+
} catch (_e) {
|
| 537 |
+
resolve(null);
|
| 538 |
+
}
|
| 539 |
+
});
|
| 540 |
+
|
| 541 |
+
const mdCacheGet = (keys, includeNewImg = false, lang = "", mode = "") =>
|
| 542 |
+
sendBg({
|
| 543 |
+
type: "TP_MD_CACHE_GET",
|
| 544 |
+
keys,
|
| 545 |
+
includeNewImg: !!includeNewImg,
|
| 546 |
+
lang,
|
| 547 |
+
mode,
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
const mdCacheGetNewImg = async (key, lang, mode) => {
|
| 551 |
+
const resp = await mdCacheGet([key], true, lang, mode);
|
| 552 |
+
const rec = resp?.items?.[key] || null;
|
| 553 |
+
return (
|
| 554 |
+
rec?.newImg ||
|
| 555 |
+
extractNewImageSrc(rec?.result || null) ||
|
| 556 |
+
extractNewImageSrc(rec || null) ||
|
| 557 |
+
null
|
| 558 |
+
);
|
| 559 |
+
};
|
| 560 |
+
|
| 561 |
+
const mdApplyCachedNewImg = (
|
| 562 |
+
originalUrl,
|
| 563 |
+
key,
|
| 564 |
+
isTextMode,
|
| 565 |
+
imgElement,
|
| 566 |
+
lang,
|
| 567 |
+
mode,
|
| 568 |
+
) => {
|
| 569 |
+
mdCacheGetNewImg(key, lang, mode).then((newSrc) => {
|
| 570 |
+
if (!newSrc) return;
|
| 571 |
+
if (isTextMode) {
|
| 572 |
+
const mdKey = mdKeyFromUrl(originalUrl);
|
| 573 |
+
if (!mdKey) return;
|
| 574 |
+
const existing = mdHtmlOverlaysByKey.get(mdKey) || null;
|
| 575 |
+
const rec =
|
| 576 |
+
existing ||
|
| 577 |
+
(() => {
|
| 578 |
+
const rect = imgElement.getBoundingClientRect();
|
| 579 |
+
const r = upsertMangaDexHtmlOverlay(
|
| 580 |
+
mdKey,
|
| 581 |
+
imgElement,
|
| 582 |
+
Math.max(1, rect.width),
|
| 583 |
+
Math.max(1, rect.height),
|
| 584 |
+
"badge",
|
| 585 |
+
);
|
| 586 |
+
r.scope.textContent = "";
|
| 587 |
+
return r;
|
| 588 |
+
})();
|
| 589 |
+
rec.isTextMode = true;
|
| 590 |
+
updateCleanLayer(rec, imgElement, newSrc);
|
| 591 |
+
mdScheduleUpdate();
|
| 592 |
+
return;
|
| 593 |
+
}
|
| 594 |
+
replaceImageInDOM(originalUrl, newSrc).then((applied) => {
|
| 595 |
+
if (!applied)
|
| 596 |
+
mdRememberPending(originalUrl, {
|
| 597 |
+
needNewImg: true,
|
| 598 |
+
needMode: "replace",
|
| 599 |
+
needLang: lang,
|
| 600 |
+
needCacheMode: mode,
|
| 601 |
+
});
|
| 602 |
+
});
|
| 603 |
+
});
|
| 604 |
+
};
|
| 605 |
+
|
| 606 |
+
function ensureOverlayStyle(cssText) {
|
| 607 |
+
if (!cssText) cssText = "";
|
| 608 |
+
let styleEl = document.getElementById(OVERLAY_STYLE_ID);
|
| 609 |
+
if (!styleEl) {
|
| 610 |
+
styleEl = document.createElement("style");
|
| 611 |
+
styleEl.id = OVERLAY_STYLE_ID;
|
| 612 |
+
styleEl.type = "text/css";
|
| 613 |
+
document.head.appendChild(styleEl);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
const harden =
|
| 617 |
+
`\n/* TextPhantom harden */\n` +
|
| 618 |
+
`.tp-ol-root{position:absolute!important;left:0!important;top:0!important;pointer-events:none!important;z-index:2147483647!important;` +
|
| 619 |
+
`display:block!important;opacity:1!important;visibility:visible!important;overflow:visible!important;transform-origin:0 0!important;}` +
|
| 620 |
+
`.tp-ol-scope{position:absolute!important;left:0!important;top:0!important;pointer-events:none!important;` +
|
| 621 |
+
`display:block!important;opacity:1!important;visibility:visible!important;overflow:visible!important;}` +
|
| 622 |
+
`.tp-ol-scope *{box-sizing:border-box!important;pointer-events:none!important;}` +
|
| 623 |
+
`.tp-ol-container{position:relative!important;display:inline-block!important;line-height:0!important;overflow:visible!important;}`;
|
| 624 |
+
|
| 625 |
+
const next = String(cssText) + harden;
|
| 626 |
+
if (styleEl.textContent !== next) styleEl.textContent = next;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
function computeScale(imgElement, baseW, baseH, preferRect = false) {
|
| 630 |
+
const rect = imgElement.getBoundingClientRect();
|
| 631 |
+
const cw = preferRect
|
| 632 |
+
? rect.width || 1
|
| 633 |
+
: imgElement.offsetWidth ||
|
| 634 |
+
imgElement.clientWidth ||
|
| 635 |
+
imgElement.width ||
|
| 636 |
+
rect.width ||
|
| 637 |
+
1;
|
| 638 |
+
const ch = preferRect
|
| 639 |
+
? rect.height || 1
|
| 640 |
+
: imgElement.offsetHeight ||
|
| 641 |
+
imgElement.clientHeight ||
|
| 642 |
+
imgElement.height ||
|
| 643 |
+
rect.height ||
|
| 644 |
+
1;
|
| 645 |
+
const nw =
|
| 646 |
+
(Number.isFinite(baseW) && baseW > 0 ? baseW : imgElement.naturalWidth) ||
|
| 647 |
+
cw ||
|
| 648 |
+
1;
|
| 649 |
+
const nh =
|
| 650 |
+
(Number.isFinite(baseH) && baseH > 0
|
| 651 |
+
? baseH
|
| 652 |
+
: imgElement.naturalHeight) ||
|
| 653 |
+
ch ||
|
| 654 |
+
1;
|
| 655 |
+
const cs = getComputedStyle(imgElement);
|
| 656 |
+
const fit = cs.objectFit || "fill";
|
| 657 |
+
const transform = cs.transform || "none";
|
| 658 |
+
const transformOrigin = cs.transformOrigin || "0 0";
|
| 659 |
+
|
| 660 |
+
const parsePos = (v, pad) => {
|
| 661 |
+
if (!pad) return 0;
|
| 662 |
+
if (!v) return pad * 0.5;
|
| 663 |
+
const s = String(v).trim();
|
| 664 |
+
if (s.endsWith("%")) return (pad * parseFloat(s)) / 100;
|
| 665 |
+
if (s.endsWith("px")) return Math.min(pad, Math.max(0, parseFloat(s)));
|
| 666 |
+
const n = parseFloat(s);
|
| 667 |
+
return Number.isFinite(n) ? Math.min(pad, Math.max(0, n)) : pad * 0.5;
|
| 668 |
+
};
|
| 669 |
+
|
| 670 |
+
const posParts = (cs.objectPosition || "50% 50%").trim().split(/\s+/);
|
| 671 |
+
const posX = posParts[0];
|
| 672 |
+
const posY = posParts[1] || posParts[0];
|
| 673 |
+
|
| 674 |
+
let sx = cw / nw;
|
| 675 |
+
let sy = ch / nh;
|
| 676 |
+
let offX = 0;
|
| 677 |
+
let offY = 0;
|
| 678 |
+
|
| 679 |
+
if (fit !== "fill") {
|
| 680 |
+
let scale = 1;
|
| 681 |
+
if (fit === "contain") scale = Math.min(sx, sy);
|
| 682 |
+
else if (fit === "cover") scale = Math.max(sx, sy);
|
| 683 |
+
else if (fit === "scale-down") scale = Math.min(1, Math.min(sx, sy));
|
| 684 |
+
else if (fit === "none") scale = 1;
|
| 685 |
+
sx = scale;
|
| 686 |
+
sy = scale;
|
| 687 |
+
const drawnW = nw * scale;
|
| 688 |
+
const drawnH = nh * scale;
|
| 689 |
+
const padX = Math.max(0, cw - drawnW);
|
| 690 |
+
const padY = Math.max(0, ch - drawnH);
|
| 691 |
+
offX = parsePos(posX, padX);
|
| 692 |
+
offY = parsePos(posY, padY);
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
return {
|
| 696 |
+
rect,
|
| 697 |
+
cw,
|
| 698 |
+
ch,
|
| 699 |
+
nw,
|
| 700 |
+
nh,
|
| 701 |
+
sx: isFinite(sx) && sx > 0 ? sx : 1,
|
| 702 |
+
sy: isFinite(sy) && sy > 0 ? sy : 1,
|
| 703 |
+
offX,
|
| 704 |
+
offY,
|
| 705 |
+
transform,
|
| 706 |
+
transformOrigin,
|
| 707 |
+
};
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
function createOverlayBadge(label) {
|
| 711 |
+
const badge = document.createElement("div");
|
| 712 |
+
badge.textContent = label || "AI…";
|
| 713 |
+
badge.style.position = "absolute";
|
| 714 |
+
badge.style.left = "6px";
|
| 715 |
+
badge.style.top = "6px";
|
| 716 |
+
badge.style.padding = "4px 6px";
|
| 717 |
+
badge.style.borderRadius = "6px";
|
| 718 |
+
badge.style.background = "rgba(255,255,255,.75)";
|
| 719 |
+
badge.style.color = "rgba(20,20,20,.95)";
|
| 720 |
+
badge.style.fontFamily = "var(--tp-font,system-ui)";
|
| 721 |
+
badge.style.fontSize = "12px";
|
| 722 |
+
badge.style.lineHeight = "1.2";
|
| 723 |
+
badge.style.textShadow =
|
| 724 |
+
"0 0 2px rgba(255,255,255,.90),0 1px 1px rgba(0,0,0,.25)";
|
| 725 |
+
return badge;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
function extractNewImageSrc(result) {
|
| 729 |
+
return (
|
| 730 |
+
result?.imageDataUri ||
|
| 731 |
+
result?.image ||
|
| 732 |
+
result?.imageUrl ||
|
| 733 |
+
result?.image_url ||
|
| 734 |
+
result?.imageURL ||
|
| 735 |
+
null
|
| 736 |
+
);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
function ensureCleanLayer(rec) {
|
| 740 |
+
if (rec?.cleanImg && rec.cleanImg.isConnected) return rec.cleanImg;
|
| 741 |
+
if (!rec?.host) return null;
|
| 742 |
+
const img = document.createElement("img");
|
| 743 |
+
img.className = "tp-ol-clean-img";
|
| 744 |
+
img.decoding = "sync";
|
| 745 |
+
img.loading = "eager";
|
| 746 |
+
img.style.position = "absolute";
|
| 747 |
+
img.style.left = "0px";
|
| 748 |
+
img.style.top = "0px";
|
| 749 |
+
img.style.width = "100%";
|
| 750 |
+
img.style.height = "100%";
|
| 751 |
+
img.style.pointerEvents = "none";
|
| 752 |
+
img.style.maxWidth = "none";
|
| 753 |
+
img.style.maxHeight = "none";
|
| 754 |
+
img.style.objectFit = "contain";
|
| 755 |
+
img.style.objectPosition = "center center";
|
| 756 |
+
img.style.display = "none";
|
| 757 |
+
if (rec.host.firstChild) rec.host.insertBefore(img, rec.host.firstChild);
|
| 758 |
+
else rec.host.appendChild(img);
|
| 759 |
+
rec.cleanImg = img;
|
| 760 |
+
return img;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
function syncCleanLayerFit(cleanImg, imgElement) {
|
| 764 |
+
try {
|
| 765 |
+
const cs = getComputedStyle(imgElement);
|
| 766 |
+
if (cs?.objectFit) cleanImg.style.objectFit = cs.objectFit;
|
| 767 |
+
if (cs?.objectPosition) cleanImg.style.objectPosition = cs.objectPosition;
|
| 768 |
+
} catch {}
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
function updateCleanLayer(rec, imgElement, newSrc) {
|
| 772 |
+
if (!rec?.host || !imgElement) return;
|
| 773 |
+
const layer = ensureCleanLayer(rec);
|
| 774 |
+
if (!layer) return;
|
| 775 |
+
if (!newSrc) {
|
| 776 |
+
layer.style.display = "none";
|
| 777 |
+
return;
|
| 778 |
+
}
|
| 779 |
+
syncCleanLayerFit(layer, imgElement);
|
| 780 |
+
if (layer.src !== newSrc) layer.src = newSrc;
|
| 781 |
+
layer.style.display = "block";
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
function applyHtmlOverlay(
|
| 785 |
+
imgElement,
|
| 786 |
+
result,
|
| 787 |
+
source,
|
| 788 |
+
isTextMode,
|
| 789 |
+
original = "",
|
| 790 |
+
) {
|
| 791 |
+
const aiHtml = result?.Ai?.aihtml || result?.ai?.aihtml || "";
|
| 792 |
+
const translatedHtml =
|
| 793 |
+
result?.translated?.translatedhtml || result?.translatedhtml || "";
|
| 794 |
+
const originalHtml =
|
| 795 |
+
result?.original?.originalhtml || result?.originalhtml || "";
|
| 796 |
+
|
| 797 |
+
const req = String(source || "")
|
| 798 |
+
.trim()
|
| 799 |
+
.toLowerCase();
|
| 800 |
+
|
| 801 |
+
const chosen = req === "ai" || req === "original" ? req : "translated";
|
| 802 |
+
const html =
|
| 803 |
+
chosen === "ai"
|
| 804 |
+
? aiHtml
|
| 805 |
+
: chosen === "original"
|
| 806 |
+
? originalHtml
|
| 807 |
+
: translatedHtml;
|
| 808 |
+
|
| 809 |
+
const cssParts = [String(result?.htmlCss || "")];
|
| 810 |
+
if (chosen === "ai")
|
| 811 |
+
cssParts.push(
|
| 812 |
+
String(result?.Ai?.aihtmlCss || result?.ai?.aihtmlCss || ""),
|
| 813 |
+
);
|
| 814 |
+
const cssText = Array.from(
|
| 815 |
+
new Set(cssParts.map((s) => String(s || "").trim()).filter(Boolean)),
|
| 816 |
+
).join("\n");
|
| 817 |
+
|
| 818 |
+
const meta =
|
| 819 |
+
chosen === "ai"
|
| 820 |
+
? result?.Ai?.aihtmlMeta || result?.ai?.aihtmlMeta || {}
|
| 821 |
+
: result?.htmlMeta || {};
|
| 822 |
+
|
| 823 |
+
const baseW =
|
| 824 |
+
Number(meta.baseW || meta.sourceWidth) ||
|
| 825 |
+
imgElement.naturalWidth ||
|
| 826 |
+
imgElement.width ||
|
| 827 |
+
1;
|
| 828 |
+
const baseH =
|
| 829 |
+
Number(meta.baseH || meta.sourceHeight) ||
|
| 830 |
+
imgElement.naturalHeight ||
|
| 831 |
+
imgElement.height ||
|
| 832 |
+
1;
|
| 833 |
+
|
| 834 |
+
const newImgSrc = isTextMode ? extractNewImageSrc(result) : null;
|
| 835 |
+
|
| 836 |
+
log.debug("OVERLAY_HTML", {
|
| 837 |
+
source: req,
|
| 838 |
+
chosen,
|
| 839 |
+
aiHtmlLen: aiHtml ? String(aiHtml).length : 0,
|
| 840 |
+
translatedHtmlLen: translatedHtml ? String(translatedHtml).length : 0,
|
| 841 |
+
originalHtmlLen: originalHtml ? String(originalHtml).length : 0,
|
| 842 |
+
baseW,
|
| 843 |
+
baseH,
|
| 844 |
+
});
|
| 845 |
+
|
| 846 |
+
ensureOverlayStyle(cssText);
|
| 847 |
+
|
| 848 |
+
const mdKey = isMangaDexHost() ? mdGetKeyForImg(imgElement) : "";
|
| 849 |
+
if (mdKey) {
|
| 850 |
+
if (isTextMode && req === "ai" && !aiHtml) {
|
| 851 |
+
const rec = upsertMangaDexHtmlOverlay(
|
| 852 |
+
mdKey,
|
| 853 |
+
imgElement,
|
| 854 |
+
baseW,
|
| 855 |
+
baseH,
|
| 856 |
+
"badge",
|
| 857 |
+
);
|
| 858 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 859 |
+
rec.scope.textContent = "";
|
| 860 |
+
const aiMeta = (result && result.Ai && result.Ai.meta) || {};
|
| 861 |
+
const reason = String(
|
| 862 |
+
aiMeta.skipped_reason || aiMeta.reason || "",
|
| 863 |
+
).trim();
|
| 864 |
+
const badgeText =
|
| 865 |
+
reason === "no_text"
|
| 866 |
+
? "No text"
|
| 867 |
+
: reason === "rate_limited"
|
| 868 |
+
? "Rate limit"
|
| 869 |
+
: "AI…";
|
| 870 |
+
rec.scope.appendChild(createOverlayBadge(badgeText));
|
| 871 |
+
scheduleMangaDexOverlayUpdate();
|
| 872 |
+
if (!imgElement.complete) {
|
| 873 |
+
imgElement.addEventListener("load", scheduleMangaDexOverlayUpdate, {
|
| 874 |
+
once: true,
|
| 875 |
+
passive: true,
|
| 876 |
+
});
|
| 877 |
+
}
|
| 878 |
+
setTimeout(scheduleMangaDexOverlayUpdate, 50);
|
| 879 |
+
return;
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
if (!html) {
|
| 883 |
+
if (isTextMode && newImgSrc) {
|
| 884 |
+
const rec = upsertMangaDexHtmlOverlay(
|
| 885 |
+
mdKey,
|
| 886 |
+
imgElement,
|
| 887 |
+
baseW,
|
| 888 |
+
baseH,
|
| 889 |
+
"badge",
|
| 890 |
+
);
|
| 891 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 892 |
+
rec.scope.textContent = "";
|
| 893 |
+
scheduleMangaDexOverlayUpdate();
|
| 894 |
+
if (!imgElement.complete) {
|
| 895 |
+
imgElement.addEventListener("load", scheduleMangaDexOverlayUpdate, {
|
| 896 |
+
once: true,
|
| 897 |
+
passive: true,
|
| 898 |
+
});
|
| 899 |
+
}
|
| 900 |
+
setTimeout(scheduleMangaDexOverlayUpdate, 50);
|
| 901 |
+
return;
|
| 902 |
+
}
|
| 903 |
+
hideMangaDexHtmlOverlay(mdKey);
|
| 904 |
+
return;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
const rec = upsertMangaDexHtmlOverlay(
|
| 908 |
+
mdKey,
|
| 909 |
+
imgElement,
|
| 910 |
+
baseW,
|
| 911 |
+
baseH,
|
| 912 |
+
"html",
|
| 913 |
+
);
|
| 914 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 915 |
+
rec.scope.textContent = "";
|
| 916 |
+
const tmp = document.createElement("div");
|
| 917 |
+
tmp.innerHTML = String(html || "")
|
| 918 |
+
.replace(/<<TP_P\d+>>/g, "")
|
| 919 |
+
.replace(/<<TP_P/g, "");
|
| 920 |
+
while (tmp.firstChild) rec.scope.appendChild(tmp.firstChild);
|
| 921 |
+
scheduleMangaDexOverlayUpdate();
|
| 922 |
+
if (!imgElement.complete) {
|
| 923 |
+
imgElement.addEventListener("load", scheduleMangaDexOverlayUpdate, {
|
| 924 |
+
once: true,
|
| 925 |
+
passive: true,
|
| 926 |
+
});
|
| 927 |
+
}
|
| 928 |
+
setTimeout(scheduleMangaDexOverlayUpdate, 50);
|
| 929 |
+
return;
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
const key = _normUrl(original) || _normUrl(getBestImgUrl(imgElement));
|
| 933 |
+
if (!key) return;
|
| 934 |
+
|
| 935 |
+
if (isTextMode && req === "ai" && !aiHtml) {
|
| 936 |
+
const rec = upsertHtmlOverlay(key, imgElement, baseW, baseH, "badge");
|
| 937 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 938 |
+
rec.scope.textContent = "";
|
| 939 |
+
rec.scope.appendChild(createOverlayBadge("AI…"));
|
| 940 |
+
scheduleHtmlOverlayUpdate();
|
| 941 |
+
if (!imgElement.complete) {
|
| 942 |
+
imgElement.addEventListener("load", scheduleHtmlOverlayUpdate, {
|
| 943 |
+
once: true,
|
| 944 |
+
passive: true,
|
| 945 |
+
});
|
| 946 |
+
}
|
| 947 |
+
setTimeout(scheduleHtmlOverlayUpdate, 50);
|
| 948 |
+
return;
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
if (!html) {
|
| 952 |
+
if (isTextMode && newImgSrc) {
|
| 953 |
+
const rec = upsertHtmlOverlay(key, imgElement, baseW, baseH, "badge");
|
| 954 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 955 |
+
rec.scope.textContent = "";
|
| 956 |
+
scheduleHtmlOverlayUpdate();
|
| 957 |
+
if (!imgElement.complete) {
|
| 958 |
+
imgElement.addEventListener("load", scheduleHtmlOverlayUpdate, {
|
| 959 |
+
once: true,
|
| 960 |
+
passive: true,
|
| 961 |
+
});
|
| 962 |
+
}
|
| 963 |
+
setTimeout(scheduleHtmlOverlayUpdate, 50);
|
| 964 |
+
return;
|
| 965 |
+
}
|
| 966 |
+
hideHtmlOverlay(key);
|
| 967 |
+
return;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
const rec = upsertHtmlOverlay(key, imgElement, baseW, baseH, "html");
|
| 971 |
+
updateCleanLayer(rec, imgElement, newImgSrc);
|
| 972 |
+
rec.scope.textContent = "";
|
| 973 |
+
const tmp = document.createElement("div");
|
| 974 |
+
tmp.innerHTML = String(html || "")
|
| 975 |
+
.replace(/<<TP_P\d+>>/g, "")
|
| 976 |
+
.replace(/<<TP_P/g, "");
|
| 977 |
+
while (tmp.firstChild) rec.scope.appendChild(tmp.firstChild);
|
| 978 |
+
scheduleHtmlOverlayUpdate();
|
| 979 |
+
if (!imgElement.complete) {
|
| 980 |
+
imgElement.addEventListener("load", scheduleHtmlOverlayUpdate, {
|
| 981 |
+
once: true,
|
| 982 |
+
passive: true,
|
| 983 |
+
});
|
| 984 |
+
}
|
| 985 |
+
setTimeout(scheduleHtmlOverlayUpdate, 50);
|
| 986 |
+
emitViewerEvent("textphantom:overlay-updated", {
|
| 987 |
+
original,
|
| 988 |
+
result,
|
| 989 |
+
mode: isTextMode ? "lens_text" : "lens_images",
|
| 990 |
+
source: req,
|
| 991 |
+
});
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
function buildPayload(
|
| 995 |
+
{ original_image_url, position, imageDataUri },
|
| 996 |
+
mode,
|
| 997 |
+
lang,
|
| 998 |
+
menuSource = "page_scan",
|
| 999 |
+
customStage,
|
| 1000 |
+
) {
|
| 1001 |
+
const pipeline = customStage
|
| 1002 |
+
? [buildPipelineEvent(customStage)]
|
| 1003 |
+
: [buildPipelineEvent("collected")];
|
| 1004 |
+
|
| 1005 |
+
const payload = {
|
| 1006 |
+
mode,
|
| 1007 |
+
lang,
|
| 1008 |
+
type: "image",
|
| 1009 |
+
src: original_image_url || null,
|
| 1010 |
+
imageDataUri: imageDataUri || null,
|
| 1011 |
+
menu: menuSource,
|
| 1012 |
+
context: {
|
| 1013 |
+
page_url: location.href,
|
| 1014 |
+
timestamp: new Date().toISOString(),
|
| 1015 |
+
},
|
| 1016 |
+
metadata: {
|
| 1017 |
+
image_id: crypto.randomUUID(),
|
| 1018 |
+
original_image_url: original_image_url || null,
|
| 1019 |
+
position,
|
| 1020 |
+
pipeline,
|
| 1021 |
+
ocr_image: null,
|
| 1022 |
+
extra: null,
|
| 1023 |
+
},
|
| 1024 |
+
};
|
| 1025 |
+
log.info("Built payload", {
|
| 1026 |
+
src: truncate(original_image_url),
|
| 1027 |
+
hasData: Boolean(imageDataUri),
|
| 1028 |
+
menu: menuSource,
|
| 1029 |
+
mode,
|
| 1030 |
+
lang,
|
| 1031 |
+
});
|
| 1032 |
+
return payload;
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
function mdKeyFromUrl(u) {
|
| 1036 |
+
const s = _normUrl(u);
|
| 1037 |
+
if (!s) return "";
|
| 1038 |
+
try {
|
| 1039 |
+
const x = new URL(s, location.href);
|
| 1040 |
+
const parts = String(x.pathname || "")
|
| 1041 |
+
.split("/")
|
| 1042 |
+
.filter(Boolean);
|
| 1043 |
+
for (let i = parts.length - 1; i >= 0; i--) {
|
| 1044 |
+
const p = parts[i];
|
| 1045 |
+
if (p === "data" || p === "data-saver") {
|
| 1046 |
+
if (parts.length >= i + 3)
|
| 1047 |
+
return `md:${parts[i]}/${parts[i + 1]}/${parts[i + 2]}`;
|
| 1048 |
+
break;
|
| 1049 |
+
}
|
| 1050 |
+
}
|
| 1051 |
+
return "";
|
| 1052 |
+
} catch {
|
| 1053 |
+
return "";
|
| 1054 |
+
}
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
const mdPendingByOriginal = new Map();
|
| 1058 |
+
|
| 1059 |
+
function mdPendingTrim(maxSize = 80) {
|
| 1060 |
+
if (mdPendingByOriginal.size <= maxSize) return;
|
| 1061 |
+
const items = Array.from(mdPendingByOriginal.entries()).sort(
|
| 1062 |
+
(a, b) => (a[1]?.ts || 0) - (b[1]?.ts || 0),
|
| 1063 |
+
);
|
| 1064 |
+
const drop = Math.max(0, items.length - maxSize);
|
| 1065 |
+
for (let i = 0; i < drop; i++) mdPendingByOriginal.delete(items[i][0]);
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
function mdRememberPending(original, patch) {
|
| 1069 |
+
const key = mdKeyFromUrl(original) || _normUrl(original);
|
| 1070 |
+
if (!key) return;
|
| 1071 |
+
const prev = mdPendingByOriginal.get(key) || {};
|
| 1072 |
+
mdPendingByOriginal.set(key, { ...prev, ...(patch || {}), ts: Date.now() });
|
| 1073 |
+
mdPendingTrim();
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
function mdTakePending(key) {
|
| 1077 |
+
const v = mdPendingByOriginal.get(key);
|
| 1078 |
+
if (v) mdPendingByOriginal.delete(key);
|
| 1079 |
+
return v || null;
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
function isMangaDexHost() {
|
| 1083 |
+
return /(^|\.)mangadex\.org$/i.test(String(location.hostname || ""));
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
function getMangaDexChapterId() {
|
| 1087 |
+
const m = String(location.pathname || "").match(
|
| 1088 |
+
/\/chapter\/([a-f0-9-]{8,})/i,
|
| 1089 |
+
);
|
| 1090 |
+
return m ? m[1] : "";
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
function getMangaDexPageIndexFromUrl() {
|
| 1094 |
+
const parts = String(location.pathname || "")
|
| 1095 |
+
.split("/")
|
| 1096 |
+
.filter(Boolean);
|
| 1097 |
+
const ci = parts.indexOf("chapter");
|
| 1098 |
+
if (ci >= 0 && parts.length >= ci + 3) {
|
| 1099 |
+
const p = parts[ci + 2];
|
| 1100 |
+
if (/^\d+$/.test(p)) return Math.max(0, Number(p) - 1);
|
| 1101 |
+
}
|
| 1102 |
+
try {
|
| 1103 |
+
const qs = new URLSearchParams(String(location.search || ""));
|
| 1104 |
+
const q = qs.get("page") || qs.get("p");
|
| 1105 |
+
if (q && /^\d+$/.test(q)) return Math.max(0, Number(q) - 1);
|
| 1106 |
+
} catch {}
|
| 1107 |
+
const hm = String(location.hash || "").match(/(?:^|[?#&])page=(\d+)/i);
|
| 1108 |
+
if (hm) return Math.max(0, Number(hm[1]) - 1);
|
| 1109 |
+
return null;
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
const MD_CACHE_TTL_MS = 180000;
|
| 1113 |
+
let mdCache = null;
|
| 1114 |
+
|
| 1115 |
+
async function fetchMangaDexChapterUrls() {
|
| 1116 |
+
if (!isMangaDexHost()) return null;
|
| 1117 |
+
|
| 1118 |
+
const chapterId = getMangaDexChapterId();
|
| 1119 |
+
if (!chapterId) return null;
|
| 1120 |
+
|
| 1121 |
+
const now = Date.now();
|
| 1122 |
+
if (
|
| 1123 |
+
mdCache &&
|
| 1124 |
+
mdCache.chapterId === chapterId &&
|
| 1125 |
+
now - (mdCache.ts || 0) < MD_CACHE_TTL_MS &&
|
| 1126 |
+
Array.isArray(mdCache.urls) &&
|
| 1127 |
+
mdCache.urls.length
|
| 1128 |
+
)
|
| 1129 |
+
return mdCache;
|
| 1130 |
+
|
| 1131 |
+
const apiUrl = `https://api.mangadex.org/at-home/server/${chapterId}`;
|
| 1132 |
+
|
| 1133 |
+
try {
|
| 1134 |
+
const res = await fetch(apiUrl, { credentials: "omit" });
|
| 1135 |
+
if (!res.ok) throw new Error(`MangaDex API ${res.status}`);
|
| 1136 |
+
const baseInfo = await res.json();
|
| 1137 |
+
|
| 1138 |
+
const baseUrl = baseInfo?.baseUrl;
|
| 1139 |
+
const chapter = baseInfo?.chapter;
|
| 1140 |
+
const hash = chapter?.hash;
|
| 1141 |
+
|
| 1142 |
+
const data = Array.isArray(chapter?.data) ? chapter.data : [];
|
| 1143 |
+
const dataSaver = Array.isArray(chapter?.dataSaver)
|
| 1144 |
+
? chapter.dataSaver
|
| 1145 |
+
: [];
|
| 1146 |
+
|
| 1147 |
+
let path = "data";
|
| 1148 |
+
let files = data;
|
| 1149 |
+
if (!files.length && dataSaver.length) {
|
| 1150 |
+
path = "data-saver";
|
| 1151 |
+
files = dataSaver;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
if (!baseUrl || !hash || !files.length)
|
| 1155 |
+
throw new Error("Unexpected MangaDex API shape");
|
| 1156 |
+
|
| 1157 |
+
const urls = files.map(
|
| 1158 |
+
(filename) => `${baseUrl}/${path}/${hash}/${filename}`,
|
| 1159 |
+
);
|
| 1160 |
+
|
| 1161 |
+
mdCache = { chapterId, ts: now, urls, path };
|
| 1162 |
+
return mdCache;
|
| 1163 |
+
} catch (e) {
|
| 1164 |
+
console.warn(
|
| 1165 |
+
"[TextPhantom][content] MangaDex API error",
|
| 1166 |
+
e?.message || e,
|
| 1167 |
+
);
|
| 1168 |
+
mdCache = { chapterId, ts: now, urls: [], path: "data" };
|
| 1169 |
+
return null;
|
| 1170 |
+
}
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
async function hydrateMangaDexFromCache() {
|
| 1174 |
+
if (!isMangaDexHost() || !__tpIsTop) return null;
|
| 1175 |
+
|
| 1176 |
+
const info = await fetchMangaDexChapterUrls();
|
| 1177 |
+
const urls = info?.urls;
|
| 1178 |
+
if (!Array.isArray(urls) || !urls.length) return null;
|
| 1179 |
+
|
| 1180 |
+
const pairs = urls
|
| 1181 |
+
.map((u) => ({ url: u, key: mdKeyFromUrl(u) }))
|
| 1182 |
+
.filter((p) => !!p.key);
|
| 1183 |
+
const keys = Array.from(new Set(pairs.map((p) => p.key))).slice(0, 600);
|
| 1184 |
+
if (!keys.length) return null;
|
| 1185 |
+
|
| 1186 |
+
const st = await getSettings();
|
| 1187 |
+
|
| 1188 |
+
const resp = await mdCacheGet(keys, false, st.lang, st.mode);
|
| 1189 |
+
|
| 1190 |
+
const items = resp?.items || null;
|
| 1191 |
+
if (!items) return null;
|
| 1192 |
+
const isText = String(st.mode || "lens_text") === "lens_text";
|
| 1193 |
+
const source = isText ? String(st.sources || "translated") : "translated";
|
| 1194 |
+
|
| 1195 |
+
for (const p of pairs) {
|
| 1196 |
+
const rec = items[p.key];
|
| 1197 |
+
if (!rec) continue;
|
| 1198 |
+
|
| 1199 |
+
if (rec.result) {
|
| 1200 |
+
const img = findTargetImage(p.url);
|
| 1201 |
+
if (img) applyHtmlOverlay(img, rec.result, source, isText, p.url);
|
| 1202 |
+
else
|
| 1203 |
+
mdRememberPending(p.url, {
|
| 1204 |
+
overlay: { result: rec.result, source, isTextMode: isText },
|
| 1205 |
+
});
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
const hasNewImg =
|
| 1209 |
+
Boolean(rec?.hasNewImg) ||
|
| 1210 |
+
Boolean(rec?.newImg) ||
|
| 1211 |
+
Boolean(extractNewImageSrc(rec?.result || null));
|
| 1212 |
+
|
| 1213 |
+
if (hasNewImg) {
|
| 1214 |
+
const img = findTargetImage(p.url);
|
| 1215 |
+
if (img)
|
| 1216 |
+
mdApplyCachedNewImg(p.url, p.key, isText, img, st.lang, st.mode);
|
| 1217 |
+
else
|
| 1218 |
+
mdRememberPending(p.url, {
|
| 1219 |
+
needNewImg: true,
|
| 1220 |
+
needMode: isText ? "clean" : "replace",
|
| 1221 |
+
needLang: st.lang,
|
| 1222 |
+
needCacheMode: st.mode,
|
| 1223 |
+
});
|
| 1224 |
+
}
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
return { pairs, items };
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
function isProbableMangaDexPageImage(img) {
|
| 1231 |
+
if (!img || !img.getBoundingClientRect) return false;
|
| 1232 |
+
const u = String(getBestImgUrl(img) || "");
|
| 1233 |
+
if (!u) return false;
|
| 1234 |
+
const rect = img.getBoundingClientRect();
|
| 1235 |
+
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
| 1236 |
+
if (area < 65000 && (rect.width < 260 || rect.height < 260)) return false;
|
| 1237 |
+
if (u.startsWith("blob:") || u.startsWith("data:")) return true;
|
| 1238 |
+
if (/mangadex\.network|uploads\.mangadex\.org/i.test(u)) return true;
|
| 1239 |
+
return false;
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
function getMangaDexPageImagesInDOM() {
|
| 1243 |
+
const root = document;
|
| 1244 |
+
const nodes = Array.from(root.querySelectorAll(".md--page img"));
|
| 1245 |
+
const imgs = nodes.length ? nodes : Array.from(document.images || []);
|
| 1246 |
+
return imgs.filter(isProbableMangaDexPageImage);
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
function inferMangaDexPageIndexForImg(img) {
|
| 1250 |
+
const alt = String(
|
| 1251 |
+
img?.getAttribute?.("alt") || img?.getAttribute?.("aria-label") || "",
|
| 1252 |
+
);
|
| 1253 |
+
const m0 = alt.match(/^\s*(\d+)\s*[-_]/);
|
| 1254 |
+
if (m0) return Math.max(0, Number(m0[1]) - 1);
|
| 1255 |
+
const m1 = alt.match(/(?:^|\D)(\d+)\s*\/\s*(\d+)/);
|
| 1256 |
+
if (m1) return Math.max(0, Number(m1[1]) - 1);
|
| 1257 |
+
const m2 = alt.match(/page\s*(\d+)/i);
|
| 1258 |
+
if (m2) return Math.max(0, Number(m2[1]) - 1);
|
| 1259 |
+
|
| 1260 |
+
const tpPage =
|
| 1261 |
+
img?.dataset?.tpMdPage ||
|
| 1262 |
+
(typeof img?.getAttribute === "function"
|
| 1263 |
+
? img.getAttribute("data-tp-md-page")
|
| 1264 |
+
: "");
|
| 1265 |
+
if (tpPage && /^\d+$/.test(String(tpPage)))
|
| 1266 |
+
return Math.max(0, Number(tpPage) - 1);
|
| 1267 |
+
|
| 1268 |
+
const key = String(img?.dataset?.tpOriginalKey || "");
|
| 1269 |
+
const km = key.match(/\/(\d+)\s*[-_]/);
|
| 1270 |
+
if (km) return Math.max(0, Number(km[1]) - 1);
|
| 1271 |
+
|
| 1272 |
+
const cur = String(img?.dataset?.tpOriginal || "");
|
| 1273 |
+
const cm = cur.match(/\/(\d+)\s*[-_]/);
|
| 1274 |
+
if (cm) return Math.max(0, Number(cm[1]) - 1);
|
| 1275 |
+
|
| 1276 |
+
const direct =
|
| 1277 |
+
img?.getAttribute?.("data-page") ||
|
| 1278 |
+
img?.dataset?.page ||
|
| 1279 |
+
img?.dataset?.pageIndex ||
|
| 1280 |
+
"";
|
| 1281 |
+
if (direct && /^\d+$/.test(String(direct)))
|
| 1282 |
+
return Math.max(0, Number(direct) - 1);
|
| 1283 |
+
|
| 1284 |
+
const near =
|
| 1285 |
+
img?.closest?.("[data-page]")?.getAttribute?.("data-page") || "";
|
| 1286 |
+
if (near && /^\d+$/.test(String(near)))
|
| 1287 |
+
return Math.max(0, Number(near) - 1);
|
| 1288 |
+
|
| 1289 |
+
return null;
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
function mdApplyOriginalToImg(img, idx, urls) {
|
| 1293 |
+
if (!img || !Array.isArray(urls) || idx == null) return;
|
| 1294 |
+
if (idx < 0 || idx >= urls.length) return;
|
| 1295 |
+
const url = _normUrl(urls[idx]);
|
| 1296 |
+
if (!url) return;
|
| 1297 |
+
const key = mdKeyFromUrl(url) || url;
|
| 1298 |
+
if (img.dataset.tpOriginalKey !== key) img.dataset.tpOriginalKey = key;
|
| 1299 |
+
if (_normUrl(img.dataset.tpOriginal) !== url) img.dataset.tpOriginal = url;
|
| 1300 |
+
img.dataset.tpMdPage = String(idx + 1);
|
| 1301 |
+
const pending = mdTakePending(key);
|
| 1302 |
+
if (pending) {
|
| 1303 |
+
if (pending.newSrc)
|
| 1304 |
+
replaceImageInDOM(url, pending.newSrc).catch(() => {});
|
| 1305 |
+
if (pending.cleanSrc) {
|
| 1306 |
+
const existing = mdHtmlOverlaysByKey.get(key) || null;
|
| 1307 |
+
const rec =
|
| 1308 |
+
existing ||
|
| 1309 |
+
(() => {
|
| 1310 |
+
const rect = img.getBoundingClientRect();
|
| 1311 |
+
const r = upsertMangaDexHtmlOverlay(
|
| 1312 |
+
key,
|
| 1313 |
+
img,
|
| 1314 |
+
Math.max(1, rect.width),
|
| 1315 |
+
Math.max(1, rect.height),
|
| 1316 |
+
"badge",
|
| 1317 |
+
);
|
| 1318 |
+
r.scope.textContent = "";
|
| 1319 |
+
return r;
|
| 1320 |
+
})();
|
| 1321 |
+
rec.isTextMode = true;
|
| 1322 |
+
updateCleanLayer(rec, img, pending.cleanSrc);
|
| 1323 |
+
mdScheduleUpdate();
|
| 1324 |
+
}
|
| 1325 |
+
if (pending.overlay) {
|
| 1326 |
+
try {
|
| 1327 |
+
applyHtmlOverlay(
|
| 1328 |
+
img,
|
| 1329 |
+
pending.overlay.result,
|
| 1330 |
+
pending.overlay.source,
|
| 1331 |
+
pending.overlay.isTextMode,
|
| 1332 |
+
url,
|
| 1333 |
+
);
|
| 1334 |
+
} catch {}
|
| 1335 |
+
}
|
| 1336 |
+
if (pending.needNewImg)
|
| 1337 |
+
mdApplyCachedNewImg(
|
| 1338 |
+
url,
|
| 1339 |
+
key,
|
| 1340 |
+
pending.needMode === "clean",
|
| 1341 |
+
img,
|
| 1342 |
+
pending.needLang,
|
| 1343 |
+
pending.needCacheMode,
|
| 1344 |
+
);
|
| 1345 |
+
}
|
| 1346 |
+
}
|
| 1347 |
+
|
| 1348 |
+
async function ensureMangaDexDomMapping() {
|
| 1349 |
+
if (!isMangaDexHost()) return;
|
| 1350 |
+
const info = await fetchMangaDexChapterUrls();
|
| 1351 |
+
const urls = info?.urls || [];
|
| 1352 |
+
if (!urls.length) return;
|
| 1353 |
+
|
| 1354 |
+
const imgs = getMangaDexPageImagesInDOM();
|
| 1355 |
+
if (!imgs.length) return;
|
| 1356 |
+
|
| 1357 |
+
imgs.forEach((img) => {
|
| 1358 |
+
const idx = inferMangaDexPageIndexForImg(img);
|
| 1359 |
+
if (idx == null) return;
|
| 1360 |
+
mdApplyOriginalToImg(img, idx, urls);
|
| 1361 |
+
});
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
let mdMappingScheduled = false;
|
| 1365 |
+
function scheduleMangaDexMapping() {
|
| 1366 |
+
if (!isMangaDexHost()) return;
|
| 1367 |
+
if (mdMappingScheduled) return;
|
| 1368 |
+
mdMappingScheduled = true;
|
| 1369 |
+
setTimeout(() => {
|
| 1370 |
+
mdMappingScheduled = false;
|
| 1371 |
+
ensureMangaDexDomMapping()
|
| 1372 |
+
.then(() => {
|
| 1373 |
+
if (mdOverlaysByKey.size) scheduleMangaDexOverlayUpdate();
|
| 1374 |
+
})
|
| 1375 |
+
.catch(() => {});
|
| 1376 |
+
}, 60);
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
async function resolveMangaDexOriginalForBlob(blobUrl) {
|
| 1380 |
+
if (!isMangaDexHost()) return null;
|
| 1381 |
+
if (!blobUrl?.startsWith("blob:")) return null;
|
| 1382 |
+
|
| 1383 |
+
scheduleMangaDexMapping();
|
| 1384 |
+
const info = await fetchMangaDexChapterUrls();
|
| 1385 |
+
const urls = info?.urls || [];
|
| 1386 |
+
if (!urls.length) return null;
|
| 1387 |
+
|
| 1388 |
+
const img = Array.from(document.images || []).find(
|
| 1389 |
+
(i) => (i.currentSrc || i.src) === blobUrl,
|
| 1390 |
+
);
|
| 1391 |
+
if (!img) return null;
|
| 1392 |
+
|
| 1393 |
+
if (img.dataset.tpOriginal) return _normUrl(img.dataset.tpOriginal);
|
| 1394 |
+
|
| 1395 |
+
const byAlt = inferMangaDexPageIndexForImg(img);
|
| 1396 |
+
if (byAlt != null) {
|
| 1397 |
+
mdApplyOriginalToImg(img, byAlt, urls);
|
| 1398 |
+
return _normUrl(img.dataset.tpOriginal);
|
| 1399 |
+
}
|
| 1400 |
+
|
| 1401 |
+
const byUrl = getMangaDexPageIndexFromUrl();
|
| 1402 |
+
if (byUrl != null) {
|
| 1403 |
+
mdApplyOriginalToImg(img, byUrl, urls);
|
| 1404 |
+
return _normUrl(img.dataset.tpOriginal);
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
const pageImgs = getMangaDexPageImagesInDOM();
|
| 1408 |
+
const pos = pageImgs.indexOf(img);
|
| 1409 |
+
if (pos >= 0) {
|
| 1410 |
+
mdApplyOriginalToImg(img, pos, urls);
|
| 1411 |
+
return _normUrl(img.dataset.tpOriginal);
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
return null;
|
| 1415 |
+
}
|
| 1416 |
+
|
| 1417 |
+
let toastEl = null;
|
| 1418 |
+
let toastTimer = 0;
|
| 1419 |
+
function showToast(text, ms = 2000) {
|
| 1420 |
+
if (!text) return;
|
| 1421 |
+
if (!toastEl) {
|
| 1422 |
+
toastEl = document.createElement("div");
|
| 1423 |
+
toastEl.style.position = "fixed";
|
| 1424 |
+
toastEl.style.right = "10px";
|
| 1425 |
+
toastEl.style.bottom = "10px";
|
| 1426 |
+
toastEl.style.zIndex = 2147483647;
|
| 1427 |
+
toastEl.style.padding = "8px 10px";
|
| 1428 |
+
toastEl.style.borderRadius = "10px";
|
| 1429 |
+
toastEl.style.background = "rgba(0,0,0,.75)";
|
| 1430 |
+
toastEl.style.color = "#fff";
|
| 1431 |
+
toastEl.style.fontSize = "12px";
|
| 1432 |
+
toastEl.style.lineHeight = "1.2";
|
| 1433 |
+
toastEl.style.maxWidth = "68vw";
|
| 1434 |
+
toastEl.style.pointerEvents = "none";
|
| 1435 |
+
document.documentElement.appendChild(toastEl);
|
| 1436 |
+
}
|
| 1437 |
+
toastEl.textContent = String(text);
|
| 1438 |
+
toastEl.style.display = "block";
|
| 1439 |
+
if (toastTimer) clearTimeout(toastTimer);
|
| 1440 |
+
toastTimer = setTimeout(
|
| 1441 |
+
() => {
|
| 1442 |
+
if (toastEl) toastEl.style.display = "none";
|
| 1443 |
+
},
|
| 1444 |
+
Math.max(800, Number(ms) || 0),
|
| 1445 |
+
);
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
if (isMangaDexHost() && __tpIsTop) {
|
| 1449 |
+
scheduleMangaDexMapping();
|
| 1450 |
+
try {
|
| 1451 |
+
const mo = new MutationObserver(() => scheduleMangaDexMapping());
|
| 1452 |
+
mo.observe(document.documentElement, {
|
| 1453 |
+
subtree: true,
|
| 1454 |
+
childList: true,
|
| 1455 |
+
attributes: true,
|
| 1456 |
+
attributeFilter: ["src", "srcset", "data-src", "alt", "aria-label"],
|
| 1457 |
+
});
|
| 1458 |
+
window.addEventListener("popstate", () => {
|
| 1459 |
+
mdCache = null;
|
| 1460 |
+
scheduleMangaDexMapping();
|
| 1461 |
+
});
|
| 1462 |
+
window.addEventListener("hashchange", () => {
|
| 1463 |
+
mdCache = null;
|
| 1464 |
+
scheduleMangaDexMapping();
|
| 1465 |
+
});
|
| 1466 |
+
|
| 1467 |
+
if (!history.__tpMdPatched) {
|
| 1468 |
+
history.__tpMdPatched = true;
|
| 1469 |
+
const ps = history.pushState;
|
| 1470 |
+
history.pushState = function (...args) {
|
| 1471 |
+
const r = ps.apply(this, args);
|
| 1472 |
+
scheduleMangaDexMapping();
|
| 1473 |
+
return r;
|
| 1474 |
+
};
|
| 1475 |
+
const rs = history.replaceState;
|
| 1476 |
+
history.replaceState = function (...args) {
|
| 1477 |
+
const r = rs.apply(this, args);
|
| 1478 |
+
scheduleMangaDexMapping();
|
| 1479 |
+
return r;
|
| 1480 |
+
};
|
| 1481 |
+
}
|
| 1482 |
+
} catch {}
|
| 1483 |
+
}
|
| 1484 |
+
|
| 1485 |
+
async function dataUriToBlobUrl(dataUri) {
|
| 1486 |
+
try {
|
| 1487 |
+
const res = await fetch(dataUri);
|
| 1488 |
+
const blob = await res.blob();
|
| 1489 |
+
return URL.createObjectURL(blob);
|
| 1490 |
+
} catch {
|
| 1491 |
+
return null;
|
| 1492 |
+
}
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
const mdOverlaysByKey = new Map();
|
| 1496 |
+
const mdHtmlOverlaysByKey = new Map();
|
| 1497 |
+
let mdOverlayRaf = 0;
|
| 1498 |
+
|
| 1499 |
+
const htmlOverlaysByKey = new Map();
|
| 1500 |
+
let htmlOverlayRaf = 0;
|
| 1501 |
+
|
| 1502 |
+
function updateHtmlOverlays() {
|
| 1503 |
+
if (!htmlOverlaysByKey.size) return;
|
| 1504 |
+
for (const [key, rec] of htmlOverlaysByKey.entries()) {
|
| 1505 |
+
const host = rec?.host;
|
| 1506 |
+
const scope = rec?.scope;
|
| 1507 |
+
if (!host || !scope) {
|
| 1508 |
+
htmlOverlaysByKey.delete(key);
|
| 1509 |
+
continue;
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
const prevImg = rec.img;
|
| 1513 |
+
let img = prevImg;
|
| 1514 |
+
if (!img || !img.isConnected) img = findTargetImage(key);
|
| 1515 |
+
if (!img) {
|
| 1516 |
+
host.style.display = "none";
|
| 1517 |
+
rec.img = null;
|
| 1518 |
+
continue;
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
const curKey = _normUrl(getBestImgUrl(img));
|
| 1522 |
+
if (curKey && curKey !== key) {
|
| 1523 |
+
host.style.display = "none";
|
| 1524 |
+
rec.img = null;
|
| 1525 |
+
continue;
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
+
if (img !== prevImg) {
|
| 1529 |
+
if (img?.dataset) img.dataset.tpOriginal = key;
|
| 1530 |
+
if (rec.ro) {
|
| 1531 |
+
try {
|
| 1532 |
+
rec.ro.disconnect();
|
| 1533 |
+
} catch {}
|
| 1534 |
+
}
|
| 1535 |
+
rec.ro = new ResizeObserver(() => scheduleHtmlOverlayUpdate());
|
| 1536 |
+
rec.ro.observe(img);
|
| 1537 |
+
}
|
| 1538 |
+
|
| 1539 |
+
const parent = ensureOverlayHostMountedNearImage(rec, img);
|
| 1540 |
+
if (!parent) {
|
| 1541 |
+
host.style.display = "none";
|
| 1542 |
+
rec.img = img;
|
| 1543 |
+
continue;
|
| 1544 |
+
}
|
| 1545 |
+
|
| 1546 |
+
const { r, left, top } = getOverlayBoxFromParent(img, parent);
|
| 1547 |
+
if (r.width < 2 || r.height < 2) {
|
| 1548 |
+
host.style.display = "none";
|
| 1549 |
+
rec.img = img;
|
| 1550 |
+
continue;
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
rec.img = img;
|
| 1554 |
+
host.style.display = "block";
|
| 1555 |
+
host.style.setProperty("left", `${left}px`, "important");
|
| 1556 |
+
host.style.setProperty("top", `${top}px`, "important");
|
| 1557 |
+
host.style.setProperty("width", `${r.width}px`, "important");
|
| 1558 |
+
host.style.setProperty("height", `${r.height}px`, "important");
|
| 1559 |
+
|
| 1560 |
+
if (rec.kind === "badge") {
|
| 1561 |
+
scope.style.width = `${r.width}px`;
|
| 1562 |
+
scope.style.height = `${r.height}px`;
|
| 1563 |
+
scope.style.transform = "";
|
| 1564 |
+
scope.style.transformOrigin = "0 0";
|
| 1565 |
+
host.style.transform = "";
|
| 1566 |
+
host.style.transformOrigin = "";
|
| 1567 |
+
continue;
|
| 1568 |
+
}
|
| 1569 |
+
|
| 1570 |
+
const { nw, nh, sx, sy, offX, offY } = computeScale(
|
| 1571 |
+
img,
|
| 1572 |
+
rec.baseW,
|
| 1573 |
+
rec.baseH,
|
| 1574 |
+
true,
|
| 1575 |
+
);
|
| 1576 |
+
|
| 1577 |
+
scope.style.width = `${nw}px`;
|
| 1578 |
+
scope.style.height = `${nh}px`;
|
| 1579 |
+
scope.style.transform = `translate(${offX}px, ${offY}px) scale(${sx}, ${sy})`;
|
| 1580 |
+
scope.style.transformOrigin = "0 0";
|
| 1581 |
+
host.style.transform = "";
|
| 1582 |
+
host.style.transformOrigin = "";
|
| 1583 |
+
}
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
function scheduleHtmlOverlayUpdate() {
|
| 1587 |
+
if (htmlOverlayRaf) return;
|
| 1588 |
+
htmlOverlayRaf = requestAnimationFrame(() => {
|
| 1589 |
+
htmlOverlayRaf = 0;
|
| 1590 |
+
updateHtmlOverlays();
|
| 1591 |
+
});
|
| 1592 |
+
}
|
| 1593 |
+
|
| 1594 |
+
function ensureOverlayHostMountedNearImage(rec, img) {
|
| 1595 |
+
const host = rec?.host;
|
| 1596 |
+
if (!host || !img?.isConnected) return null;
|
| 1597 |
+
const parent = img.parentElement;
|
| 1598 |
+
if (!parent) return null;
|
| 1599 |
+
|
| 1600 |
+
if (host.parentElement !== parent || host.nextSibling !== img) {
|
| 1601 |
+
try {
|
| 1602 |
+
if (host.parentElement) host.parentElement.removeChild(host);
|
| 1603 |
+
} catch {}
|
| 1604 |
+
try {
|
| 1605 |
+
parent.insertBefore(host, img);
|
| 1606 |
+
} catch {
|
| 1607 |
+
try {
|
| 1608 |
+
parent.appendChild(host);
|
| 1609 |
+
} catch {}
|
| 1610 |
+
}
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
+
try {
|
| 1614 |
+
if (getComputedStyle(parent).position === "static") {
|
| 1615 |
+
parent.style.position = "relative";
|
| 1616 |
+
}
|
| 1617 |
+
} catch {}
|
| 1618 |
+
|
| 1619 |
+
return host.parentElement === parent ? parent : null;
|
| 1620 |
+
}
|
| 1621 |
+
|
| 1622 |
+
function getOverlayBoxFromParent(img, parent) {
|
| 1623 |
+
const r = img.getBoundingClientRect();
|
| 1624 |
+
if (!parent) return { r, left: 0, top: 0 };
|
| 1625 |
+
const pr = parent.getBoundingClientRect();
|
| 1626 |
+
return {
|
| 1627 |
+
r,
|
| 1628 |
+
left: r.left - pr.left + (parent.scrollLeft || 0),
|
| 1629 |
+
top: r.top - pr.top + (parent.scrollTop || 0),
|
| 1630 |
+
};
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
function ensureHtmlOverlayListeners() {
|
| 1634 |
+
if (window.__tpHtmlOverlayListeners) return;
|
| 1635 |
+
window.__tpHtmlOverlayListeners = true;
|
| 1636 |
+
window.addEventListener("scroll", scheduleHtmlOverlayUpdate, {
|
| 1637 |
+
passive: true,
|
| 1638 |
+
capture: true,
|
| 1639 |
+
});
|
| 1640 |
+
window.addEventListener("resize", scheduleHtmlOverlayUpdate, {
|
| 1641 |
+
passive: true,
|
| 1642 |
+
});
|
| 1643 |
+
try {
|
| 1644 |
+
const mo = new MutationObserver(() => scheduleHtmlOverlayUpdate());
|
| 1645 |
+
mo.observe(document.documentElement, {
|
| 1646 |
+
subtree: true,
|
| 1647 |
+
childList: true,
|
| 1648 |
+
attributes: true,
|
| 1649 |
+
attributeFilter: ["src", "srcset", "data-src", "data-srcset", "style", "class"],
|
| 1650 |
+
});
|
| 1651 |
+
} catch {}
|
| 1652 |
+
}
|
| 1653 |
+
|
| 1654 |
+
function upsertHtmlOverlay(key, img, baseW, baseH, kind) {
|
| 1655 |
+
let rec = htmlOverlaysByKey.get(key);
|
| 1656 |
+
if (!rec) {
|
| 1657 |
+
const host = document.createElement("div");
|
| 1658 |
+
host.className = "tp-ol-root";
|
| 1659 |
+
host.style.position = "absolute";
|
| 1660 |
+
host.style.left = "0px";
|
| 1661 |
+
host.style.top = "0px";
|
| 1662 |
+
host.style.zIndex = 2147483647;
|
| 1663 |
+
host.style.pointerEvents = "none";
|
| 1664 |
+
host.style.display = "none";
|
| 1665 |
+
const scope = document.createElement("div");
|
| 1666 |
+
scope.className = "tp-ol-scope";
|
| 1667 |
+
scope.style.position = "relative";
|
| 1668 |
+
host.appendChild(scope);
|
| 1669 |
+
rec = {
|
| 1670 |
+
host,
|
| 1671 |
+
scope,
|
| 1672 |
+
img: null,
|
| 1673 |
+
baseW: 1,
|
| 1674 |
+
baseH: 1,
|
| 1675 |
+
kind: "html",
|
| 1676 |
+
ro: null,
|
| 1677 |
+
};
|
| 1678 |
+
htmlOverlaysByKey.set(key, rec);
|
| 1679 |
+
ensureHtmlOverlayListeners();
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
if (img) ensureOverlayHostMountedNearImage(rec, img);
|
| 1683 |
+
rec.img = img;
|
| 1684 |
+
rec.baseW = Number.isFinite(baseW) && baseW > 0 ? baseW : 1;
|
| 1685 |
+
rec.baseH = Number.isFinite(baseH) && baseH > 0 ? baseH : 1;
|
| 1686 |
+
rec.kind = kind || "html";
|
| 1687 |
+
if (img?.dataset && !img.dataset.tpOriginal) img.dataset.tpOriginal = key;
|
| 1688 |
+
if (rec.ro) {
|
| 1689 |
+
try {
|
| 1690 |
+
rec.ro.disconnect();
|
| 1691 |
+
} catch {}
|
| 1692 |
+
}
|
| 1693 |
+
rec.ro = new ResizeObserver(() => scheduleHtmlOverlayUpdate());
|
| 1694 |
+
rec.ro.observe(img);
|
| 1695 |
+
return rec;
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
function hideHtmlOverlay(key) {
|
| 1699 |
+
const rec = htmlOverlaysByKey.get(key);
|
| 1700 |
+
if (rec?.cleanImg) rec.cleanImg.style.display = "none";
|
| 1701 |
+
if (rec?.host) rec.host.style.display = "none";
|
| 1702 |
+
}
|
| 1703 |
+
|
| 1704 |
+
function findMangaDexImgByKey(key) {
|
| 1705 |
+
if (!key) return null;
|
| 1706 |
+
return Array.from(document.images || []).find(
|
| 1707 |
+
(img) => String(img.dataset.tpOriginalKey || "") === key,
|
| 1708 |
+
);
|
| 1709 |
+
}
|
| 1710 |
+
|
| 1711 |
+
function updateMangaDexOverlays() {
|
| 1712 |
+
if (!mdOverlaysByKey.size && !mdHtmlOverlaysByKey.size) return;
|
| 1713 |
+
for (const [key, rec] of mdOverlaysByKey.entries()) {
|
| 1714 |
+
const el = rec?.el;
|
| 1715 |
+
if (!el) {
|
| 1716 |
+
mdOverlaysByKey.delete(key);
|
| 1717 |
+
continue;
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
let img = rec.img;
|
| 1721 |
+
if (!img || !img.isConnected) img = findMangaDexImgByKey(key);
|
| 1722 |
+
if (!img) {
|
| 1723 |
+
el.style.display = "none";
|
| 1724 |
+
rec.img = null;
|
| 1725 |
+
continue;
|
| 1726 |
+
}
|
| 1727 |
+
|
| 1728 |
+
const r = img.getBoundingClientRect();
|
| 1729 |
+
if (r.width < 2 || r.height < 2) {
|
| 1730 |
+
el.style.display = "none";
|
| 1731 |
+
rec.img = img;
|
| 1732 |
+
continue;
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
rec.img = img;
|
| 1736 |
+
el.style.display = "block";
|
| 1737 |
+
el.style.left = `${r.left + window.scrollX}px`;
|
| 1738 |
+
el.style.top = `${r.top + window.scrollY}px`;
|
| 1739 |
+
el.style.width = `${r.width}px`;
|
| 1740 |
+
el.style.height = `${r.height}px`;
|
| 1741 |
+
}
|
| 1742 |
+
|
| 1743 |
+
for (const [key, rec] of mdHtmlOverlaysByKey.entries()) {
|
| 1744 |
+
const host = rec?.host;
|
| 1745 |
+
const scope = rec?.scope;
|
| 1746 |
+
if (!host || !scope) {
|
| 1747 |
+
mdHtmlOverlaysByKey.delete(key);
|
| 1748 |
+
continue;
|
| 1749 |
+
}
|
| 1750 |
+
|
| 1751 |
+
let img = rec.img;
|
| 1752 |
+
if (!img || !img.isConnected) img = findMangaDexImgByKey(key);
|
| 1753 |
+
if (!img) {
|
| 1754 |
+
host.style.display = "none";
|
| 1755 |
+
rec.img = null;
|
| 1756 |
+
continue;
|
| 1757 |
+
}
|
| 1758 |
+
|
| 1759 |
+
const curKey = mdKeyFromUrl(getBestImgUrl(img));
|
| 1760 |
+
if (curKey && curKey !== key) {
|
| 1761 |
+
host.style.display = "none";
|
| 1762 |
+
rec.img = null;
|
| 1763 |
+
continue;
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
const parent = ensureOverlayHostMountedNearImage(rec, img);
|
| 1767 |
+
if (!parent) {
|
| 1768 |
+
host.style.display = "none";
|
| 1769 |
+
rec.img = img;
|
| 1770 |
+
continue;
|
| 1771 |
+
}
|
| 1772 |
+
|
| 1773 |
+
const { r, left, top } = getOverlayBoxFromParent(img, parent);
|
| 1774 |
+
if (r.width < 2 || r.height < 2) {
|
| 1775 |
+
host.style.display = "none";
|
| 1776 |
+
rec.img = img;
|
| 1777 |
+
continue;
|
| 1778 |
+
}
|
| 1779 |
+
|
| 1780 |
+
rec.img = img;
|
| 1781 |
+
host.style.display = "block";
|
| 1782 |
+
host.style.setProperty("left", `${left}px`, "important");
|
| 1783 |
+
host.style.setProperty("top", `${top}px`, "important");
|
| 1784 |
+
host.style.setProperty("width", `${r.width}px`, "important");
|
| 1785 |
+
host.style.setProperty("height", `${r.height}px`, "important");
|
| 1786 |
+
|
| 1787 |
+
if (rec.kind === "badge") {
|
| 1788 |
+
scope.style.width = `${r.width}px`;
|
| 1789 |
+
scope.style.height = `${r.height}px`;
|
| 1790 |
+
scope.style.transform = "";
|
| 1791 |
+
scope.style.transformOrigin = "0 0";
|
| 1792 |
+
host.style.transform = "";
|
| 1793 |
+
host.style.transformOrigin = "";
|
| 1794 |
+
continue;
|
| 1795 |
+
}
|
| 1796 |
+
|
| 1797 |
+
const { nw, nh, sx, sy, offX, offY, transform, transformOrigin } =
|
| 1798 |
+
computeScale(img, rec.baseW, rec.baseH, true);
|
| 1799 |
+
|
| 1800 |
+
scope.style.width = `${nw}px`;
|
| 1801 |
+
scope.style.height = `${nh}px`;
|
| 1802 |
+
scope.style.transform = `translate(${offX}px, ${offY}px) scale(${sx}, ${sy})`;
|
| 1803 |
+
scope.style.transformOrigin = "0 0";
|
| 1804 |
+
|
| 1805 |
+
if (transform && transform !== "none") {
|
| 1806 |
+
host.style.transform = transform;
|
| 1807 |
+
host.style.transformOrigin = transformOrigin || "0 0";
|
| 1808 |
+
} else {
|
| 1809 |
+
host.style.transform = "";
|
| 1810 |
+
host.style.transformOrigin = "";
|
| 1811 |
+
}
|
| 1812 |
+
}
|
| 1813 |
+
}
|
| 1814 |
+
|
| 1815 |
+
function mdScheduleUpdate() {
|
| 1816 |
+
scheduleMangaDexOverlayUpdate();
|
| 1817 |
+
}
|
| 1818 |
+
|
| 1819 |
+
function scheduleMangaDexOverlayUpdate() {
|
| 1820 |
+
if (mdOverlayRaf) return;
|
| 1821 |
+
mdOverlayRaf = requestAnimationFrame(() => {
|
| 1822 |
+
mdOverlayRaf = 0;
|
| 1823 |
+
updateMangaDexOverlays();
|
| 1824 |
+
});
|
| 1825 |
+
}
|
| 1826 |
+
|
| 1827 |
+
function ensureMangaDexOverlayListeners() {
|
| 1828 |
+
if (window.__tpMdOverlayListeners) return;
|
| 1829 |
+
window.__tpMdOverlayListeners = true;
|
| 1830 |
+
window.addEventListener("scroll", scheduleMangaDexOverlayUpdate, {
|
| 1831 |
+
passive: true,
|
| 1832 |
+
});
|
| 1833 |
+
window.addEventListener("resize", scheduleMangaDexOverlayUpdate, {
|
| 1834 |
+
passive: true,
|
| 1835 |
+
});
|
| 1836 |
+
try {
|
| 1837 |
+
const mo = new MutationObserver(() => scheduleMangaDexOverlayUpdate());
|
| 1838 |
+
mo.observe(document.documentElement, {
|
| 1839 |
+
subtree: true,
|
| 1840 |
+
childList: true,
|
| 1841 |
+
attributes: true,
|
| 1842 |
+
attributeFilter: ["src", "srcset", "data-src", "data-srcset", "style", "class"],
|
| 1843 |
+
});
|
| 1844 |
+
} catch {}
|
| 1845 |
+
}
|
| 1846 |
+
|
| 1847 |
+
function mdGetKeyForImg(img) {
|
| 1848 |
+
const k = String(img?.dataset?.tpOriginalKey || "");
|
| 1849 |
+
if (k) return k;
|
| 1850 |
+
const u = String(img?.dataset?.tpOriginal || "");
|
| 1851 |
+
return mdKeyFromUrl(u);
|
| 1852 |
+
}
|
| 1853 |
+
|
| 1854 |
+
function upsertMangaDexHtmlOverlay(mdKey, img, baseW, baseH, kind) {
|
| 1855 |
+
let rec = mdHtmlOverlaysByKey.get(mdKey);
|
| 1856 |
+
if (!rec) {
|
| 1857 |
+
const host = document.createElement("div");
|
| 1858 |
+
host.className = "tp-ol-root";
|
| 1859 |
+
host.style.zIndex = 2147483647;
|
| 1860 |
+
host.style.pointerEvents = "none";
|
| 1861 |
+
host.style.display = "none";
|
| 1862 |
+
const scope = document.createElement("div");
|
| 1863 |
+
scope.className = "tp-ol-scope";
|
| 1864 |
+
host.appendChild(scope);
|
| 1865 |
+
rec = { host, scope, img: null, baseW: 1, baseH: 1, kind: "html" };
|
| 1866 |
+
mdHtmlOverlaysByKey.set(mdKey, rec);
|
| 1867 |
+
ensureMangaDexOverlayListeners();
|
| 1868 |
+
}
|
| 1869 |
+
|
| 1870 |
+
if (img) ensureOverlayHostMountedNearImage(rec, img);
|
| 1871 |
+
rec.img = img;
|
| 1872 |
+
rec.baseW = Number.isFinite(baseW) && baseW > 0 ? baseW : 1;
|
| 1873 |
+
rec.baseH = Number.isFinite(baseH) && baseH > 0 ? baseH : 1;
|
| 1874 |
+
rec.kind = kind || "html";
|
| 1875 |
+
return rec;
|
| 1876 |
+
}
|
| 1877 |
+
|
| 1878 |
+
function hideMangaDexHtmlOverlay(mdKey) {
|
| 1879 |
+
const rec = mdHtmlOverlaysByKey.get(mdKey);
|
| 1880 |
+
if (rec?.cleanImg) rec.cleanImg.style.display = "none";
|
| 1881 |
+
if (rec?.host) rec.host.style.display = "none";
|
| 1882 |
+
}
|
| 1883 |
+
|
| 1884 |
+
async function replaceMangaDexImageWithOverlay(original, newSrc) {
|
| 1885 |
+
const mdKey = mdKeyFromUrl(original);
|
| 1886 |
+
if (!mdKey) return 0;
|
| 1887 |
+
|
| 1888 |
+
let img = findMangaDexImgByKey(mdKey);
|
| 1889 |
+
if (!img) img = findTargetImage(original);
|
| 1890 |
+
if (!img) {
|
| 1891 |
+
log.warn("REPLACE_IMAGE target not found", {
|
| 1892 |
+
original: truncate(original),
|
| 1893 |
+
});
|
| 1894 |
+
return 0;
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
+
img.dataset.tpOriginalKey = mdKey;
|
| 1898 |
+
const key = _normUrl(original);
|
| 1899 |
+
if (key) img.dataset.tpOriginal = key;
|
| 1900 |
+
noteReplaceState(original, "pending");
|
| 1901 |
+
|
| 1902 |
+
let rec = mdOverlaysByKey.get(mdKey);
|
| 1903 |
+
if (!rec) {
|
| 1904 |
+
const el = document.createElement("img");
|
| 1905 |
+
el.decoding = "sync";
|
| 1906 |
+
el.loading = "eager";
|
| 1907 |
+
el.style.position = "absolute";
|
| 1908 |
+
el.style.zIndex = 2147483647;
|
| 1909 |
+
el.style.pointerEvents = "none";
|
| 1910 |
+
el.style.objectFit = "contain";
|
| 1911 |
+
el.style.maxWidth = "none";
|
| 1912 |
+
el.style.maxHeight = "none";
|
| 1913 |
+
el.style.display = "none";
|
| 1914 |
+
document.documentElement.appendChild(el);
|
| 1915 |
+
rec = { el, img: null, blobUrl: "", original: "" };
|
| 1916 |
+
mdOverlaysByKey.set(mdKey, rec);
|
| 1917 |
+
ensureMangaDexOverlayListeners();
|
| 1918 |
+
el.addEventListener(
|
| 1919 |
+
"load",
|
| 1920 |
+
() => {
|
| 1921 |
+
const okKey = rec.original;
|
| 1922 |
+
if (okKey)
|
| 1923 |
+
replaceStateByOriginal.set(okKey, { state: "ok", ts: Date.now() });
|
| 1924 |
+
},
|
| 1925 |
+
{ passive: true },
|
| 1926 |
+
);
|
| 1927 |
+
el.addEventListener(
|
| 1928 |
+
"error",
|
| 1929 |
+
() => {
|
| 1930 |
+
const errKey = rec.original;
|
| 1931 |
+
if (errKey) {
|
| 1932 |
+
replaceStateByOriginal.set(errKey, {
|
| 1933 |
+
state: "fail",
|
| 1934 |
+
ts: Date.now(),
|
| 1935 |
+
});
|
| 1936 |
+
markImageError(errKey, "Failed to load replaced image");
|
| 1937 |
+
}
|
| 1938 |
+
},
|
| 1939 |
+
{ passive: true },
|
| 1940 |
+
);
|
| 1941 |
+
}
|
| 1942 |
+
|
| 1943 |
+
rec.img = img;
|
| 1944 |
+
rec.original = _normUrl(original);
|
| 1945 |
+
|
| 1946 |
+
let nextSrc = newSrc;
|
| 1947 |
+
if (typeof newSrc === "string" && newSrc.startsWith("data:")) {
|
| 1948 |
+
const blobUrl = await dataUriToBlobUrl(newSrc);
|
| 1949 |
+
if (blobUrl) nextSrc = blobUrl;
|
| 1950 |
+
}
|
| 1951 |
+
|
| 1952 |
+
if (
|
| 1953 |
+
rec.blobUrl &&
|
| 1954 |
+
rec.blobUrl.startsWith("blob:") &&
|
| 1955 |
+
rec.blobUrl !== nextSrc
|
| 1956 |
+
) {
|
| 1957 |
+
try {
|
| 1958 |
+
URL.revokeObjectURL(rec.blobUrl);
|
| 1959 |
+
} catch {}
|
| 1960 |
+
}
|
| 1961 |
+
rec.blobUrl =
|
| 1962 |
+
typeof nextSrc === "string" && nextSrc.startsWith("blob:") ? nextSrc : "";
|
| 1963 |
+
rec.el.src = nextSrc;
|
| 1964 |
+
|
| 1965 |
+
scheduleMangaDexOverlayUpdate();
|
| 1966 |
+
return 1;
|
| 1967 |
+
}
|
| 1968 |
+
|
| 1969 |
+
async function replaceImageInDOM(original, newSrc) {
|
| 1970 |
+
if (isMangaDexHost() && mdKeyFromUrl(original))
|
| 1971 |
+
return replaceMangaDexImageWithOverlay(original, newSrc);
|
| 1972 |
+
|
| 1973 |
+
const img = findTargetImage(original);
|
| 1974 |
+
if (!img) {
|
| 1975 |
+
log.warn("REPLACE_IMAGE target not found", {
|
| 1976 |
+
original: truncate(original),
|
| 1977 |
+
});
|
| 1978 |
+
return 0;
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
const mdKey = mdKeyFromUrl(original);
|
| 1982 |
+
if (mdKey) img.dataset.tpOriginalKey = mdKey;
|
| 1983 |
+
const key = _normUrl(original);
|
| 1984 |
+
if (key) img.dataset.tpOriginal = key;
|
| 1985 |
+
noteReplaceState(original, "pending");
|
| 1986 |
+
|
| 1987 |
+
if (!img.dataset.tpReplaceTracked) {
|
| 1988 |
+
img.dataset.tpReplaceTracked = "1";
|
| 1989 |
+
img.addEventListener(
|
| 1990 |
+
"load",
|
| 1991 |
+
() => {
|
| 1992 |
+
const k = _normUrl(img.dataset.tpOriginal);
|
| 1993 |
+
if (k) replaceStateByOriginal.set(k, { state: "ok", ts: Date.now() });
|
| 1994 |
+
},
|
| 1995 |
+
{ passive: true },
|
| 1996 |
+
);
|
| 1997 |
+
img.addEventListener(
|
| 1998 |
+
"error",
|
| 1999 |
+
() => {
|
| 2000 |
+
const k = _normUrl(img.dataset.tpOriginal);
|
| 2001 |
+
if (k) {
|
| 2002 |
+
replaceStateByOriginal.set(k, { state: "fail", ts: Date.now() });
|
| 2003 |
+
markImageError(k, "Failed to load replaced image");
|
| 2004 |
+
}
|
| 2005 |
+
},
|
| 2006 |
+
{ passive: true },
|
| 2007 |
+
);
|
| 2008 |
+
}
|
| 2009 |
+
const before = img.currentSrc || img.src;
|
| 2010 |
+
|
| 2011 |
+
let nextSrc = newSrc;
|
| 2012 |
+
if (typeof newSrc === "string" && newSrc.startsWith("data:")) {
|
| 2013 |
+
const blobUrl = await dataUriToBlobUrl(newSrc);
|
| 2014 |
+
if (blobUrl) nextSrc = blobUrl;
|
| 2015 |
+
}
|
| 2016 |
+
|
| 2017 |
+
const prevBlob = img.dataset.tpBlobUrl;
|
| 2018 |
+
if (prevBlob && prevBlob.startsWith("blob:")) {
|
| 2019 |
+
try {
|
| 2020 |
+
URL.revokeObjectURL(prevBlob);
|
| 2021 |
+
} catch {}
|
| 2022 |
+
delete img.dataset.tpBlobUrl;
|
| 2023 |
+
}
|
| 2024 |
+
if (typeof nextSrc === "string" && nextSrc.startsWith("blob:")) {
|
| 2025 |
+
img.dataset.tpBlobUrl = nextSrc;
|
| 2026 |
+
}
|
| 2027 |
+
|
| 2028 |
+
img.src = nextSrc;
|
| 2029 |
+
img.removeAttribute("srcset");
|
| 2030 |
+
img.removeAttribute("sizes");
|
| 2031 |
+
img.removeAttribute("data-src");
|
| 2032 |
+
img.removeAttribute("data-srcset");
|
| 2033 |
+
img.removeAttribute("loading");
|
| 2034 |
+
img.decoding = "sync";
|
| 2035 |
+
img.loading = "eager";
|
| 2036 |
+
emitViewerEvent("textphantom:image-updated", {
|
| 2037 |
+
original,
|
| 2038 |
+
newSrc: nextSrc,
|
| 2039 |
+
rawNewSrc: newSrc,
|
| 2040 |
+
});
|
| 2041 |
+
log.info("REPLACE_IMAGE done", {
|
| 2042 |
+
before: truncate(before),
|
| 2043 |
+
original: truncate(original),
|
| 2044 |
+
});
|
| 2045 |
+
return 1;
|
| 2046 |
+
}
|
| 2047 |
+
|
| 2048 |
+
chrome.runtime.onMessage.addListener((msg, _sender, sendResp) => {
|
| 2049 |
+
(async () => {
|
| 2050 |
+
if (msg?.type === "TP_PING") {
|
| 2051 |
+
sendResp({ ok: true });
|
| 2052 |
+
return;
|
| 2053 |
+
}
|
| 2054 |
+
if (msg?.type === "TP_KEEPALIVE_START") {
|
| 2055 |
+
startKeepAlive(msg?.ms);
|
| 2056 |
+
sendResp({ ok: true });
|
| 2057 |
+
return;
|
| 2058 |
+
}
|
| 2059 |
+
if (msg?.type === "TP_KEEPALIVE_STOP") {
|
| 2060 |
+
stopKeepAlive();
|
| 2061 |
+
sendResp({ ok: true });
|
| 2062 |
+
return;
|
| 2063 |
+
}
|
| 2064 |
+
if (msg?.type === "TP_TOAST") {
|
| 2065 |
+
showToast(msg?.text || msg?.message || "", msg?.ms || 1600);
|
| 2066 |
+
sendResp({ ok: true });
|
| 2067 |
+
return;
|
| 2068 |
+
}
|
| 2069 |
+
const { mode, lang } = await getSettings();
|
| 2070 |
+
try {
|
| 2071 |
+
log.debug("MSG", {
|
| 2072 |
+
type: msg?.type,
|
| 2073 |
+
original: truncate(msg?.original),
|
| 2074 |
+
});
|
| 2075 |
+
} catch {}
|
| 2076 |
+
if (
|
| 2077 |
+
msg?.type === "WS_STATUS_UPDATE" ||
|
| 2078 |
+
msg?.type === "API_STATUS_UPDATE"
|
| 2079 |
+
) {
|
| 2080 |
+
sendResp({ ok: true });
|
| 2081 |
+
return;
|
| 2082 |
+
}
|
| 2083 |
+
|
| 2084 |
+
if (msg.type === "GET_IMAGES") {
|
| 2085 |
+
removeLazyScriptsAndForceSrc();
|
| 2086 |
+
normalizeLazyImages();
|
| 2087 |
+
let infos = [];
|
| 2088 |
+
const isMangaDex = isMangaDexHost();
|
| 2089 |
+
|
| 2090 |
+
if (isMangaDex) {
|
| 2091 |
+
showToast("TextPhantom: loading MangaDex pages…", 2600);
|
| 2092 |
+
scheduleMangaDexMapping();
|
| 2093 |
+
await ensureMangaDexDomMapping();
|
| 2094 |
+
const hyd = await hydrateMangaDexFromCache().catch(() => null);
|
| 2095 |
+
const mdCacheItems = hyd?.items || null;
|
| 2096 |
+
const wantsHtml = String(mode || "").includes("text");
|
| 2097 |
+
const isCachedUrl = (src) => {
|
| 2098 |
+
if (!mdCacheItems) return false;
|
| 2099 |
+
const k = mdKeyFromUrl(String(src || ""));
|
| 2100 |
+
if (!k) return false;
|
| 2101 |
+
const rec = mdCacheItems[k];
|
| 2102 |
+
if (!rec) return false;
|
| 2103 |
+
return wantsHtml ? Boolean(rec.result) : Boolean(rec.hasNewImg);
|
| 2104 |
+
};
|
| 2105 |
+
const seen = new Set();
|
| 2106 |
+
const posBySrc = new Map();
|
| 2107 |
+
getMangaDexPageImagesInDOM().forEach((img) => {
|
| 2108 |
+
const src = _normUrl(getBestImgUrl(img));
|
| 2109 |
+
if (!isHttpish(src) || isCachedUrl(src)) return;
|
| 2110 |
+
if (!posBySrc.has(src))
|
| 2111 |
+
posBySrc.set(src, buildPositionFromElement(img));
|
| 2112 |
+
});
|
| 2113 |
+
|
| 2114 |
+
const md = await fetchMangaDexChapterUrls();
|
| 2115 |
+
const urls = Array.isArray(md?.urls) ? md.urls : [];
|
| 2116 |
+
const all = [];
|
| 2117 |
+
|
| 2118 |
+
for (const src of urls) {
|
| 2119 |
+
const u = _normUrl(src);
|
| 2120 |
+
if (!isHttpish(u) || isCachedUrl(u) || seen.has(u)) continue;
|
| 2121 |
+
seen.add(u);
|
| 2122 |
+
all.push(
|
| 2123 |
+
buildPayload(
|
| 2124 |
+
{ original_image_url: u, position: posBySrc.get(u) || null },
|
| 2125 |
+
mode,
|
| 2126 |
+
lang,
|
| 2127 |
+
"page_scan",
|
| 2128 |
+
"collected_mangadex_api",
|
| 2129 |
+
),
|
| 2130 |
+
);
|
| 2131 |
+
}
|
| 2132 |
+
|
| 2133 |
+
for (const [src, pos] of posBySrc.entries()) {
|
| 2134 |
+
if (!isHttpish(src) || isCachedUrl(src) || seen.has(src)) continue;
|
| 2135 |
+
seen.add(src);
|
| 2136 |
+
all.push(
|
| 2137 |
+
buildPayload(
|
| 2138 |
+
{ original_image_url: src, position: pos || null },
|
| 2139 |
+
mode,
|
| 2140 |
+
lang,
|
| 2141 |
+
"page_scan",
|
| 2142 |
+
"collected_mangadex_dom",
|
| 2143 |
+
),
|
| 2144 |
+
);
|
| 2145 |
+
}
|
| 2146 |
+
|
| 2147 |
+
infos = all.filter(Boolean);
|
| 2148 |
+
} else {
|
| 2149 |
+
infos = await collectImagesForScan(mode, lang, "page_scan");
|
| 2150 |
+
}
|
| 2151 |
+
|
| 2152 |
+
log.info("GET_IMAGES", {
|
| 2153 |
+
returned: infos.length,
|
| 2154 |
+
host: location.host,
|
| 2155 |
+
href: location.href,
|
| 2156 |
+
});
|
| 2157 |
+
sendResp(infos);
|
| 2158 |
+
return;
|
| 2159 |
+
}
|
| 2160 |
+
|
| 2161 |
+
if (msg.type === "GET_CONTEXT_IMAGE_PAYLOAD") {
|
| 2162 |
+
const img =
|
| 2163 |
+
__tp_lastRightClick.img && __tp_lastRightClick.img.isConnected
|
| 2164 |
+
? __tp_lastRightClick.img
|
| 2165 |
+
: null;
|
| 2166 |
+
const payload = img
|
| 2167 |
+
? await buildPayloadFromImage(
|
| 2168 |
+
img,
|
| 2169 |
+
mode,
|
| 2170 |
+
lang,
|
| 2171 |
+
"img_one",
|
| 2172 |
+
"context_menu_single",
|
| 2173 |
+
true,
|
| 2174 |
+
)
|
| 2175 |
+
: null;
|
| 2176 |
+
sendResp({ ok: Boolean(payload), payload });
|
| 2177 |
+
return;
|
| 2178 |
+
}
|
| 2179 |
+
|
| 2180 |
+
if (msg.type === "REPLACE_IMAGE") {
|
| 2181 |
+
const { original, newSrc } = msg;
|
| 2182 |
+
log.info("REPLACE_IMAGE request", { original: truncate(original) });
|
| 2183 |
+
const applied = await replaceImageInDOM(original, newSrc);
|
| 2184 |
+
if (!applied && isMangaDexHost())
|
| 2185 |
+
mdRememberPending(original, { newSrc });
|
| 2186 |
+
sendResp({ ok: true, applied: !!applied });
|
| 2187 |
+
return;
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
if (msg.type === "RESOLVE_AND_REPLACE_MANGADEX_BLOB") {
|
| 2191 |
+
const real = await resolveMangaDexOriginalForBlob(msg.blobUrl);
|
| 2192 |
+
sendResp({ resolved: real });
|
| 2193 |
+
return;
|
| 2194 |
+
}
|
| 2195 |
+
|
| 2196 |
+
if (msg.type === "IMAGE_ERROR") {
|
| 2197 |
+
const original = msg.original;
|
| 2198 |
+
const message = msg.message;
|
| 2199 |
+
const isNoOverlay = /no overlay data/i.test(String(message || ""));
|
| 2200 |
+
setTimeout(() => {
|
| 2201 |
+
if (isNoOverlay)
|
| 2202 |
+
log.warn("overlay.noData", { original: truncate(original) });
|
| 2203 |
+
if (shouldShowReplaceError(original)) {
|
| 2204 |
+
markImageError(
|
| 2205 |
+
original,
|
| 2206 |
+
isNoOverlay ? "No text detected" : message,
|
| 2207 |
+
);
|
| 2208 |
+
} else {
|
| 2209 |
+
log.debug("IMAGE_ERROR skipped (image replaced)", {
|
| 2210 |
+
original: truncate(original),
|
| 2211 |
+
});
|
| 2212 |
+
}
|
| 2213 |
+
}, 1200);
|
| 2214 |
+
sendResp({ ok: true });
|
| 2215 |
+
return;
|
| 2216 |
+
}
|
| 2217 |
+
|
| 2218 |
+
if (msg.type === "OVERLAY_HTML") {
|
| 2219 |
+
try {
|
| 2220 |
+
const mode = typeof msg?.mode === "string" ? msg.mode : "";
|
| 2221 |
+
if (!mode) {
|
| 2222 |
+
log.warn("OVERLAY_HTML missing mode", {
|
| 2223 |
+
original: truncate(msg.original),
|
| 2224 |
+
});
|
| 2225 |
+
sendResp({ ok: true, ignored: true });
|
| 2226 |
+
return;
|
| 2227 |
+
}
|
| 2228 |
+
const isText = mode === "lens_text";
|
| 2229 |
+
const source = isText
|
| 2230 |
+
? String(msg?.source || "")
|
| 2231 |
+
.trim()
|
| 2232 |
+
.toLowerCase()
|
| 2233 |
+
: "translated";
|
| 2234 |
+
if (isText && !source) {
|
| 2235 |
+
log.warn("OVERLAY_HTML missing source", {
|
| 2236 |
+
original: truncate(msg.original),
|
| 2237 |
+
});
|
| 2238 |
+
sendResp({ ok: true, ignored: true });
|
| 2239 |
+
return;
|
| 2240 |
+
}
|
| 2241 |
+
const img = findTargetImage(msg.original);
|
| 2242 |
+
|
| 2243 |
+
if (img) {
|
| 2244 |
+
applyHtmlOverlay(img, msg.result, source, isText, msg.original);
|
| 2245 |
+
log.info("OVERLAY_HTML applied", {
|
| 2246 |
+
original: truncate(msg.original),
|
| 2247 |
+
});
|
| 2248 |
+
} else if (isMangaDexHost()) {
|
| 2249 |
+
mdRememberPending(msg.original, {
|
| 2250 |
+
overlay: { result: msg.result, source, isTextMode: isText },
|
| 2251 |
+
});
|
| 2252 |
+
}
|
| 2253 |
+
} catch (e) {
|
| 2254 |
+
console.warn("[LensOCR content] OVERLAY_HTML failed", e);
|
| 2255 |
+
}
|
| 2256 |
+
sendResp({ ok: true });
|
| 2257 |
+
return;
|
| 2258 |
+
}
|
| 2259 |
+
sendResp({ ok: true, ignored: true });
|
| 2260 |
+
})();
|
| 2261 |
+
return true;
|
| 2262 |
+
});
|
| 2263 |
+
})();
|
__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[](https://github.com/sponsors/Kuju29)
|
| 2 |
+
|
| 3 |
+
*Note: ผมทำอีกโปรเจคอยู่ เดี๋ยวกลับมาอัพเดตแก้ปัญหาที่มีอยู่ครับ มันต้องไปแก้ตั้งแต่การแกะโครงสร้างเวลา ai แปลเลยต้องรอให้ว่างจริงๆ อัพเดตรอบต่อไปจะใช้ claude ai โค๊ดขยะน่าจะน้อยลงและเป็นมิตรกับระบบมากขึ้น*
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+
|
| 7 |
+
# ✨ TextPhantomOCR Overlay
|
| 8 |
+
[](https://www.youtube.com/watch?v=WQ7p7zsz_kc "Installation Guide") [](https://www.youtube.com/watch?v=NMHwaI8mn4c "Run API Online")
|
| 9 |
+
|
| 10 |
+
## 📥 Download & Install Options
|
| 11 |
+
|
| 12 |
+
| Version | Extension Download | API Download | Note |
|
| 13 |
+
|----------|-------------------|--------------|------|
|
| 14 |
+
| [Chrome Web Store](https://chromewebstore.google.com/detail/cjbaepobgmickhgebgagklfcfacbbpem?utm_source=item-share-cb) | [](https://chromewebstore.google.com/detail/cjbaepobgmickhgebgagklfcfacbbpem?utm_source=item-share-cb) | [](https://huggingface.co/spaces/plan291037/TextPhantom_OCR_API2/tree/main) | Easiest option, but still requires API |
|
| 15 |
+
| **[v3.0.0](https://github.com/Kuju29/TextPhantomOCR_Overlay/tree/v3.0.0)** | [](https://github.com/Kuju29/TextPhantomOCR_Overlay/archive/refs/heads/main.zip) | [](https://huggingface.co/spaces/plan291037/TextPhantom_OCR_API2/tree/main) | Manual install |
|
| 16 |
+
| **[v2.0.2](https://github.com/Kuju29/TextPhantomOCR_Overlay/tree/backup_V2.0.2)** | [](https://github.com/Kuju29/TextPhantomOCR_Overlay/archive/refs/heads/main.zip) | [](https://github.com/Kuju29/TextPhantomOCR_Overlay/releases/tag/v2.0.2) | Manual install |
|
| 17 |
+
| **[v1.0.1](https://github.com/Kuju29/TextPhantomOCR_Overlay/tree/v1.0.1)** | [](https://github.com/Kuju29/TextPhantomOCR_Overlay/archive/refs/tags/v1.0.1.zip) | [](https://github.com/Kuju29/TextPhantomOCR_Overlay/releases/tag/v1.0.1) | Manual install |
|
| 18 |
+
|
| 19 |
+
## 🧩 1. Extension Installation
|
| 20 |
+
|
| 21 |
+
#### **Option – Chrome Web Store**
|
| 22 |
+
1. Install from [Chrome Web Store](https://chromewebstore.google.com/detail/cjbaepobgmickhgebgagklfcfacbbpem?utm_source=item-share-cb).
|
| 23 |
+
|
| 24 |
+
#### **Option – Manual Install**
|
| 25 |
+
1. Download and extract the **Source Code** for your version.
|
| 26 |
+
2. Open Chrome → Go to `chrome://extensions/`.
|
| 27 |
+
3. Enable **Developer Mode** (top right).
|
| 28 |
+
4. Click **Load unpacked**.
|
| 29 |
+
5. Select the extracted folder.
|
| 30 |
+
|
| 31 |
+
## 🌐 2. Using the API on Hugging Face (Run API for v3.0.0)
|
| 32 |
+
1. Go to: [https://huggingface.co/spaces/plan291037/TextPhantom_OCR_API2/tree/main](https://huggingface.co/spaces/plan291037/TextPhantom_OCR_API2/tree/main)
|
| 33 |
+
2. Click the **three dots** in the top-right and select **Duplicate this Space**.
|
| 34 |
+
3. The build will start automatically — wait until you see **Running**.
|
| 35 |
+
4. Click the **three dots** again and choose **Embed this Space**.
|
| 36 |
+
5. Copy the link and use it in your API extension.
|
| 37 |
+
|
| 38 |
+
**Notes:**
|
| 39 |
+
* If unused, the Space will shut down automatically within 48 hours.
|
| 40 |
+
* To stop it manually, go to **Settings** and click the small **Pause Space** link.
|
| 41 |
+
* This API works only in **images** mode.
|
| 42 |
+
|
| 43 |
+
## 🖱️ 3. Getting Started with the Extension
|
| 44 |
+
- Navigate to the page you want to translate.
|
| 45 |
+
- **Right-click** anywhere → choose **🔍 Translate...** from the context menu.
|
| 46 |
+
|
| 47 |
+
## 🤖 Free AI Option (Hugging Face – Google Gemma)
|
| 48 |
+
|
| 49 |
+
TextPhantom can use **Google Gemma** models via Hugging Face.
|
| 50 |
+
This option is **free to start** (you use your own Hugging Face API key).
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
#### Step 1: Enable Access to Gemma Model
|
| 54 |
+
|
| 55 |
+
Google Gemma models require accepting a license before use.
|
| 56 |
+
|
| 57 |
+
1. Log in to Hugging Face
|
| 58 |
+
https://huggingface.co/login
|
| 59 |
+
|
| 60 |
+
2. Open the model page
|
| 61 |
+
https://huggingface.co/google/gemma-3-27b-it
|
| 62 |
+
|
| 63 |
+
3. Click **“Agree and access”** (or **“Request access”**)
|
| 64 |
+
|
| 65 |
+
4. Wait for approval (usually a few minutes)
|
| 66 |
+
|
| 67 |
+
> You only need to do this **once per account**.
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
#### Step 2: Create a Hugging Face API Key
|
| 71 |
+
|
| 72 |
+
This API key will be used by TextPhantom to call the AI model.
|
| 73 |
+
|
| 74 |
+
1. Go to
|
| 75 |
+
https://huggingface.co/settings/tokens
|
| 76 |
+
|
| 77 |
+
2. Click **New token**
|
| 78 |
+
|
| 79 |
+
3. Set:
|
| 80 |
+
- **Name**: `textphantom-ai`
|
| 81 |
+
- **Role**: `Read`
|
| 82 |
+
|
| 83 |
+
4. Create the token and **copy it**
|
| 84 |
+
5. Required Token Permissions
|
| 85 |
+
When creating your Hugging Face API key, make sure the following permissions are enabled:
|
| 86 |
+
|
| 87 |
+
- ✅ **Read access to contents of all public gated repos you can access**
|
| 88 |
+
Required to use gated models such as **Google Gemma**.
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
## 🖼️ DEMO / UI PREVIEW
|
| 92 |
+
|
| 93 |
+
<p align="center">
|
| 94 |
+
<img src="https://github.com/user-attachments/assets/52135c1a-ba52-46e7-9174-9fdd1cc6b26d" width="40%" alt="UI Preview" />
|
| 95 |
+
<br><br>
|
| 96 |
+
<img src="https://github.com/user-attachments/assets/6f7beaad-2f92-48bc-a8ef-776a0886a8eb" width="40%" alt="UI Preview" />
|
| 97 |
+
<br><br>
|
| 98 |
+
<img src="https://github.com/user-attachments/assets/998e8a9e-ae27-4911-9e59-aef28245f60c" width="100%" alt="Example 1" />
|
| 99 |
+
<br><br>
|
| 100 |
+
<img src="https://github.com/user-attachments/assets/205a97d9-718d-4599-8511-ccf63e30691f" width="100%" alt="Example 2" />
|
| 101 |
+
<br><br>
|
| 102 |
+
<img src="https://github.com/user-attachments/assets/92427293-8ec7-40c3-b797-a2b27fedb8a6" width="100%" alt="Example 3" />
|
| 103 |
+
</p>
|
ai_prompt.js
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeUrl } from "../shared/url.js";
|
| 2 |
+
import { ensureApiDefaults } from "../shared/api_defaults.js";
|
| 3 |
+
import { AI_PROMPT_MAX_CHARS, makePromptKey, migratePromptMap, normalizeAiModel, normalizePrompt } from "../shared/prompt.js";
|
| 4 |
+
import { filterImageFiles, saveLocalSession, sortLocalPages, toLocalPageRecord } from "../shared/local_gallery.js";
|
| 5 |
+
|
| 6 |
+
const MODES = [
|
| 7 |
+
{ id: "lens_images", name: "Google Lens (image)", needLang: true },
|
| 8 |
+
{ id: "lens_text", name: "Google Lens (text)", needLang: true },
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
const FALLBACK_LANGS = [
|
| 12 |
+
{ code: "en", name: "English" },
|
| 13 |
+
{ code: "th", name: "Thai" },
|
| 14 |
+
{ code: "ja", name: "Japanese" },
|
| 15 |
+
{ code: "ko", name: "Korean" },
|
| 16 |
+
{ code: "zh-CN", name: "Chinese (Simplified)" },
|
| 17 |
+
{ code: "zh-TW", name: "Chinese (Traditional)" },
|
| 18 |
+
{ code: "vi", name: "Vietnamese" },
|
| 19 |
+
{ code: "id", name: "Indonesian" },
|
| 20 |
+
{ code: "ms", name: "Malay" },
|
| 21 |
+
{ code: "tl", name: "Tagalog" },
|
| 22 |
+
{ code: "fil", name: "Filipino" },
|
| 23 |
+
{ code: "hi", name: "Hindi" },
|
| 24 |
+
{ code: "bn", name: "Bengali" },
|
| 25 |
+
{ code: "ur", name: "Urdu" },
|
| 26 |
+
{ code: "ta", name: "Tamil" },
|
| 27 |
+
{ code: "te", name: "Telugu" },
|
| 28 |
+
{ code: "ml", name: "Malayalam" },
|
| 29 |
+
{ code: "mr", name: "Marathi" },
|
| 30 |
+
{ code: "gu", name: "Gujarati" },
|
| 31 |
+
{ code: "kn", name: "Kannada" },
|
| 32 |
+
{ code: "pa", name: "Punjabi" },
|
| 33 |
+
{ code: "ne", name: "Nepali" },
|
| 34 |
+
{ code: "si", name: "Sinhala" },
|
| 35 |
+
{ code: "my", name: "Myanmar (Burmese)" },
|
| 36 |
+
{ code: "km", name: "Khmer" },
|
| 37 |
+
{ code: "lo", name: "Lao" },
|
| 38 |
+
{ code: "jv", name: "Javanese" },
|
| 39 |
+
{ code: "su", name: "Sundanese" },
|
| 40 |
+
{ code: "es", name: "Spanish" },
|
| 41 |
+
{ code: "fr", name: "French" },
|
| 42 |
+
{ code: "de", name: "German" },
|
| 43 |
+
{ code: "it", name: "Italian" },
|
| 44 |
+
{ code: "pt", name: "Portuguese" },
|
| 45 |
+
{ code: "nl", name: "Dutch" },
|
| 46 |
+
{ code: "pl", name: "Polish" },
|
| 47 |
+
{ code: "ro", name: "Romanian" },
|
| 48 |
+
{ code: "ru", name: "Russian" },
|
| 49 |
+
{ code: "uk", name: "Ukrainian" },
|
| 50 |
+
{ code: "cs", name: "Czech" },
|
| 51 |
+
{ code: "sk", name: "Slovak" },
|
| 52 |
+
{ code: "sl", name: "Slovenian" },
|
| 53 |
+
{ code: "hr", name: "Croatian" },
|
| 54 |
+
{ code: "sr", name: "Serbian" },
|
| 55 |
+
{ code: "bs", name: "Bosnian" },
|
| 56 |
+
{ code: "bg", name: "Bulgarian" },
|
| 57 |
+
{ code: "mk", name: "Macedonian" },
|
| 58 |
+
{ code: "el", name: "Greek" },
|
| 59 |
+
{ code: "tr", name: "Turkish" },
|
| 60 |
+
{ code: "hu", name: "Hungarian" },
|
| 61 |
+
{ code: "fi", name: "Finnish" },
|
| 62 |
+
{ code: "sv", name: "Swedish" },
|
| 63 |
+
{ code: "da", name: "Danish" },
|
| 64 |
+
{ code: "no", name: "Norwegian" },
|
| 65 |
+
{ code: "et", name: "Estonian" },
|
| 66 |
+
{ code: "lv", name: "Latvian" },
|
| 67 |
+
{ code: "lt", name: "Lithuanian" },
|
| 68 |
+
{ code: "is", name: "Icelandic" },
|
| 69 |
+
{ code: "ga", name: "Irish" },
|
| 70 |
+
{ code: "cy", name: "Welsh" },
|
| 71 |
+
{ code: "mt", name: "Maltese" },
|
| 72 |
+
{ code: "sq", name: "Albanian" },
|
| 73 |
+
{ code: "hy", name: "Armenian" },
|
| 74 |
+
{ code: "ka", name: "Georgian" },
|
| 75 |
+
{ code: "az", name: "Azerbaijani" },
|
| 76 |
+
{ code: "kk", name: "Kazakh" },
|
| 77 |
+
{ code: "ky", name: "Kyrgyz" },
|
| 78 |
+
{ code: "tg", name: "Tajik" },
|
| 79 |
+
{ code: "uz", name: "Uzbek" },
|
| 80 |
+
{ code: "tk", name: "Turkmen" },
|
| 81 |
+
{ code: "mn", name: "Mongolian" },
|
| 82 |
+
{ code: "ar", name: "Arabic" },
|
| 83 |
+
{ code: "fa", name: "Persian" },
|
| 84 |
+
{ code: "iw", name: "Hebrew" },
|
| 85 |
+
{ code: "ps", name: "Pashto" },
|
| 86 |
+
{ code: "ug", name: "Uyghur" },
|
| 87 |
+
{ code: "ku", name: "Kurdish (Kurmanji)" },
|
| 88 |
+
{ code: "sw", name: "Swahili" },
|
| 89 |
+
{ code: "am", name: "Amharic" },
|
| 90 |
+
{ code: "ha", name: "Hausa" },
|
| 91 |
+
{ code: "ig", name: "Igbo" },
|
| 92 |
+
{ code: "yo", name: "Yoruba" },
|
| 93 |
+
{ code: "zu", name: "Zulu" },
|
| 94 |
+
{ code: "xh", name: "Xhosa" },
|
| 95 |
+
{ code: "so", name: "Somali" },
|
| 96 |
+
{ code: "rw", name: "Kinyarwanda" },
|
| 97 |
+
{ code: "mg", name: "Malagasy" },
|
| 98 |
+
{ code: "af", name: "Afrikaans" },
|
| 99 |
+
{ code: "ca", name: "Catalan" },
|
| 100 |
+
{ code: "eu", name: "Basque" },
|
| 101 |
+
{ code: "gl", name: "Galician" },
|
| 102 |
+
{ code: "eo", name: "Esperanto" },
|
| 103 |
+
{ code: "be", name: "Belarusian" },
|
| 104 |
+
{ code: "ceb", name: "Cebuano" },
|
| 105 |
+
{ code: "co", name: "Corsican" },
|
| 106 |
+
{ code: "fy", name: "Frisian" },
|
| 107 |
+
{ code: "haw", name: "Hawaiian" },
|
| 108 |
+
{ code: "hmn", name: "Hmong" },
|
| 109 |
+
{ code: "ht", name: "Haitian Creole" },
|
| 110 |
+
{ code: "lb", name: "Luxembourgish" },
|
| 111 |
+
{ code: "la", name: "Latin" },
|
| 112 |
+
{ code: "mi", name: "Maori" },
|
| 113 |
+
{ code: "or", name: "Odia (Oriya)" },
|
| 114 |
+
{ code: "gd", name: "Scots Gaelic" },
|
| 115 |
+
{ code: "sm", name: "Samoan" },
|
| 116 |
+
{ code: "sn", name: "Shona" },
|
| 117 |
+
{ code: "st", name: "Sesotho" },
|
| 118 |
+
{ code: "sd", name: "Sindhi" },
|
| 119 |
+
{ code: "tt", name: "Tatar" },
|
| 120 |
+
{ code: "yi", name: "Yiddish" },
|
| 121 |
+
{ code: "ny", name: "Chichewa" }
|
| 122 |
+
];
|
| 123 |
+
|
| 124 |
+
const FALLBACK_SOURCES = [
|
| 125 |
+
{ id: "original", name: "Original" },
|
| 126 |
+
{ id: "translated", name: "Translated" },
|
| 127 |
+
{ id: "ai", name: "Ai" },
|
| 128 |
+
];
|
| 129 |
+
|
| 130 |
+
const HEALTH_PATH = "/health";
|
| 131 |
+
const META_PATH = "/meta";
|
| 132 |
+
const AI_RESOLVE_PATH = "/ai/resolve";
|
| 133 |
+
const AI_PROMPT_DEFAULT_PATH = "/ai/prompt/default";
|
| 134 |
+
const WARMUP_PATH = "/warmup";
|
| 135 |
+
|
| 136 |
+
const HEALTH_TIMEOUT_MS = 5000;
|
| 137 |
+
const AI_META_TIMEOUT_MS = 8000;
|
| 138 |
+
const PROMPT_TIMEOUT_MS = 8000;
|
| 139 |
+
const WARMUP_TIMEOUT_MS = 2500;
|
| 140 |
+
const RETRY_DELAYS_MS = [600, 1200, 2500, 5000];
|
| 141 |
+
|
| 142 |
+
const modeSel = document.getElementById("mode");
|
| 143 |
+
const langSel = document.getElementById("lang");
|
| 144 |
+
const sourcesSel = document.getElementById("sources");
|
| 145 |
+
const langWrap = document.getElementById("lang-wrap");
|
| 146 |
+
const sourcesWrap = document.getElementById("sources-wrap");
|
| 147 |
+
const aiKeyWrap = document.getElementById("ai-key-wrap");
|
| 148 |
+
const aiKeyInput = document.getElementById("ai-key");
|
| 149 |
+
const aiModelWrap = document.getElementById("ai-model-wrap");
|
| 150 |
+
const aiModelSel = document.getElementById("ai-model");
|
| 151 |
+
const aiPromptWrap = document.getElementById("ai-prompt-wrap");
|
| 152 |
+
const aiPromptInput = document.getElementById("ai-prompt");
|
| 153 |
+
const aiPromptCountEl = document.getElementById("ai-prompt-count");
|
| 154 |
+
const aiPromptResetBtn = document.getElementById("ai-prompt-reset");
|
| 155 |
+
const apiInput = document.getElementById("api-url");
|
| 156 |
+
const emojiEl = document.getElementById("api-status-emoji");
|
| 157 |
+
const resetBtn = document.getElementById("reset-api");
|
| 158 |
+
const openLocalImagesBtn = document.getElementById("open-local-images");
|
| 159 |
+
const openLocalFolderBtn = document.getElementById("open-local-folder");
|
| 160 |
+
const localImagesInput = document.getElementById("local-images-input");
|
| 161 |
+
const localFolderInput = document.getElementById("local-folder-input");
|
| 162 |
+
|
| 163 |
+
const runStatusTextEl = document.getElementById("run-status-text");
|
| 164 |
+
const runProgressEl = document.querySelector(".run-progress");
|
| 165 |
+
const runProgressBarEl = document.getElementById("run-progress-bar");
|
| 166 |
+
const runStatusSubEl = document.getElementById("run-status-sub");
|
| 167 |
+
|
| 168 |
+
aiPromptResetBtn?.addEventListener("click", async () => {
|
| 169 |
+
await resetPromptForLang(langSel.value);
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
let userInteractedApi = false;
|
| 173 |
+
let lastApiOk = false;
|
| 174 |
+
let lastSavedApiUrl = "";
|
| 175 |
+
let retryTimer = null;
|
| 176 |
+
let metaCache = null;
|
| 177 |
+
let modelDirty = false;
|
| 178 |
+
let aiMetaSeq = 0;
|
| 179 |
+
let promptSeq = 0;
|
| 180 |
+
let lastResolvedProvider = "";
|
| 181 |
+
let lastResolvedKey = "";
|
| 182 |
+
|
| 183 |
+
let desiredLang = "en";
|
| 184 |
+
let desiredSources = "translated";
|
| 185 |
+
let desiredAiModel = "auto";
|
| 186 |
+
|
| 187 |
+
let aiPromptByLangState = {};
|
| 188 |
+
let aiPromptDefaultsByLang = {};
|
| 189 |
+
let aiPromptDirtyByLang = {};
|
| 190 |
+
|
| 191 |
+
let pendingApiSave = false;
|
| 192 |
+
let pendingAiSave = false;
|
| 193 |
+
let aiResolveDebounce = null;
|
| 194 |
+
|
| 195 |
+
let apiDefaults = { defaultApiUrl: "", resetApiUrl: "", fetchedAt: 0 };
|
| 196 |
+
|
| 197 |
+
const inFlight = {
|
| 198 |
+
seq: 0,
|
| 199 |
+
controller: null,
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
function updatePromptCount(text = null) {
|
| 203 |
+
if (!aiPromptCountEl) return;
|
| 204 |
+
const s = typeof text === "string" ? text : String(aiPromptInput?.value || "");
|
| 205 |
+
aiPromptCountEl.textContent = `${s.length}/${AI_PROMPT_MAX_CHARS}`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function buildUrl(path, params = null) {
|
| 209 |
+
const base = normalizeUrl(apiInput?.value);
|
| 210 |
+
if (!base) return "";
|
| 211 |
+
const u = new URL(`${base}${path}`);
|
| 212 |
+
if (params && typeof params === "object") {
|
| 213 |
+
for (const [k, v] of Object.entries(params)) {
|
| 214 |
+
const vv = String(v ?? "").trim();
|
| 215 |
+
if (vv) u.searchParams.set(k, vv);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
return u.toString();
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function setEmojiStatus(type, detail) {
|
| 222 |
+
if (!emojiEl) return;
|
| 223 |
+
if (type === "loading") {
|
| 224 |
+
emojiEl.textContent = "⏳";
|
| 225 |
+
emojiEl.title = detail || "Checking API...";
|
| 226 |
+
} else if (type === "ok") {
|
| 227 |
+
emojiEl.textContent = "✅";
|
| 228 |
+
emojiEl.title = detail || "Online";
|
| 229 |
+
} else {
|
| 230 |
+
emojiEl.textContent = "❌";
|
| 231 |
+
emojiEl.title = detail || "Offline / Not reachable";
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
function renderBatchStatus(b) {
|
| 236 |
+
if (!runStatusTextEl || !runProgressEl || !runProgressBarEl) return;
|
| 237 |
+
const batch = b && typeof b === "object" ? b : null;
|
| 238 |
+
const stats = batch && batch.stats && typeof batch.stats === "object" ? batch.stats : null;
|
| 239 |
+
const total = Number(stats?.total) || 0;
|
| 240 |
+
const finished = Number(stats?.finished) || 0;
|
| 241 |
+
const msg = typeof batch?.message === "string" && batch.message ? batch.message : "Idle";
|
| 242 |
+
runStatusTextEl.textContent = msg;
|
| 243 |
+
const pct = total ? Math.max(0, Math.min(100, Math.round((finished / total) * 100))) : 0;
|
| 244 |
+
runProgressBarEl.style.width = pct + "%";
|
| 245 |
+
runProgressEl.setAttribute("aria-valuenow", String(pct));
|
| 246 |
+
if (runStatusSubEl) {
|
| 247 |
+
if (total) {
|
| 248 |
+
const pass = Number(batch?.pass) || 1;
|
| 249 |
+
const stage = typeof batch?.stage === "string" ? batch.stage : "";
|
| 250 |
+
const extra = stage ? `• ${stage}` : "";
|
| 251 |
+
runStatusSubEl.textContent = `pass ${pass} • ${finished}/${total} ${extra}`.trim();
|
| 252 |
+
} else {
|
| 253 |
+
runStatusSubEl.textContent = "";
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function initRunStatus() {
|
| 259 |
+
try {
|
| 260 |
+
chrome.runtime.sendMessage({ type: "GET_BATCH_STATUS" }, (resp) => {
|
| 261 |
+
renderBatchStatus(resp?.batch);
|
| 262 |
+
});
|
| 263 |
+
} catch {}
|
| 264 |
+
|
| 265 |
+
try {
|
| 266 |
+
chrome.runtime.onMessage.addListener((msg) => {
|
| 267 |
+
if (!msg || typeof msg !== "object") return;
|
| 268 |
+
if (msg.type === "BATCH_STATUS_UPDATE") renderBatchStatus(msg.batch);
|
| 269 |
+
});
|
| 270 |
+
} catch {}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
const warmedApi = new Set();
|
| 275 |
+
async function warmupApi(base) {
|
| 276 |
+
const b = normalizeUrl(base);
|
| 277 |
+
if (!b || warmedApi.has(b)) return;
|
| 278 |
+
warmedApi.add(b);
|
| 279 |
+
const controller = new AbortController();
|
| 280 |
+
const timeout = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS);
|
| 281 |
+
try {
|
| 282 |
+
await fetch(`${b}${WARMUP_PATH}`, {
|
| 283 |
+
method: "GET",
|
| 284 |
+
cache: "no-store",
|
| 285 |
+
signal: controller.signal,
|
| 286 |
+
});
|
| 287 |
+
} catch {
|
| 288 |
+
} finally {
|
| 289 |
+
clearTimeout(timeout);
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function abortInFlight() {
|
| 294 |
+
if (inFlight.controller) {
|
| 295 |
+
try {
|
| 296 |
+
inFlight.controller.abort();
|
| 297 |
+
} catch {}
|
| 298 |
+
}
|
| 299 |
+
inFlight.controller = null;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function scheduleRetry(url, attemptIndex) {
|
| 303 |
+
clearTimeout(retryTimer);
|
| 304 |
+
if (attemptIndex >= RETRY_DELAYS_MS.length) return;
|
| 305 |
+
const delay = RETRY_DELAYS_MS[attemptIndex];
|
| 306 |
+
retryTimer = setTimeout(() => {
|
| 307 |
+
if (normalizeUrl(apiInput.value) === url) checkHealth(url, attemptIndex);
|
| 308 |
+
}, delay);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async function parseHealth(res) {
|
| 312 |
+
try {
|
| 313 |
+
const ct = res.headers.get("content-type") || "";
|
| 314 |
+
if (ct.includes("application/json")) return await res.json();
|
| 315 |
+
} catch {}
|
| 316 |
+
try {
|
| 317 |
+
const txt = await res.text();
|
| 318 |
+
return { ok: /\bok\b/i.test(txt), raw: txt };
|
| 319 |
+
} catch {
|
| 320 |
+
return null;
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
async function checkHealthOnce(url, seq) {
|
| 325 |
+
const controller = new AbortController();
|
| 326 |
+
inFlight.controller = controller;
|
| 327 |
+
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
| 328 |
+
try {
|
| 329 |
+
const res = await fetch(`${url}${HEALTH_PATH}`, {
|
| 330 |
+
method: "GET",
|
| 331 |
+
headers: { accept: "application/json, text/plain;q=0.8" },
|
| 332 |
+
cache: "no-store",
|
| 333 |
+
signal: controller.signal,
|
| 334 |
+
});
|
| 335 |
+
if (!res.ok) throw new Error(`status ${res.status}`);
|
| 336 |
+
const data = await parseHealth(res);
|
| 337 |
+
if (!data || !data.ok) throw new Error("unhealthy");
|
| 338 |
+
if (seq !== inFlight.seq) return false;
|
| 339 |
+
return true;
|
| 340 |
+
} finally {
|
| 341 |
+
clearTimeout(timeout);
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
async function checkHealth(url, attemptIndex = 0) {
|
| 346 |
+
const cleaned = normalizeUrl(url);
|
| 347 |
+
if (!cleaned) return;
|
| 348 |
+
inFlight.seq += 1;
|
| 349 |
+
const seq = inFlight.seq;
|
| 350 |
+
abortInFlight();
|
| 351 |
+
setEmojiStatus(
|
| 352 |
+
userInteractedApi ? "loading" : "loading",
|
| 353 |
+
userInteractedApi ? "Checking API..." : "Waiting…",
|
| 354 |
+
);
|
| 355 |
+
try {
|
| 356 |
+
const healthy = await checkHealthOnce(cleaned, seq);
|
| 357 |
+
if (seq !== inFlight.seq) return;
|
| 358 |
+
lastApiOk = healthy;
|
| 359 |
+
if (healthy) {
|
| 360 |
+
clearTimeout(retryTimer);
|
| 361 |
+
setEmojiStatus("ok", "Online");
|
| 362 |
+
warmupApi(cleaned);
|
| 363 |
+
refreshMeta(cleaned);
|
| 364 |
+
} else {
|
| 365 |
+
setEmojiStatus("error", userInteractedApi ? "Health failed" : "Waiting…");
|
| 366 |
+
scheduleRetry(cleaned, attemptIndex);
|
| 367 |
+
}
|
| 368 |
+
} catch (err) {
|
| 369 |
+
if (seq !== inFlight.seq) return;
|
| 370 |
+
lastApiOk = false;
|
| 371 |
+
const msg =
|
| 372 |
+
err && err.name === "AbortError"
|
| 373 |
+
? "Timed out"
|
| 374 |
+
: err?.message || "Offline";
|
| 375 |
+
setEmojiStatus(
|
| 376 |
+
userInteractedApi ? "error" : "loading",
|
| 377 |
+
userInteractedApi ? msg : "Waiting…",
|
| 378 |
+
);
|
| 379 |
+
scheduleRetry(cleaned, attemptIndex);
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
async function fetchJson(url, body, timeoutMs) {
|
| 384 |
+
const controller = new AbortController();
|
| 385 |
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
| 386 |
+
try {
|
| 387 |
+
const res = await fetch(url, {
|
| 388 |
+
method: body ? "POST" : "GET",
|
| 389 |
+
headers: body
|
| 390 |
+
? { "Content-Type": "application/json", accept: "application/json" }
|
| 391 |
+
: { accept: "application/json" },
|
| 392 |
+
cache: "no-store",
|
| 393 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 394 |
+
signal: controller.signal,
|
| 395 |
+
});
|
| 396 |
+
if (!res.ok) throw new Error(`status ${res.status}`);
|
| 397 |
+
return await res.json();
|
| 398 |
+
} finally {
|
| 399 |
+
clearTimeout(timeout);
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function setSelectOptions(
|
| 404 |
+
sel,
|
| 405 |
+
list,
|
| 406 |
+
{ valueKey = "id", labelKey = "name", keepValue = "" } = {},
|
| 407 |
+
) {
|
| 408 |
+
const prev = keepValue || sel.value || "";
|
| 409 |
+
sel.innerHTML = "";
|
| 410 |
+
const items = Array.isArray(list) ? list : [];
|
| 411 |
+
for (const it of items) {
|
| 412 |
+
const opt = document.createElement("option");
|
| 413 |
+
opt.value = String(it?.[valueKey] ?? "");
|
| 414 |
+
opt.textContent = String(it?.[labelKey] ?? opt.value);
|
| 415 |
+
sel.appendChild(opt);
|
| 416 |
+
}
|
| 417 |
+
const canKeep = [...sel.options].some((o) => o.value === prev);
|
| 418 |
+
if (canKeep) sel.value = prev;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function populateModes() {
|
| 422 |
+
setSelectOptions(modeSel, MODES, {
|
| 423 |
+
valueKey: "id",
|
| 424 |
+
labelKey: "name",
|
| 425 |
+
keepValue: modeSel.value,
|
| 426 |
+
});
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
function toggleUi() {
|
| 430 |
+
const modeId = modeSel.value || "lens_text";
|
| 431 |
+
const isText = modeId === "lens_text";
|
| 432 |
+
sourcesWrap.style.display = isText ? "" : "none";
|
| 433 |
+
|
| 434 |
+
const source = (sourcesSel.value || "").trim() || "translated";
|
| 435 |
+
const needLang = MODES.find((m) => m.id === modeId)?.needLang ?? true;
|
| 436 |
+
const showLang = needLang && !(isText && source === "original");
|
| 437 |
+
langWrap.style.display = showLang ? "" : "none";
|
| 438 |
+
const showAi = isText && source === "ai";
|
| 439 |
+
|
| 440 |
+
const hasEnv = Boolean(metaCache?.has_env_ai_key);
|
| 441 |
+
const hasKey = (aiKeyInput.value || "").trim().length > 0;
|
| 442 |
+
const canConfigureAi = hasKey || hasEnv;
|
| 443 |
+
|
| 444 |
+
aiKeyWrap.style.display = showAi ? "" : "none";
|
| 445 |
+
aiModelWrap.style.display = showAi && canConfigureAi ? "" : "none";
|
| 446 |
+
aiPromptWrap.style.display = showAi && canConfigureAi ? "" : "none";
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
async function refreshMeta(baseUrl) {
|
| 450 |
+
const url = `${baseUrl}${META_PATH}`;
|
| 451 |
+
try {
|
| 452 |
+
const data = await fetchJson(url, null, HEALTH_TIMEOUT_MS);
|
| 453 |
+
if (data && data.ok) {
|
| 454 |
+
metaCache = data;
|
| 455 |
+
const langs =
|
| 456 |
+
Array.isArray(data.languages) && data.languages.length
|
| 457 |
+
? data.languages
|
| 458 |
+
: FALLBACK_LANGS;
|
| 459 |
+
const sources =
|
| 460 |
+
Array.isArray(data.sources) && data.sources.length
|
| 461 |
+
? data.sources
|
| 462 |
+
: FALLBACK_SOURCES;
|
| 463 |
+
const beforeLang = langSel.value;
|
| 464 |
+
const beforeSources = sourcesSel.value;
|
| 465 |
+
setSelectOptions(langSel, langs, {
|
| 466 |
+
valueKey: "code",
|
| 467 |
+
labelKey: "name",
|
| 468 |
+
keepValue: desiredLang || beforeLang,
|
| 469 |
+
});
|
| 470 |
+
setSelectOptions(sourcesSel, sources, {
|
| 471 |
+
valueKey: "id",
|
| 472 |
+
labelKey: "name",
|
| 473 |
+
keepValue: desiredSources || beforeSources,
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
+
const afterLang = langSel.value;
|
| 477 |
+
const afterSources = sourcesSel.value;
|
| 478 |
+
const patch = {};
|
| 479 |
+
if (afterLang && afterLang !== desiredLang) {
|
| 480 |
+
desiredLang = afterLang;
|
| 481 |
+
patch.lang = afterLang;
|
| 482 |
+
}
|
| 483 |
+
if (afterSources && afterSources !== desiredSources) {
|
| 484 |
+
desiredSources = afterSources;
|
| 485 |
+
patch.sources = afterSources;
|
| 486 |
+
}
|
| 487 |
+
if (Object.keys(patch).length) await chrome.storage.local.set(patch);
|
| 488 |
+
toggleUi();
|
| 489 |
+
}
|
| 490 |
+
} catch {}
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async function ensureAiAvailableOrFallback() {
|
| 494 |
+
const modeId = modeSel.value || "lens_text";
|
| 495 |
+
if (modeId !== "lens_text") return true;
|
| 496 |
+
|
| 497 |
+
const source = (sourcesSel.value || "").trim() || "translated";
|
| 498 |
+
if (source !== "ai") return true;
|
| 499 |
+
|
| 500 |
+
const hasEnv = Boolean(metaCache?.has_env_ai_key);
|
| 501 |
+
const hasKey = (aiKeyInput.value || "").trim().length > 0;
|
| 502 |
+
if (hasKey || hasEnv) return true;
|
| 503 |
+
|
| 504 |
+
toggleUi();
|
| 505 |
+
return false;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
function setModelOptions(models, { keepValue = "", strict = true } = {}) {
|
| 509 |
+
const prev =
|
| 510 |
+
(keepValue || aiModelSel.value || desiredAiModel || "auto").trim() ||
|
| 511 |
+
"auto";
|
| 512 |
+
aiModelSel.innerHTML = "";
|
| 513 |
+
const base = [{ id: "auto", name: "auto" }];
|
| 514 |
+
const list = (Array.isArray(models) ? models : [])
|
| 515 |
+
.map((m) => ({
|
| 516 |
+
id: String(m || ""),
|
| 517 |
+
name: String(m || ""),
|
| 518 |
+
}))
|
| 519 |
+
.filter((m) => m.id)
|
| 520 |
+
.sort((a, b) =>
|
| 521 |
+
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
| 522 |
+
);
|
| 523 |
+
const canKeep = prev && [...new Set(list.map((m) => m.id))].includes(prev);
|
| 524 |
+
if (!strict && prev && prev !== "auto" && !canKeep)
|
| 525 |
+
list.unshift({ id: prev, name: prev });
|
| 526 |
+
for (const it of base.concat(list)) {
|
| 527 |
+
const opt = document.createElement("option");
|
| 528 |
+
opt.value = it.id;
|
| 529 |
+
opt.textContent = it.name;
|
| 530 |
+
aiModelSel.appendChild(opt);
|
| 531 |
+
}
|
| 532 |
+
aiModelSel.value = canKeep ? prev : "auto";
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
async function refreshAiMeta({ forcePrompt = false } = {}) {
|
| 536 |
+
const modeId = modeSel.value || "lens_text";
|
| 537 |
+
if (modeId !== "lens_text") return;
|
| 538 |
+
|
| 539 |
+
const ok = await ensureAiAvailableOrFallback();
|
| 540 |
+
if (!ok) return;
|
| 541 |
+
|
| 542 |
+
const base = normalizeUrl(apiInput.value);
|
| 543 |
+
if (!base) return;
|
| 544 |
+
|
| 545 |
+
const source = (sourcesSel.value || "").trim() || "translated";
|
| 546 |
+
if (source !== "ai") {
|
| 547 |
+
setModelOptions([], { keepValue: "auto" });
|
| 548 |
+
return;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
const seq = ++aiMetaSeq;
|
| 552 |
+
try {
|
| 553 |
+
const lang = langSel.value || "en";
|
| 554 |
+
const aiKey = (aiKeyInput.value || "").trim();
|
| 555 |
+
const currentModel =
|
| 556 |
+
(aiModelSel.value || "").trim() || desiredAiModel || "auto";
|
| 557 |
+
|
| 558 |
+
const data = await fetchJson(
|
| 559 |
+
`${base}${AI_RESOLVE_PATH}`,
|
| 560 |
+
{ api_key: aiKey, model: currentModel, lang },
|
| 561 |
+
AI_META_TIMEOUT_MS,
|
| 562 |
+
);
|
| 563 |
+
if (seq !== aiMetaSeq) return;
|
| 564 |
+
|
| 565 |
+
if (!data || !data.ok) {
|
| 566 |
+
setModelOptions([], { keepValue: currentModel, strict: false });
|
| 567 |
+
toggleUi();
|
| 568 |
+
return;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
const provider = String(data.provider || "").trim();
|
| 572 |
+
const keyChanged = aiKey !== lastResolvedKey;
|
| 573 |
+
const providerChanged = Boolean(
|
| 574 |
+
lastResolvedProvider &&
|
| 575 |
+
provider &&
|
| 576 |
+
provider !== lastResolvedProvider &&
|
| 577 |
+
keyChanged,
|
| 578 |
+
);
|
| 579 |
+
|
| 580 |
+
if (provider) lastResolvedProvider = provider;
|
| 581 |
+
lastResolvedKey = aiKey;
|
| 582 |
+
|
| 583 |
+
const models = Array.isArray(data.models) ? data.models : [];
|
| 584 |
+
const preferred =
|
| 585 |
+
(modelDirty
|
| 586 |
+
? (aiModelSel.value || "").trim()
|
| 587 |
+
: (desiredAiModel || "").trim()) ||
|
| 588 |
+
currentModel ||
|
| 589 |
+
"auto";
|
| 590 |
+
setModelOptions(models, { keepValue: preferred, strict: true });
|
| 591 |
+
|
| 592 |
+
const optionValues = [...aiModelSel.options].map((o) => o.value);
|
| 593 |
+
let nextModel = (aiModelSel.value || "").trim() || "auto";
|
| 594 |
+
if (!optionValues.includes(nextModel) || nextModel === "") {
|
| 595 |
+
const suggested = String(data.model || "").trim();
|
| 596 |
+
if (suggested && optionValues.includes(suggested)) nextModel = suggested;
|
| 597 |
+
else nextModel = "auto";
|
| 598 |
+
} else if (
|
| 599 |
+
providerChanged &&
|
| 600 |
+
nextModel !== "auto" &&
|
| 601 |
+
!optionValues.includes(nextModel)
|
| 602 |
+
) {
|
| 603 |
+
nextModel = "auto";
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
if ((aiModelSel.value || "").trim() !== nextModel) aiModelSel.value = nextModel;
|
| 607 |
+
|
| 608 |
+
if (nextModel !== currentModel) {
|
| 609 |
+
desiredAiModel = nextModel;
|
| 610 |
+
await chrome.storage.local.set({ aiKey, aiModel: nextModel });
|
| 611 |
+
} else {
|
| 612 |
+
await chrome.storage.local.set({ aiKey });
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
if (forcePrompt) await applyPromptForLang(lang, { forceFetch: true });
|
| 616 |
+
|
| 617 |
+
toggleUi();
|
| 618 |
+
} catch {}
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
function canUseAiUi() {
|
| 622 |
+
const modeId = modeSel.value || "lens_text";
|
| 623 |
+
if (modeId !== "lens_text") return false;
|
| 624 |
+
const source = (sourcesSel.value || "").trim() || "translated";
|
| 625 |
+
return source === "ai";
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
async function fetchDefaultPromptForLang(lang, model = 'auto') {
|
| 629 |
+
const l = (lang || 'en').trim() || 'en';
|
| 630 |
+
const m2 = normalizeAiModel(model);
|
| 631 |
+
const url = buildUrl(AI_PROMPT_DEFAULT_PATH, { lang: l, model: m2 });
|
| 632 |
+
if (!url) return '';
|
| 633 |
+
const ctrl = new AbortController();
|
| 634 |
+
const timer = setTimeout(() => ctrl.abort(), PROMPT_TIMEOUT_MS);
|
| 635 |
+
const resp = await fetch(url, { signal: ctrl.signal }).catch(() => null);
|
| 636 |
+
clearTimeout(timer);
|
| 637 |
+
const data = resp ? await resp.json().catch(() => null) : null;
|
| 638 |
+
const p2 = data && data.ok ? String(data.prompt_editable_default || '').trim() : '';
|
| 639 |
+
return normalizePrompt(p2);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
async function resetPromptForLang(lang) {
|
| 643 |
+
if (!canUseAiUi()) return;
|
| 644 |
+
const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en';
|
| 645 |
+
const model = normalizeAiModel(desiredAiModel);
|
| 646 |
+
const key = makePromptKey(l, model);
|
| 647 |
+
const seq = ++promptSeq;
|
| 648 |
+
|
| 649 |
+
const cached = String(aiPromptDefaultsByLang[key] || '').trim();
|
| 650 |
+
const def = cached ? normalizePrompt(cached) : await fetchDefaultPromptForLang(l, model);
|
| 651 |
+
if (seq !== promptSeq) return;
|
| 652 |
+
|
| 653 |
+
aiPromptDefaultsByLang[key] = def;
|
| 654 |
+
aiPromptByLangState[key] = def;
|
| 655 |
+
aiPromptDirtyByLang[key] = false;
|
| 656 |
+
|
| 657 |
+
aiPromptInput.value = def;
|
| 658 |
+
updatePromptCount(def);
|
| 659 |
+
await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState });
|
| 660 |
+
chrome.runtime.sendMessage({ type: 'AI_SETTINGS_CHANGED' });
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
async function applyPromptForLang(lang, { forceFetch = false } = {}) {
|
| 664 |
+
if (!canUseAiUi()) return;
|
| 665 |
+
const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en';
|
| 666 |
+
const model = normalizeAiModel(desiredAiModel);
|
| 667 |
+
const key = makePromptKey(l, model);
|
| 668 |
+
if (aiPromptDirtyByLang[key]) return;
|
| 669 |
+
if (Object.prototype.hasOwnProperty.call(aiPromptByLangState, key) && !forceFetch) {
|
| 670 |
+
const saved = String(aiPromptByLangState[key] || '');
|
| 671 |
+
aiPromptInput.value = saved;
|
| 672 |
+
updatePromptCount(saved);
|
| 673 |
+
return;
|
| 674 |
+
}
|
| 675 |
+
if (Object.prototype.hasOwnProperty.call(aiPromptDefaultsByLang, key) && !forceFetch) {
|
| 676 |
+
const def = String(aiPromptDefaultsByLang[key] || '');
|
| 677 |
+
aiPromptInput.value = def;
|
| 678 |
+
updatePromptCount(def);
|
| 679 |
+
return;
|
| 680 |
+
}
|
| 681 |
+
const def = await fetchDefaultPromptForLang(l, model);
|
| 682 |
+
aiPromptDefaultsByLang[key] = def;
|
| 683 |
+
aiPromptInput.value = def;
|
| 684 |
+
updatePromptCount(def);
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
async function flushPromptForLang(lang, model = null) {
|
| 688 |
+
if (!canUseAiUi()) return;
|
| 689 |
+
const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en';
|
| 690 |
+
const m2 = normalizeAiModel(model || desiredAiModel);
|
| 691 |
+
const key = makePromptKey(l, m2);
|
| 692 |
+
if (!aiPromptDirtyByLang[key]) return;
|
| 693 |
+
const prompt = normalizePrompt(String(aiPromptInput.value || ''));
|
| 694 |
+
aiPromptByLangState[key] = prompt;
|
| 695 |
+
aiPromptDirtyByLang[key] = false;
|
| 696 |
+
await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState });
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
let debounceTimer = null;
|
| 700 |
+
function scheduleSaveApi(raw) {
|
| 701 |
+
clearTimeout(debounceTimer);
|
| 702 |
+
pendingApiSave = true;
|
| 703 |
+
debounceTimer = setTimeout(async () => {
|
| 704 |
+
pendingApiSave = false;
|
| 705 |
+
const normalized = normalizeUrl(raw);
|
| 706 |
+
if (!normalized) return;
|
| 707 |
+
|
| 708 |
+
if (normalized === lastSavedApiUrl) {
|
| 709 |
+
checkHealth(normalized);
|
| 710 |
+
return;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
userInteractedApi = true;
|
| 714 |
+
await chrome.storage.local.set({ customApiUrl: normalized });
|
| 715 |
+
lastSavedApiUrl = normalized;
|
| 716 |
+
chrome.runtime.sendMessage({ type: "API_URL_CHANGED" });
|
| 717 |
+
setEmojiStatus("loading", "Checking API...");
|
| 718 |
+
checkHealth(normalized);
|
| 719 |
+
refreshAiMeta({ forcePrompt: false });
|
| 720 |
+
}, 800);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
let aiDebounce = null;
|
| 724 |
+
function scheduleSaveAi() {
|
| 725 |
+
clearTimeout(aiDebounce);
|
| 726 |
+
pendingAiSave = true;
|
| 727 |
+
aiDebounce = setTimeout(async () => {
|
| 728 |
+
pendingAiSave = false;
|
| 729 |
+
const modeId = modeSel.value || "lens_text";
|
| 730 |
+
if (modeId !== "lens_text") {
|
| 731 |
+
const aiKey = (aiKeyInput.value || "").trim();
|
| 732 |
+
const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto");
|
| 733 |
+
desiredAiModel = aiModel;
|
| 734 |
+
await chrome.storage.local.set({ aiKey, aiModel });
|
| 735 |
+
chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" });
|
| 736 |
+
return;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
const source = (sourcesSel.value || "").trim() || "translated";
|
| 740 |
+
const lang = desiredLang || langSel.value || "en";
|
| 741 |
+
const aiKey = (aiKeyInput.value || "").trim();
|
| 742 |
+
const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto");
|
| 743 |
+
desiredAiModel = aiModel;
|
| 744 |
+
|
| 745 |
+
const key = makePromptKey(lang, aiModel);
|
| 746 |
+
if (aiPromptDirtyByLang[key]) {
|
| 747 |
+
const prompt = normalizePrompt(String(aiPromptInput.value || ""));
|
| 748 |
+
aiPromptByLangState[key] = prompt;
|
| 749 |
+
aiPromptDirtyByLang[key] = false;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
await chrome.storage.local.set({
|
| 753 |
+
aiKey,
|
| 754 |
+
aiModel,
|
| 755 |
+
aiPromptByLang: aiPromptByLangState,
|
| 756 |
+
});
|
| 757 |
+
chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" });
|
| 758 |
+
modelDirty = true;
|
| 759 |
+
toggleUi();
|
| 760 |
+
if (source === "ai") refreshAiMeta({ forcePrompt: false });
|
| 761 |
+
}, 400);
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
function scheduleResolveAiMeta({ immediate = false } = {}) {
|
| 765 |
+
if (aiResolveDebounce) clearTimeout(aiResolveDebounce);
|
| 766 |
+
const run = () => {
|
| 767 |
+
if ((modeSel.value || "lens_text") !== "lens_text") return;
|
| 768 |
+
if ((sourcesSel.value || "").trim() !== "ai") return;
|
| 769 |
+
refreshAiMeta({ forcePrompt: false });
|
| 770 |
+
};
|
| 771 |
+
if (immediate) {
|
| 772 |
+
run();
|
| 773 |
+
return;
|
| 774 |
+
}
|
| 775 |
+
aiResolveDebounce = setTimeout(run, 350);
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
function resetFileInput(input) {
|
| 780 |
+
if (input) input.value = "";
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
async function openLocalViewerFromFiles(fileList, sourceLabel) {
|
| 784 |
+
const images = filterImageFiles(fileList);
|
| 785 |
+
if (!images.length) return;
|
| 786 |
+
const session = await saveLocalSession({
|
| 787 |
+
id: crypto.randomUUID(),
|
| 788 |
+
createdAt: Date.now(),
|
| 789 |
+
title: sourceLabel,
|
| 790 |
+
pages: sortLocalPages(images.map((file, index) => toLocalPageRecord(file, index))),
|
| 791 |
+
});
|
| 792 |
+
const url = chrome.runtime.getURL(
|
| 793 |
+
`viewer/viewer.html?sid=${encodeURIComponent(session.id)}`,
|
| 794 |
+
);
|
| 795 |
+
await chrome.tabs.create({ url });
|
| 796 |
+
window.close();
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
async function handleLocalPickerChange(input, sourceLabel) {
|
| 800 |
+
const files = [...(input?.files || [])];
|
| 801 |
+
resetFileInput(input);
|
| 802 |
+
await openLocalViewerFromFiles(files, sourceLabel);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
function handleWsStatus(status) {
|
| 806 |
+
if (lastApiOk) return;
|
| 807 |
+
if (status === "connected") setEmojiStatus("ok", "WS connected");
|
| 808 |
+
else if (status === "connecting") setEmojiStatus("loading", "Connecting…");
|
| 809 |
+
else setEmojiStatus("error", "WS disconnected");
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
async function loadSettings() {
|
| 813 |
+
populateModes();
|
| 814 |
+
setEmojiStatus("loading", "Initializing…");
|
| 815 |
+
|
| 816 |
+
const stored = await chrome.storage.local.get([
|
| 817 |
+
"mode",
|
| 818 |
+
"lang",
|
| 819 |
+
"sources",
|
| 820 |
+
"customApiUrl",
|
| 821 |
+
"aiKey",
|
| 822 |
+
"aiModel",
|
| 823 |
+
"aiPromptByLang",
|
| 824 |
+
]);
|
| 825 |
+
|
| 826 |
+
modeSel.value = stored.mode || "lens_text";
|
| 827 |
+
desiredLang =
|
| 828 |
+
typeof stored.lang === "string" && stored.lang ? stored.lang : "en";
|
| 829 |
+
desiredSources =
|
| 830 |
+
typeof stored.sources === "string" && stored.sources
|
| 831 |
+
? stored.sources
|
| 832 |
+
: "translated";
|
| 833 |
+
desiredAiModel =
|
| 834 |
+
typeof stored.aiModel === "string" && stored.aiModel
|
| 835 |
+
? stored.aiModel
|
| 836 |
+
: "auto";
|
| 837 |
+
|
| 838 |
+
setSelectOptions(langSel, FALLBACK_LANGS, {
|
| 839 |
+
valueKey: "code",
|
| 840 |
+
labelKey: "name",
|
| 841 |
+
keepValue: desiredLang,
|
| 842 |
+
});
|
| 843 |
+
setSelectOptions(sourcesSel, FALLBACK_SOURCES, {
|
| 844 |
+
valueKey: "id",
|
| 845 |
+
labelKey: "name",
|
| 846 |
+
keepValue: desiredSources,
|
| 847 |
+
});
|
| 848 |
+
|
| 849 |
+
langSel.value = desiredLang;
|
| 850 |
+
sourcesSel.value = desiredSources;
|
| 851 |
+
|
| 852 |
+
const storedCustom = String(stored.customApiUrl || "");
|
| 853 |
+
lastSavedApiUrl = storedCustom;
|
| 854 |
+
apiInput.value = storedCustom;
|
| 855 |
+
|
| 856 |
+
if (storedCustom) userInteractedApi = true;
|
| 857 |
+
|
| 858 |
+
const aiKey = String(stored.aiKey || "");
|
| 859 |
+
aiPromptByLangState =
|
| 860 |
+
stored.aiPromptByLang && typeof stored.aiPromptByLang === "object"
|
| 861 |
+
? stored.aiPromptByLang
|
| 862 |
+
: {};
|
| 863 |
+
const mig = migratePromptMap(aiPromptByLangState);
|
| 864 |
+
aiPromptByLangState = mig.map;
|
| 865 |
+
if (mig.changed) await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState });
|
| 866 |
+
|
| 867 |
+
const key = makePromptKey(desiredLang, desiredAiModel);
|
| 868 |
+
const prompt = Object.prototype.hasOwnProperty.call(aiPromptByLangState, key)
|
| 869 |
+
? String(aiPromptByLangState[key] || "")
|
| 870 |
+
: "";
|
| 871 |
+
|
| 872 |
+
aiKeyInput.value = aiKey;
|
| 873 |
+
setModelOptions([], { keepValue: desiredAiModel });
|
| 874 |
+
aiPromptInput.value = prompt;
|
| 875 |
+
updatePromptCount(prompt);
|
| 876 |
+
|
| 877 |
+
toggleUi();
|
| 878 |
+
|
| 879 |
+
const initial = normalizeUrl(apiInput.value);
|
| 880 |
+
if (initial) checkHealth(initial);
|
| 881 |
+
|
| 882 |
+
ensureApiDefaults()
|
| 883 |
+
.then((d) => {
|
| 884 |
+
apiDefaults = d || apiDefaults;
|
| 885 |
+
if (storedCustom) return;
|
| 886 |
+
const def = apiDefaults.defaultApiUrl || "";
|
| 887 |
+
if (!def) return;
|
| 888 |
+
if (!normalizeUrl(apiInput.value)) {
|
| 889 |
+
apiInput.value = def;
|
| 890 |
+
checkHealth(def);
|
| 891 |
+
}
|
| 892 |
+
})
|
| 893 |
+
.catch(() => {});
|
| 894 |
+
|
| 895 |
+
if ((sourcesSel.value || "").trim() === "ai" && modeSel.value === "lens_text") {
|
| 896 |
+
applyPromptForLang(desiredLang, { forceFetch: false }).catch(() => {});
|
| 897 |
+
refreshAiMeta({ forcePrompt: false });
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
chrome.runtime.sendMessage({ type: "GET_API_STATUS" }, (resp) => {
|
| 901 |
+
if (chrome.runtime.lastError) return;
|
| 902 |
+
if (resp && typeof resp.ok === "boolean" && resp.ok) {
|
| 903 |
+
lastApiOk = true;
|
| 904 |
+
setEmojiStatus("ok", "Online");
|
| 905 |
+
}
|
| 906 |
+
});
|
| 907 |
+
|
| 908 |
+
chrome.runtime.sendMessage({ type: "GET_WS_STATUS" }, (resp) => {
|
| 909 |
+
if (chrome.runtime.lastError) return;
|
| 910 |
+
if (resp && typeof resp.status === "string") handleWsStatus(resp.status);
|
| 911 |
+
});
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
window.addEventListener("offline", () => {
|
| 915 |
+
lastApiOk = false;
|
| 916 |
+
setEmojiStatus("error", "No internet");
|
| 917 |
+
});
|
| 918 |
+
window.addEventListener("online", () => {
|
| 919 |
+
if (apiInput.value) checkHealth(apiInput.value);
|
| 920 |
+
});
|
| 921 |
+
|
| 922 |
+
window.addEventListener("pagehide", () => {
|
| 923 |
+
try {
|
| 924 |
+
if (pendingApiSave) {
|
| 925 |
+
const normalized = normalizeUrl(apiInput.value);
|
| 926 |
+
if (normalized) chrome.storage.local.set({ customApiUrl: normalized });
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
const modeId = modeSel.value || "lens_text";
|
| 930 |
+
const aiKey = (aiKeyInput.value || "").trim();
|
| 931 |
+
const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto");
|
| 932 |
+
desiredAiModel = aiModel;
|
| 933 |
+
|
| 934 |
+
if (modeId === "lens_text") {
|
| 935 |
+
const lang = desiredLang || langSel.value || "en";
|
| 936 |
+
const key = makePromptKey(lang, aiModel);
|
| 937 |
+
const needSave = pendingAiSave || Boolean(aiPromptDirtyByLang[key]);
|
| 938 |
+
if (aiPromptDirtyByLang[key]) {
|
| 939 |
+
const prompt = normalizePrompt(String(aiPromptInput.value || ""));
|
| 940 |
+
aiPromptByLangState[key] = prompt;
|
| 941 |
+
aiPromptDirtyByLang[key] = false;
|
| 942 |
+
}
|
| 943 |
+
if (needSave)
|
| 944 |
+
chrome.storage.local.set({
|
| 945 |
+
aiKey,
|
| 946 |
+
aiModel,
|
| 947 |
+
aiPromptByLang: aiPromptByLangState,
|
| 948 |
+
});
|
| 949 |
+
} else if (pendingAiSave) {
|
| 950 |
+
chrome.storage.local.set({ aiKey, aiModel });
|
| 951 |
+
}
|
| 952 |
+
} catch {}
|
| 953 |
+
});
|
| 954 |
+
|
| 955 |
+
chrome.runtime.onMessage?.addListener((msg) => {
|
| 956 |
+
if (msg.type === "WS_STATUS_UPDATE") handleWsStatus(msg.status);
|
| 957 |
+
else if (msg.type === "API_STATUS_UPDATE") {
|
| 958 |
+
if (typeof msg.ok === "boolean") {
|
| 959 |
+
lastApiOk = msg.ok;
|
| 960 |
+
if (msg.ok) setEmojiStatus("ok", "Online");
|
| 961 |
+
else
|
| 962 |
+
setEmojiStatus(
|
| 963 |
+
userInteractedApi ? "error" : "loading",
|
| 964 |
+
userInteractedApi ? "API unhealthy" : "Waiting…",
|
| 965 |
+
);
|
| 966 |
+
}
|
| 967 |
+
}
|
| 968 |
+
});
|
| 969 |
+
|
| 970 |
+
modeSel.addEventListener("change", async () => {
|
| 971 |
+
await chrome.storage.local.set({ mode: modeSel.value });
|
| 972 |
+
modelDirty = false;
|
| 973 |
+
toggleUi();
|
| 974 |
+
await applyPromptForLang(desiredLang, { forceFetch: false });
|
| 975 |
+
refreshAiMeta({ forcePrompt: false });
|
| 976 |
+
});
|
| 977 |
+
|
| 978 |
+
langSel.addEventListener("change", async () => {
|
| 979 |
+
const prevLang = desiredLang;
|
| 980 |
+
desiredLang = langSel.value || desiredLang;
|
| 981 |
+
if (canUseAiUi()) await flushPromptForLang(prevLang, desiredAiModel);
|
| 982 |
+
await chrome.storage.local.set({ lang: desiredLang });
|
| 983 |
+
modelDirty = false;
|
| 984 |
+
await applyPromptForLang(desiredLang, { forceFetch: false });
|
| 985 |
+
toggleUi();
|
| 986 |
+
refreshAiMeta({ forcePrompt: false });
|
| 987 |
+
});
|
| 988 |
+
|
| 989 |
+
sourcesSel.addEventListener("change", async () => {
|
| 990 |
+
if (canUseAiUi()) await flushPromptForLang(desiredLang, desiredAiModel);
|
| 991 |
+
modelDirty = false;
|
| 992 |
+
const ok = await ensureAiAvailableOrFallback();
|
| 993 |
+
desiredSources = sourcesSel.value || desiredSources;
|
| 994 |
+
await chrome.storage.local.set({ sources: desiredSources });
|
| 995 |
+
toggleUi();
|
| 996 |
+
if (ok) await applyPromptForLang(desiredLang, { forceFetch: false });
|
| 997 |
+
refreshAiMeta({ forcePrompt: false });
|
| 998 |
+
chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" });
|
| 999 |
+
});
|
| 1000 |
+
|
| 1001 |
+
apiInput.addEventListener("input", (e) => scheduleSaveApi(e.target.value));
|
| 1002 |
+
apiInput.addEventListener("blur", (e) => scheduleSaveApi(e.target.value));
|
| 1003 |
+
|
| 1004 |
+
aiKeyInput.addEventListener("input", () => {
|
| 1005 |
+
modelDirty = false;
|
| 1006 |
+
scheduleSaveAi();
|
| 1007 |
+
scheduleResolveAiMeta();
|
| 1008 |
+
});
|
| 1009 |
+
aiKeyInput.addEventListener("blur", () => {
|
| 1010 |
+
scheduleSaveAi();
|
| 1011 |
+
scheduleResolveAiMeta({ immediate: true });
|
| 1012 |
+
});
|
| 1013 |
+
|
| 1014 |
+
aiModelSel.addEventListener("change", async () => {
|
| 1015 |
+
const prevModel = desiredAiModel;
|
| 1016 |
+
desiredAiModel = normalizeAiModel(aiModelSel.value || prevModel);
|
| 1017 |
+
if (canUseAiUi()) await flushPromptForLang(desiredLang, prevModel);
|
| 1018 |
+
modelDirty = true;
|
| 1019 |
+
await applyPromptForLang(desiredLang, { forceFetch: false });
|
| 1020 |
+
scheduleSaveAi();
|
| 1021 |
+
});
|
| 1022 |
+
|
| 1023 |
+
aiPromptInput.addEventListener("input", () => {
|
| 1024 |
+
const key = makePromptKey(desiredLang, desiredAiModel);
|
| 1025 |
+
aiPromptDirtyByLang[key] = true;
|
| 1026 |
+
updatePromptCount();
|
| 1027 |
+
scheduleSaveAi();
|
| 1028 |
+
});
|
| 1029 |
+
aiPromptInput.addEventListener("blur", async () => {
|
| 1030 |
+
const key = makePromptKey(desiredLang, desiredAiModel);
|
| 1031 |
+
aiPromptDirtyByLang[key] = true;
|
| 1032 |
+
updatePromptCount();
|
| 1033 |
+
await flushPromptForLang(desiredLang, desiredAiModel);
|
| 1034 |
+
scheduleSaveAi();
|
| 1035 |
+
});
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
openLocalImagesBtn?.addEventListener("click", () => {
|
| 1040 |
+
resetFileInput(localImagesInput);
|
| 1041 |
+
localImagesInput?.click();
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
openLocalFolderBtn?.addEventListener("click", () => {
|
| 1045 |
+
resetFileInput(localFolderInput);
|
| 1046 |
+
localFolderInput?.click();
|
| 1047 |
+
});
|
| 1048 |
+
|
| 1049 |
+
localImagesInput?.addEventListener("change", async () => {
|
| 1050 |
+
await handleLocalPickerChange(localImagesInput, "images");
|
| 1051 |
+
});
|
| 1052 |
+
|
| 1053 |
+
localFolderInput?.addEventListener("change", async () => {
|
| 1054 |
+
await handleLocalPickerChange(localFolderInput, "folder");
|
| 1055 |
+
});
|
| 1056 |
+
|
| 1057 |
+
resetBtn.addEventListener("click", () => {
|
| 1058 |
+
ensureApiDefaults({ force: true }).then((d) => {
|
| 1059 |
+
apiDefaults = d;
|
| 1060 |
+
const def = apiDefaults.resetApiUrl || apiDefaults.defaultApiUrl || "";
|
| 1061 |
+
apiInput.value = def;
|
| 1062 |
+
const normalized = normalizeUrl(def);
|
| 1063 |
+
chrome.storage.local.set({ customApiUrl: normalized });
|
| 1064 |
+
lastSavedApiUrl = normalized;
|
| 1065 |
+
userInteractedApi = Boolean(normalized);
|
| 1066 |
+
setEmojiStatus("loading", "Reset to default");
|
| 1067 |
+
chrome.runtime.sendMessage({ type: "API_URL_CHANGED" });
|
| 1068 |
+
checkHealth(normalized);
|
| 1069 |
+
apiInput.focus();
|
| 1070 |
+
});
|
| 1071 |
+
});
|
| 1072 |
+
|
| 1073 |
+
loadSettings();
|
| 1074 |
+
initRunStatus();
|
background-service.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: dark;
|
| 3 |
+
--bg: #0b1020;
|
| 4 |
+
--panel: rgba(11, 17, 31, 0.92);
|
| 5 |
+
--panel-2: rgba(18, 27, 47, 0.92);
|
| 6 |
+
--line: rgba(255, 255, 255, 0.08);
|
| 7 |
+
--line-2: rgba(255, 255, 255, 0.16);
|
| 8 |
+
--text: #edf2ff;
|
| 9 |
+
--muted: #8ea0c1;
|
| 10 |
+
--accent: #7aa2ff;
|
| 11 |
+
--accent-2: rgba(122, 162, 255, 0.16);
|
| 12 |
+
--danger: #ff8a8a;
|
| 13 |
+
--ok: #6ed3a5;
|
| 14 |
+
--radius: 16px;
|
| 15 |
+
--radius-sm: 12px;
|
| 16 |
+
--reader-width: 980px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
* {
|
| 20 |
+
box-sizing: border-box;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
html,
|
| 24 |
+
body {
|
| 25 |
+
margin: 0;
|
| 26 |
+
min-height: 100%;
|
| 27 |
+
background: radial-gradient(circle at top, #162241, var(--bg) 56%);
|
| 28 |
+
color: var(--text);
|
| 29 |
+
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
body {
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
button,
|
| 37 |
+
input,
|
| 38 |
+
select,
|
| 39 |
+
textarea {
|
| 40 |
+
font: inherit;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
button {
|
| 44 |
+
cursor: pointer;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.app-shell {
|
| 48 |
+
display: grid;
|
| 49 |
+
grid-template-columns: 320px minmax(0, 1fr);
|
| 50 |
+
min-height: 100vh;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.sidebar {
|
| 54 |
+
position: sticky;
|
| 55 |
+
top: 0;
|
| 56 |
+
height: 100vh;
|
| 57 |
+
display: flex;
|
| 58 |
+
flex-direction: column;
|
| 59 |
+
gap: 14px;
|
| 60 |
+
padding: 18px;
|
| 61 |
+
background: var(--panel);
|
| 62 |
+
border-right: 1px solid var(--line);
|
| 63 |
+
backdrop-filter: blur(12px);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.sidebar-head {
|
| 67 |
+
display: flex;
|
| 68 |
+
align-items: flex-end;
|
| 69 |
+
justify-content: space-between;
|
| 70 |
+
gap: 10px;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.eyebrow {
|
| 74 |
+
font-size: 11px;
|
| 75 |
+
text-transform: uppercase;
|
| 76 |
+
letter-spacing: 0.14em;
|
| 77 |
+
color: var(--muted);
|
| 78 |
+
margin-bottom: 4px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.sidebar-head h1,
|
| 82 |
+
.reader-head h2 {
|
| 83 |
+
margin: 0;
|
| 84 |
+
font-size: 24px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.sidebar-meta,
|
| 88 |
+
.selection-summary,
|
| 89 |
+
.sidebar-note,
|
| 90 |
+
.status,
|
| 91 |
+
.page-meta,
|
| 92 |
+
.zoom-indicator,
|
| 93 |
+
.page-actions {
|
| 94 |
+
color: var(--muted);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.toolbar.stack {
|
| 98 |
+
display: flex;
|
| 99 |
+
flex-direction: column;
|
| 100 |
+
gap: 10px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.toolbar-row.dual,
|
| 104 |
+
.toolbar-row.triple {
|
| 105 |
+
display: grid;
|
| 106 |
+
gap: 10px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.toolbar-row.dual {
|
| 110 |
+
grid-template-columns: 1fr 1fr;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.toolbar-row.triple {
|
| 114 |
+
grid-template-columns: repeat(3, 1fr);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.zoom-row {
|
| 118 |
+
display: grid;
|
| 119 |
+
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
| 120 |
+
gap: 10px;
|
| 121 |
+
align-items: center;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#zoom-range {
|
| 125 |
+
width: 100%;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.btn {
|
| 129 |
+
appearance: none;
|
| 130 |
+
border: 1px solid var(--line-2);
|
| 131 |
+
background: rgba(255, 255, 255, 0.04);
|
| 132 |
+
color: var(--text);
|
| 133 |
+
padding: 10px 12px;
|
| 134 |
+
border-radius: var(--radius-sm);
|
| 135 |
+
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.btn:hover {
|
| 139 |
+
transform: translateY(-1px);
|
| 140 |
+
border-color: rgba(255, 255, 255, 0.28);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.btn.primary {
|
| 144 |
+
background: var(--accent-2);
|
| 145 |
+
border-color: rgba(122, 162, 255, 0.36);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.btn.danger {
|
| 149 |
+
color: var(--danger);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.sidebar-note {
|
| 153 |
+
font-size: 12px;
|
| 154 |
+
line-height: 1.45;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.status {
|
| 158 |
+
font-size: 13px;
|
| 159 |
+
line-height: 1.45;
|
| 160 |
+
padding: 12px 14px;
|
| 161 |
+
border-radius: var(--radius-sm);
|
| 162 |
+
border: 1px solid var(--line);
|
| 163 |
+
background: rgba(255, 255, 255, 0.03);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.page-list {
|
| 167 |
+
display: flex;
|
| 168 |
+
flex-direction: column;
|
| 169 |
+
gap: 8px;
|
| 170 |
+
overflow: auto;
|
| 171 |
+
padding-right: 4px;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.page-list-item {
|
| 175 |
+
display: grid;
|
| 176 |
+
grid-template-columns: 18px auto 1fr auto;
|
| 177 |
+
gap: 10px;
|
| 178 |
+
align-items: center;
|
| 179 |
+
padding: 10px 12px;
|
| 180 |
+
border-radius: var(--radius-sm);
|
| 181 |
+
background: rgba(255, 255, 255, 0.03);
|
| 182 |
+
border: 1px solid transparent;
|
| 183 |
+
cursor: grab;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.page-list-item:hover,
|
| 187 |
+
.page-list-item.active,
|
| 188 |
+
.page-list-item.drag-over {
|
| 189 |
+
border-color: rgba(122, 162, 255, 0.32);
|
| 190 |
+
background: rgba(122, 162, 255, 0.08);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.drag-handle {
|
| 194 |
+
font-size: 16px;
|
| 195 |
+
color: var(--muted);
|
| 196 |
+
user-select: none;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.page-list-item input,
|
| 200 |
+
.page-strip input {
|
| 201 |
+
width: 16px;
|
| 202 |
+
height: 16px;
|
| 203 |
+
accent-color: var(--accent);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.page-name {
|
| 207 |
+
font-size: 13px;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
word-break: break-word;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.page-meta {
|
| 213 |
+
font-size: 11px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.badge {
|
| 217 |
+
display: inline-flex;
|
| 218 |
+
align-items: center;
|
| 219 |
+
justify-content: center;
|
| 220 |
+
min-width: 68px;
|
| 221 |
+
padding: 4px 8px;
|
| 222 |
+
border-radius: 999px;
|
| 223 |
+
font-size: 11px;
|
| 224 |
+
border: 1px solid var(--line);
|
| 225 |
+
background: rgba(255, 255, 255, 0.04);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.badge.ok {
|
| 229 |
+
color: var(--ok);
|
| 230 |
+
border-color: rgba(110, 211, 165, 0.24);
|
| 231 |
+
background: rgba(110, 211, 165, 0.1);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.reader-shell {
|
| 235 |
+
min-width: 0;
|
| 236 |
+
display: flex;
|
| 237 |
+
flex-direction: column;
|
| 238 |
+
gap: 14px;
|
| 239 |
+
padding: 18px;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.reader-head {
|
| 243 |
+
position: sticky;
|
| 244 |
+
top: 0;
|
| 245 |
+
z-index: 30;
|
| 246 |
+
display: flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
justify-content: space-between;
|
| 249 |
+
gap: 12px;
|
| 250 |
+
padding: 14px 16px;
|
| 251 |
+
border-radius: var(--radius);
|
| 252 |
+
background: rgba(8, 13, 26, 0.82);
|
| 253 |
+
border: 1px solid var(--line);
|
| 254 |
+
backdrop-filter: blur(10px);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.reader-head-main {
|
| 258 |
+
display: flex;
|
| 259 |
+
align-items: baseline;
|
| 260 |
+
gap: 12px;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.zoom-indicator {
|
| 264 |
+
font-size: 13px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.reader {
|
| 268 |
+
display: flex;
|
| 269 |
+
flex-direction: column;
|
| 270 |
+
gap: 2px;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.page-strip {
|
| 274 |
+
width: min(100%, var(--reader-width));
|
| 275 |
+
margin: 0 auto;
|
| 276 |
+
padding: 0;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.page-strip.active .page-strip-head {
|
| 280 |
+
border-color: rgba(122, 162, 255, 0.34);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.page-strip-head {
|
| 284 |
+
display: flex;
|
| 285 |
+
align-items: center;
|
| 286 |
+
justify-content: space-between;
|
| 287 |
+
gap: 10px;
|
| 288 |
+
padding: 10px 2px 10px 4px;
|
| 289 |
+
border-bottom: 1px solid transparent;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.page-strip-title {
|
| 293 |
+
display: flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
gap: 10px;
|
| 296 |
+
min-width: 0;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.page-strip-title strong {
|
| 300 |
+
font-size: 14px;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.page-actions {
|
| 304 |
+
display: flex;
|
| 305 |
+
align-items: center;
|
| 306 |
+
flex-wrap: wrap;
|
| 307 |
+
gap: 8px;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.page-actions .btn {
|
| 311 |
+
padding: 7px 10px;
|
| 312 |
+
font-size: 12px;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.page-frame {
|
| 316 |
+
position: relative;
|
| 317 |
+
width: 100%;
|
| 318 |
+
overflow: visible;
|
| 319 |
+
background: transparent;
|
| 320 |
+
border: 0;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.page-frame img {
|
| 324 |
+
display: block;
|
| 325 |
+
width: 100%;
|
| 326 |
+
height: auto;
|
| 327 |
+
margin: 0 auto;
|
| 328 |
+
background: rgba(255, 255, 255, 0.02);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.empty-state {
|
| 332 |
+
width: min(100%, 720px);
|
| 333 |
+
margin: 48px auto 0;
|
| 334 |
+
padding: 48px 24px;
|
| 335 |
+
text-align: center;
|
| 336 |
+
color: var(--muted);
|
| 337 |
+
border: 1px dashed var(--line-2);
|
| 338 |
+
border-radius: var(--radius);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
@media (max-width: 1120px) {
|
| 342 |
+
.app-shell {
|
| 343 |
+
grid-template-columns: 1fr;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.sidebar {
|
| 347 |
+
position: static;
|
| 348 |
+
height: auto;
|
| 349 |
+
border-right: 0;
|
| 350 |
+
border-bottom: 1px solid var(--line);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.page-strip {
|
| 354 |
+
width: 100%;
|
| 355 |
+
}
|
| 356 |
+
}
|
content-service.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>TextPhantom Local Viewer</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 7 |
+
<link rel="stylesheet" href="viewer.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="app-shell">
|
| 11 |
+
<aside class="sidebar">
|
| 12 |
+
<div class="sidebar-head">
|
| 13 |
+
<div>
|
| 14 |
+
<div class="eyebrow">TextPhantom</div>
|
| 15 |
+
<h1>Local Viewer</h1>
|
| 16 |
+
</div>
|
| 17 |
+
<div id="page-count" class="sidebar-meta">0 pages</div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div class="toolbar stack">
|
| 21 |
+
<div class="toolbar-row dual">
|
| 22 |
+
<button type="button" id="viewer-open-images" class="btn primary">Open image(s)</button>
|
| 23 |
+
<button type="button" id="viewer-open-folder" class="btn">Open folder</button>
|
| 24 |
+
</div>
|
| 25 |
+
<div class="toolbar-row triple">
|
| 26 |
+
<button type="button" id="toggle-select-pages" class="btn">Select all</button>
|
| 27 |
+
<button type="button" id="reverse-page-order" class="btn">Reverse</button>
|
| 28 |
+
<button type="button" id="clear-viewer" class="btn danger">Clear viewer</button>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="toolbar-row zoom-row">
|
| 31 |
+
<button type="button" id="zoom-out" class="btn">−</button>
|
| 32 |
+
<input id="zoom-range" type="range" min="420" max="2200" step="20" value="980" />
|
| 33 |
+
<button type="button" id="zoom-in" class="btn">+</button>
|
| 34 |
+
<button type="button" id="fit-width" class="btn">Fit</button>
|
| 35 |
+
</div>
|
| 36 |
+
<div class="toolbar-row dual">
|
| 37 |
+
<button type="button" id="download-selected-images" class="btn primary">Download image(s)</button>
|
| 38 |
+
<button type="button" id="download-selected-html" class="btn">Download HTML</button>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="sidebar-note">Drag names below to reorder the reader. Use Ctrl/Cmd + mouse wheel to zoom.</div>
|
| 43 |
+
<div id="viewer-status" class="status">Open local images or a folder, then right-click an image to translate.</div>
|
| 44 |
+
<div id="page-list" class="page-list"></div>
|
| 45 |
+
</aside>
|
| 46 |
+
|
| 47 |
+
<main class="reader-shell">
|
| 48 |
+
<header class="reader-head">
|
| 49 |
+
<div class="reader-head-main">
|
| 50 |
+
<h2>Reader</h2>
|
| 51 |
+
<div id="selection-summary" class="selection-summary">0 selected</div>
|
| 52 |
+
</div>
|
| 53 |
+
<div id="zoom-indicator" class="zoom-indicator">980px</div>
|
| 54 |
+
</header>
|
| 55 |
+
<section id="reader" class="reader"></section>
|
| 56 |
+
</main>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<input id="viewer-images-input" type="file" accept="image/*" multiple hidden />
|
| 60 |
+
<input id="viewer-folder-input" type="file" accept="image/*" multiple webkitdirectory hidden />
|
| 61 |
+
<script src="../content-service.js"></script>
|
| 62 |
+
<script type="module" src="viewer.js"></script>
|
| 63 |
+
</body>
|
| 64 |
+
</html>
|
defaults_api.json
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
deleteLocalSession,
|
| 3 |
+
filterImageFiles,
|
| 4 |
+
loadLocalSession,
|
| 5 |
+
saveLocalSession,
|
| 6 |
+
sortLocalPages,
|
| 7 |
+
toLocalPageRecord,
|
| 8 |
+
} from "../shared/local_gallery.js";
|
| 9 |
+
|
| 10 |
+
const readerEl = document.getElementById("reader");
|
| 11 |
+
const pageListEl = document.getElementById("page-list");
|
| 12 |
+
const pageCountEl = document.getElementById("page-count");
|
| 13 |
+
const viewerStatusEl = document.getElementById("viewer-status");
|
| 14 |
+
const selectionSummaryEl = document.getElementById("selection-summary");
|
| 15 |
+
const zoomIndicatorEl = document.getElementById("zoom-indicator");
|
| 16 |
+
|
| 17 |
+
const openImagesBtn = document.getElementById("viewer-open-images");
|
| 18 |
+
const openFolderBtn = document.getElementById("viewer-open-folder");
|
| 19 |
+
const toggleSelectBtn = document.getElementById("toggle-select-pages");
|
| 20 |
+
const reverseOrderBtn = document.getElementById("reverse-page-order");
|
| 21 |
+
const clearViewerBtn = document.getElementById("clear-viewer");
|
| 22 |
+
const zoomOutBtn = document.getElementById("zoom-out");
|
| 23 |
+
const zoomInBtn = document.getElementById("zoom-in");
|
| 24 |
+
const fitWidthBtn = document.getElementById("fit-width");
|
| 25 |
+
const zoomRange = document.getElementById("zoom-range");
|
| 26 |
+
const downloadSelectedImagesBtn = document.getElementById("download-selected-images");
|
| 27 |
+
const downloadSelectedHtmlBtn = document.getElementById("download-selected-html");
|
| 28 |
+
const imagesInput = document.getElementById("viewer-images-input");
|
| 29 |
+
const folderInput = document.getElementById("viewer-folder-input");
|
| 30 |
+
|
| 31 |
+
const params = new URLSearchParams(location.search);
|
| 32 |
+
const sessionId = String(params.get("sid") || "").trim();
|
| 33 |
+
const READER_WIDTH_KEY = "textphantom.viewer.width";
|
| 34 |
+
|
| 35 |
+
const pagesById = new Map();
|
| 36 |
+
const originalToPageId = new Map();
|
| 37 |
+
const selectedPageIds = new Set();
|
| 38 |
+
const objectUrls = new Map();
|
| 39 |
+
let pageOrder = [];
|
| 40 |
+
let currentSession = null;
|
| 41 |
+
let dragPageId = "";
|
| 42 |
+
let readerWidth = Number(localStorage.getItem(READER_WIDTH_KEY) || 980);
|
| 43 |
+
|
| 44 |
+
function setStatus(text) {
|
| 45 |
+
if (viewerStatusEl) viewerStatusEl.textContent = String(text || "");
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function resetInput(input) {
|
| 49 |
+
if (input) input.value = "";
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function clamp(v, min, max) {
|
| 53 |
+
return Math.max(min, Math.min(max, v));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function sanitizeFilenamePart(text) {
|
| 57 |
+
return String(text || "")
|
| 58 |
+
.replace(/\.[^.]+$/, "")
|
| 59 |
+
.replace(/[\\/:*?"<>|]+/g, "_")
|
| 60 |
+
.replace(/\s+/g, " ")
|
| 61 |
+
.trim() || "page";
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function getPageLabel(page) {
|
| 65 |
+
return String(page?.relativePath || page?.name || page?.id || "page");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function escapeHtml(text) {
|
| 69 |
+
return String(text || "")
|
| 70 |
+
.replace(/&/g, "&")
|
| 71 |
+
.replace(/</g, "<")
|
| 72 |
+
.replace(/>/g, ">")
|
| 73 |
+
.replace(/"/g, """);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function extFromMime(mime) {
|
| 77 |
+
const m = String(mime || "").toLowerCase();
|
| 78 |
+
if (m.includes("png")) return "png";
|
| 79 |
+
if (m.includes("webp")) return "webp";
|
| 80 |
+
if (m.includes("gif")) return "gif";
|
| 81 |
+
if (m.includes("jpeg") || m.includes("jpg")) return "jpg";
|
| 82 |
+
return "png";
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function parseDataUriMeta(dataUri) {
|
| 86 |
+
const m = String(dataUri || "").match(/^data:([^;,]+)?/i);
|
| 87 |
+
const mime = m?.[1] || "image/png";
|
| 88 |
+
return { mime, ext: extFromMime(mime) };
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async function blobToDataUri(blob) {
|
| 92 |
+
return await new Promise((resolve) => {
|
| 93 |
+
const reader = new FileReader();
|
| 94 |
+
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
| 95 |
+
reader.onerror = () => resolve("");
|
| 96 |
+
reader.readAsDataURL(blob);
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function articleForPage(pageId) {
|
| 101 |
+
return document.querySelector(`.page-strip[data-page-id="${CSS.escape(pageId)}"]`);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function imageForPage(pageId) {
|
| 105 |
+
return articleForPage(pageId)?.querySelector("img") || null;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function overlayRootForPage(pageId) {
|
| 109 |
+
return articleForPage(pageId)?.querySelector(".tp-ol-root") || null;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function badgeText(page) {
|
| 113 |
+
if (page?.overlayApplied) return "Overlay ready";
|
| 114 |
+
if (page?.translatedImageDataUri) return "Image ready";
|
| 115 |
+
return "Original";
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function badgeClass(page) {
|
| 119 |
+
return page?.overlayApplied || page?.translatedImageDataUri ? "badge ok" : "badge";
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function revokeObjectUrls() {
|
| 123 |
+
for (const url of objectUrls.values()) {
|
| 124 |
+
try {
|
| 125 |
+
URL.revokeObjectURL(url);
|
| 126 |
+
} catch {}
|
| 127 |
+
}
|
| 128 |
+
objectUrls.clear();
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function getOrderedPages() {
|
| 132 |
+
return pageOrder.map((id) => pagesById.get(id)).filter(Boolean);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function getSelectedPages() {
|
| 136 |
+
return getOrderedPages().filter((page) => selectedPageIds.has(page.id));
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function updateSelectionSummary() {
|
| 140 |
+
const selected = selectedPageIds.size;
|
| 141 |
+
if (selectionSummaryEl) selectionSummaryEl.textContent = `${selected} selected`;
|
| 142 |
+
if (toggleSelectBtn) {
|
| 143 |
+
const total = pageOrder.length;
|
| 144 |
+
toggleSelectBtn.textContent = total && selected === total ? "Deselect all" : "Select all";
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function applyReaderWidth(px) {
|
| 149 |
+
readerWidth = clamp(Number(px) || 980, Number(zoomRange.min), Number(zoomRange.max));
|
| 150 |
+
document.documentElement.style.setProperty("--reader-width", `${readerWidth}px`);
|
| 151 |
+
localStorage.setItem(READER_WIDTH_KEY, String(readerWidth));
|
| 152 |
+
if (zoomRange) zoomRange.value = String(readerWidth);
|
| 153 |
+
if (zoomIndicatorEl) zoomIndicatorEl.textContent = `${readerWidth}px`;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function pageCountText() {
|
| 157 |
+
const total = pageOrder.length;
|
| 158 |
+
return `${total} page${total === 1 ? "" : "s"}`;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function syncPageUi(pageId) {
|
| 162 |
+
const page = pagesById.get(pageId);
|
| 163 |
+
if (!page) return;
|
| 164 |
+
const checked = selectedPageIds.has(pageId);
|
| 165 |
+
document
|
| 166 |
+
.querySelectorAll(`[data-page-id="${CSS.escape(pageId)}"] input[data-role="select-page"]`)
|
| 167 |
+
.forEach((el) => {
|
| 168 |
+
el.checked = checked;
|
| 169 |
+
});
|
| 170 |
+
document
|
| 171 |
+
.querySelectorAll(`[data-page-id="${CSS.escape(pageId)}"] .page-state-badge`)
|
| 172 |
+
.forEach((badge) => {
|
| 173 |
+
badge.className = `page-state-badge ${badgeClass(page)}`;
|
| 174 |
+
badge.textContent = badgeText(page);
|
| 175 |
+
});
|
| 176 |
+
articleForPage(pageId)?.classList.toggle("active", checked);
|
| 177 |
+
pageListEl
|
| 178 |
+
.querySelector(`.page-list-item[data-page-id="${CSS.escape(pageId)}"]`)
|
| 179 |
+
?.classList.toggle("active", checked);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function syncUi() {
|
| 183 |
+
if (pageCountEl) pageCountEl.textContent = pageCountText();
|
| 184 |
+
updateSelectionSummary();
|
| 185 |
+
for (const id of pageOrder) syncPageUi(id);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function setPageSelection(pageId, checked) {
|
| 189 |
+
if (checked) selectedPageIds.add(pageId);
|
| 190 |
+
else selectedPageIds.delete(pageId);
|
| 191 |
+
syncPageUi(pageId);
|
| 192 |
+
updateSelectionSummary();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function movePage(beforeId, afterId) {
|
| 196 |
+
const from = pageOrder.indexOf(beforeId);
|
| 197 |
+
const to = pageOrder.indexOf(afterId);
|
| 198 |
+
if (from < 0 || to < 0 || from === to) return;
|
| 199 |
+
const next = [...pageOrder];
|
| 200 |
+
const [item] = next.splice(from, 1);
|
| 201 |
+
next.splice(to, 0, item);
|
| 202 |
+
pageOrder = next;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
async function persistCurrentOrder() {
|
| 206 |
+
if (!currentSession?.id) return;
|
| 207 |
+
const pages = getOrderedPages().map((page) => ({
|
| 208 |
+
id: page.id,
|
| 209 |
+
name: page.name,
|
| 210 |
+
relativePath: page.relativePath,
|
| 211 |
+
type: page.type,
|
| 212 |
+
size: page.size,
|
| 213 |
+
lastModified: page.lastModified,
|
| 214 |
+
blob: page.blob,
|
| 215 |
+
}));
|
| 216 |
+
currentSession = await saveLocalSession({
|
| 217 |
+
id: currentSession.id,
|
| 218 |
+
createdAt: currentSession.createdAt,
|
| 219 |
+
title: currentSession.title,
|
| 220 |
+
pages,
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function scrollToPage(pageId) {
|
| 225 |
+
articleForPage(pageId)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function findPageIdByOriginal(original) {
|
| 229 |
+
const key = String(original || "");
|
| 230 |
+
if (!key) return "";
|
| 231 |
+
const direct = originalToPageId.get(key);
|
| 232 |
+
if (direct) return direct;
|
| 233 |
+
for (const id of pageOrder) {
|
| 234 |
+
const page = pagesById.get(id);
|
| 235 |
+
if (page && (page.originalKey === key || page.currentSrc === key)) return id;
|
| 236 |
+
}
|
| 237 |
+
return "";
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
async function getCurrentSettings() {
|
| 241 |
+
const stored = await chrome.storage.local.get(["mode", "lang"]);
|
| 242 |
+
return {
|
| 243 |
+
mode: String(stored.mode || "lens_text") || "lens_text",
|
| 244 |
+
lang: String(stored.lang || "en") || "en",
|
| 245 |
+
};
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
async function makeExportFilename(page, ext) {
|
| 249 |
+
const { mode, lang } = await getCurrentSettings();
|
| 250 |
+
const stem = sanitizeFilenamePart(page?.name || page?.relativePath || page?.id || "page");
|
| 251 |
+
const safeMode = sanitizeFilenamePart(mode || "mode").replace(/\s+/g, "-");
|
| 252 |
+
const safeLang = sanitizeFilenamePart(lang || "en").replace(/\s+/g, "-");
|
| 253 |
+
return `${stem}.${safeMode}.${safeLang}.${ext}`;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function isLocalDownloadUrl(url) {
|
| 257 |
+
const value = String(url || "");
|
| 258 |
+
return value.startsWith("data:") || value.startsWith("blob:") || value.startsWith("chrome-extension:");
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function triggerAnchorDownload(url, filename) {
|
| 262 |
+
const link = document.createElement("a");
|
| 263 |
+
link.href = String(url || "");
|
| 264 |
+
link.download = String(filename || "download");
|
| 265 |
+
document.body.appendChild(link);
|
| 266 |
+
link.click();
|
| 267 |
+
link.remove();
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
async function dataUriToBlob(dataUri) {
|
| 271 |
+
const res = await fetch(String(dataUri || ""));
|
| 272 |
+
return await res.blob();
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async function downloadUrl(url, filename) {
|
| 276 |
+
const targetUrl = String(url || "");
|
| 277 |
+
const targetName = String(filename || "download");
|
| 278 |
+
if (!targetUrl) return;
|
| 279 |
+
if (isLocalDownloadUrl(targetUrl) || !chrome?.downloads?.download) {
|
| 280 |
+
triggerAnchorDownload(targetUrl, targetName);
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
await chrome.downloads.download({
|
| 284 |
+
url: targetUrl,
|
| 285 |
+
filename: targetName,
|
| 286 |
+
saveAs: false,
|
| 287 |
+
});
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async function downloadBlob(blob, filename) {
|
| 291 |
+
const url = URL.createObjectURL(blob);
|
| 292 |
+
try {
|
| 293 |
+
triggerAnchorDownload(url, filename);
|
| 294 |
+
} finally {
|
| 295 |
+
setTimeout(() => {
|
| 296 |
+
try {
|
| 297 |
+
URL.revokeObjectURL(url);
|
| 298 |
+
} catch {}
|
| 299 |
+
}, 1500);
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function currentImageUrlForPage(pageId) {
|
| 304 |
+
const page = pagesById.get(pageId);
|
| 305 |
+
const img = imageForPage(pageId);
|
| 306 |
+
if (!page || !img) return "";
|
| 307 |
+
return String(page.translatedImageDataUri || img.currentSrc || img.src || "");
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
async function downloadImageForPage(pageId) {
|
| 311 |
+
const page = pagesById.get(pageId);
|
| 312 |
+
if (!page) return;
|
| 313 |
+
const src = currentImageUrlForPage(pageId);
|
| 314 |
+
if (!src) return;
|
| 315 |
+
if (src.startsWith("data:")) {
|
| 316 |
+
const meta = parseDataUriMeta(src);
|
| 317 |
+
const blob = await dataUriToBlob(src);
|
| 318 |
+
await downloadBlob(blob, await makeExportFilename(page, meta.ext));
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
const res = await fetch(src);
|
| 322 |
+
const blob = await res.blob();
|
| 323 |
+
const ext = extFromMime(blob.type || page.type);
|
| 324 |
+
await downloadBlob(blob, await makeExportFilename(page, ext));
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
async function buildStandaloneHtml(pageId) {
|
| 328 |
+
const page = pagesById.get(pageId);
|
| 329 |
+
const img = imageForPage(pageId);
|
| 330 |
+
if (!page || !img) return "";
|
| 331 |
+
const overlayRoot = overlayRootForPage(pageId);
|
| 332 |
+
const overlayScope = overlayRoot?.querySelector(".tp-ol-scope") || null;
|
| 333 |
+
const styleText = document.getElementById("textphantom_overlay_css")?.textContent || "";
|
| 334 |
+
const cleanImg = overlayRoot?.querySelector(".tp-ol-clean-img") || null;
|
| 335 |
+
const width = Number(img.naturalWidth) || 1;
|
| 336 |
+
const height = Number(img.naturalHeight) || 1;
|
| 337 |
+
const overlayHtml = overlayScope ? overlayScope.innerHTML : "";
|
| 338 |
+
let bgSrc = String(page.translatedImageDataUri || "");
|
| 339 |
+
if (!bgSrc) {
|
| 340 |
+
const src = String(cleanImg?.currentSrc || cleanImg?.src || img.currentSrc || img.src || "");
|
| 341 |
+
if (src.startsWith("data:")) {
|
| 342 |
+
bgSrc = src;
|
| 343 |
+
} else if (src) {
|
| 344 |
+
const res = await fetch(src);
|
| 345 |
+
const blob = await res.blob();
|
| 346 |
+
bgSrc = await blobToDataUri(blob);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
if (!bgSrc) return "";
|
| 350 |
+
return `<!doctype html>
|
| 351 |
+
<html lang="en">
|
| 352 |
+
<head>
|
| 353 |
+
<meta charset="utf-8" />
|
| 354 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 355 |
+
<title>${escapeHtml(page.name)}</title>
|
| 356 |
+
<style>
|
| 357 |
+
html,body{margin:0;background:#111;color:#fff;font-family:system-ui,sans-serif;}
|
| 358 |
+
.tp-export{position:relative;width:min(100vw,${width}px);margin:0 auto;}
|
| 359 |
+
.tp-export img{display:block;width:100%;height:auto;}
|
| 360 |
+
.tp-export .tp-ol-root{position:absolute!important;inset:0!important;display:block!important;}
|
| 361 |
+
.tp-export .tp-ol-scope{position:absolute!important;inset:0!important;width:100%!important;height:100%!important;}
|
| 362 |
+
${styleText}
|
| 363 |
+
</style>
|
| 364 |
+
</head>
|
| 365 |
+
<body>
|
| 366 |
+
<div class="tp-export" style="aspect-ratio:${width}/${height}">
|
| 367 |
+
<img src="${bgSrc}" width="${width}" height="${height}" alt="${escapeHtml(page.name)}" />
|
| 368 |
+
<div class="tp-ol-root"><div class="tp-ol-scope">${overlayHtml}</div></div>
|
| 369 |
+
</div>
|
| 370 |
+
</body>
|
| 371 |
+
</html>`;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
async function downloadHtmlForPage(pageId) {
|
| 375 |
+
const page = pagesById.get(pageId);
|
| 376 |
+
if (!page) return;
|
| 377 |
+
const html = await buildStandaloneHtml(pageId);
|
| 378 |
+
if (!html) return;
|
| 379 |
+
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
| 380 |
+
await downloadBlob(blob, await makeExportFilename(page, "html"));
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
async function downloadSelected(kind) {
|
| 384 |
+
const selected = getSelectedPages();
|
| 385 |
+
if (!selected.length) return;
|
| 386 |
+
setStatus(
|
| 387 |
+
kind === "html"
|
| 388 |
+
? `Downloading ${selected.length} HTML file(s)…`
|
| 389 |
+
: `Downloading ${selected.length} image file(s)…`,
|
| 390 |
+
);
|
| 391 |
+
for (const page of selected) {
|
| 392 |
+
if (kind === "html") await downloadHtmlForPage(page.id);
|
| 393 |
+
else await downloadImageForPage(page.id);
|
| 394 |
+
}
|
| 395 |
+
setStatus(
|
| 396 |
+
`Downloaded ${selected.length} ${kind === "html" ? "HTML" : "image"} file(s).`,
|
| 397 |
+
);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function renderEmpty() {
|
| 401 |
+
pageListEl.innerHTML = "";
|
| 402 |
+
readerEl.innerHTML = '<div class="empty-state">No local images loaded.</div>';
|
| 403 |
+
syncUi();
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function renderSession() {
|
| 407 |
+
const pages = getOrderedPages();
|
| 408 |
+
if (!pages.length) {
|
| 409 |
+
renderEmpty();
|
| 410 |
+
return;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
pageListEl.innerHTML = pages
|
| 414 |
+
.map(
|
| 415 |
+
(page, index) => `
|
| 416 |
+
<label class="page-list-item ${selectedPageIds.has(page.id) ? "active" : ""}" data-page-id="${page.id}" draggable="true">
|
| 417 |
+
<span class="drag-handle">⋮⋮</span>
|
| 418 |
+
<input type="checkbox" data-role="select-page" data-page-id="${page.id}" ${selectedPageIds.has(page.id) ? "checked" : ""} />
|
| 419 |
+
<div>
|
| 420 |
+
<div class="page-name">${escapeHtml(page.name)}</div>
|
| 421 |
+
<div class="page-meta">${escapeHtml(page.relativePath || `Page ${index + 1}`)}</div>
|
| 422 |
+
</div>
|
| 423 |
+
<span class="page-state-badge ${badgeClass(page)}">${badgeText(page)}</span>
|
| 424 |
+
</label>
|
| 425 |
+
`,
|
| 426 |
+
)
|
| 427 |
+
.join("");
|
| 428 |
+
|
| 429 |
+
readerEl.innerHTML = pages
|
| 430 |
+
.map(
|
| 431 |
+
(page, index) => `
|
| 432 |
+
<article class="page-strip ${selectedPageIds.has(page.id) ? "active" : ""}" data-page-id="${page.id}">
|
| 433 |
+
<div class="page-strip-head">
|
| 434 |
+
<div class="page-strip-title">
|
| 435 |
+
<input type="checkbox" data-role="select-page" data-page-id="${page.id}" ${selectedPageIds.has(page.id) ? "checked" : ""} />
|
| 436 |
+
<div>
|
| 437 |
+
<strong>${escapeHtml(page.name)}</strong>
|
| 438 |
+
<div class="page-meta">${escapeHtml(page.relativePath || `Page ${index + 1}`)}</div>
|
| 439 |
+
</div>
|
| 440 |
+
<span class="page-state-badge ${badgeClass(page)}">${badgeText(page)}</span>
|
| 441 |
+
</div>
|
| 442 |
+
<div class="page-actions">
|
| 443 |
+
<button type="button" class="btn" data-role="download-image" data-page-id="${page.id}">Image</button>
|
| 444 |
+
<button type="button" class="btn" data-role="download-html" data-page-id="${page.id}">HTML</button>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
<div class="page-frame">
|
| 448 |
+
<img src="${page.objectUrl}" alt="${escapeHtml(page.name)}" loading="eager" data-tp-original="${page.originalKey}" />
|
| 449 |
+
</div>
|
| 450 |
+
</article>
|
| 451 |
+
`,
|
| 452 |
+
)
|
| 453 |
+
.join("");
|
| 454 |
+
|
| 455 |
+
syncUi();
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
async function loadSessionIntoView(id) {
|
| 459 |
+
currentSession = await loadLocalSession(id);
|
| 460 |
+
pagesById.clear();
|
| 461 |
+
selectedPageIds.clear();
|
| 462 |
+
originalToPageId.clear();
|
| 463 |
+
revokeObjectUrls();
|
| 464 |
+
pageOrder = [];
|
| 465 |
+
|
| 466 |
+
const pages = Array.isArray(currentSession?.pages) ? currentSession.pages : [];
|
| 467 |
+
for (const raw of pages) {
|
| 468 |
+
const objectUrl = URL.createObjectURL(raw.blob);
|
| 469 |
+
objectUrls.set(raw.id, objectUrl);
|
| 470 |
+
const page = {
|
| 471 |
+
...raw,
|
| 472 |
+
objectUrl,
|
| 473 |
+
blob: raw.blob,
|
| 474 |
+
originalKey: objectUrl,
|
| 475 |
+
currentSrc: objectUrl,
|
| 476 |
+
translatedImageDataUri: "",
|
| 477 |
+
overlayApplied: false,
|
| 478 |
+
};
|
| 479 |
+
pagesById.set(page.id, page);
|
| 480 |
+
originalToPageId.set(page.originalKey, page.id);
|
| 481 |
+
selectedPageIds.add(page.id);
|
| 482 |
+
pageOrder.push(page.id);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
renderSession();
|
| 486 |
+
if (pages.length) {
|
| 487 |
+
setStatus(`Loaded ${pages.length} local image(s). Right-click any image to translate.`);
|
| 488 |
+
} else {
|
| 489 |
+
setStatus("No local images found in this session.");
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async function replaceSessionFromFiles(fileList, sourceLabel) {
|
| 494 |
+
const files = filterImageFiles(fileList);
|
| 495 |
+
if (!files.length) return;
|
| 496 |
+
const next = await saveLocalSession({
|
| 497 |
+
id: crypto.randomUUID(),
|
| 498 |
+
createdAt: Date.now(),
|
| 499 |
+
title: sourceLabel,
|
| 500 |
+
pages: sortLocalPages(files.map((file, index) => toLocalPageRecord(file, index))),
|
| 501 |
+
});
|
| 502 |
+
if (currentSession?.id) {
|
| 503 |
+
deleteLocalSession(currentSession.id).catch(() => {});
|
| 504 |
+
}
|
| 505 |
+
location.href = `${location.pathname}?sid=${encodeURIComponent(next.id)}`;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
async function clearViewer() {
|
| 509 |
+
if (currentSession?.id) {
|
| 510 |
+
await deleteLocalSession(currentSession.id).catch(() => {});
|
| 511 |
+
}
|
| 512 |
+
currentSession = null;
|
| 513 |
+
pagesById.clear();
|
| 514 |
+
selectedPageIds.clear();
|
| 515 |
+
originalToPageId.clear();
|
| 516 |
+
pageOrder = [];
|
| 517 |
+
revokeObjectUrls();
|
| 518 |
+
renderEmpty();
|
| 519 |
+
setStatus("Viewer cleared.");
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
function handleTranslatedImage(detail) {
|
| 523 |
+
const pageId = findPageIdByOriginal(detail?.original);
|
| 524 |
+
if (!pageId) return;
|
| 525 |
+
const page = pagesById.get(pageId);
|
| 526 |
+
if (!page) return;
|
| 527 |
+
page.currentSrc = String(detail?.newSrc || page.currentSrc || "");
|
| 528 |
+
if (page.currentSrc) originalToPageId.set(page.currentSrc, pageId);
|
| 529 |
+
if (String(detail?.rawNewSrc || "").startsWith("data:")) {
|
| 530 |
+
page.translatedImageDataUri = detail.rawNewSrc;
|
| 531 |
+
}
|
| 532 |
+
pagesById.set(pageId, page);
|
| 533 |
+
syncPageUi(pageId);
|
| 534 |
+
setStatus(`Updated ${page.name}`);
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
function handleOverlayUpdated(detail) {
|
| 538 |
+
const pageId = findPageIdByOriginal(detail?.original);
|
| 539 |
+
if (!pageId) return;
|
| 540 |
+
const page = pagesById.get(pageId);
|
| 541 |
+
if (!page) return;
|
| 542 |
+
page.overlayApplied = true;
|
| 543 |
+
const imageDataUri = String(detail?.result?.imageDataUri || "");
|
| 544 |
+
if (imageDataUri.startsWith("data:")) page.translatedImageDataUri = imageDataUri;
|
| 545 |
+
pagesById.set(pageId, page);
|
| 546 |
+
syncPageUi(pageId);
|
| 547 |
+
setStatus(`Overlay ready for ${page.name}`);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
pageListEl.addEventListener("click", (event) => {
|
| 551 |
+
const item = event.target.closest(".page-list-item");
|
| 552 |
+
if (!item) return;
|
| 553 |
+
const pageId = String(item.dataset.pageId || "");
|
| 554 |
+
if (!pageId) return;
|
| 555 |
+
if (event.target.closest('input[type="checkbox"]')) return;
|
| 556 |
+
scrollToPage(pageId);
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
pageListEl.addEventListener("dragstart", (event) => {
|
| 560 |
+
const item = event.target.closest(".page-list-item");
|
| 561 |
+
if (!item) return;
|
| 562 |
+
dragPageId = String(item.dataset.pageId || "");
|
| 563 |
+
if (event.dataTransfer) {
|
| 564 |
+
event.dataTransfer.effectAllowed = "move";
|
| 565 |
+
event.dataTransfer.setData("text/plain", dragPageId);
|
| 566 |
+
}
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
pageListEl.addEventListener("dragover", (event) => {
|
| 570 |
+
const item = event.target.closest(".page-list-item");
|
| 571 |
+
if (!item || !dragPageId) return;
|
| 572 |
+
event.preventDefault();
|
| 573 |
+
pageListEl.querySelectorAll(".page-list-item.drag-over").forEach((el) => {
|
| 574 |
+
el.classList.remove("drag-over");
|
| 575 |
+
});
|
| 576 |
+
item.classList.add("drag-over");
|
| 577 |
+
});
|
| 578 |
+
|
| 579 |
+
pageListEl.addEventListener("dragleave", (event) => {
|
| 580 |
+
const item = event.target.closest(".page-list-item");
|
| 581 |
+
item?.classList.remove("drag-over");
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
pageListEl.addEventListener("drop", async (event) => {
|
| 585 |
+
const item = event.target.closest(".page-list-item");
|
| 586 |
+
if (!item || !dragPageId) return;
|
| 587 |
+
event.preventDefault();
|
| 588 |
+
item.classList.remove("drag-over");
|
| 589 |
+
const targetId = String(item.dataset.pageId || "");
|
| 590 |
+
if (!targetId || targetId === dragPageId) return;
|
| 591 |
+
movePage(dragPageId, targetId);
|
| 592 |
+
dragPageId = "";
|
| 593 |
+
renderSession();
|
| 594 |
+
await persistCurrentOrder();
|
| 595 |
+
setStatus("Reader order updated.");
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
pageListEl.addEventListener("dragend", () => {
|
| 599 |
+
dragPageId = "";
|
| 600 |
+
pageListEl.querySelectorAll(".page-list-item.drag-over").forEach((el) => {
|
| 601 |
+
el.classList.remove("drag-over");
|
| 602 |
+
});
|
| 603 |
+
});
|
| 604 |
+
|
| 605 |
+
for (const root of [pageListEl, readerEl]) {
|
| 606 |
+
root.addEventListener("change", (event) => {
|
| 607 |
+
const input = event.target.closest('input[data-role="select-page"]');
|
| 608 |
+
if (!input) return;
|
| 609 |
+
setPageSelection(String(input.dataset.pageId || ""), Boolean(input.checked));
|
| 610 |
+
});
|
| 611 |
+
root.addEventListener("click", async (event) => {
|
| 612 |
+
const button = event.target.closest("button[data-role]");
|
| 613 |
+
if (!button) return;
|
| 614 |
+
const pageId = String(button.dataset.pageId || "");
|
| 615 |
+
const role = String(button.dataset.role || "");
|
| 616 |
+
if (!pageId || !role) return;
|
| 617 |
+
if (role === "download-image") await downloadImageForPage(pageId);
|
| 618 |
+
if (role === "download-html") await downloadHtmlForPage(pageId);
|
| 619 |
+
});
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
openImagesBtn?.addEventListener("click", () => {
|
| 623 |
+
resetInput(imagesInput);
|
| 624 |
+
imagesInput?.click();
|
| 625 |
+
});
|
| 626 |
+
|
| 627 |
+
openFolderBtn?.addEventListener("click", () => {
|
| 628 |
+
resetInput(folderInput);
|
| 629 |
+
folderInput?.click();
|
| 630 |
+
});
|
| 631 |
+
|
| 632 |
+
imagesInput?.addEventListener("change", async () => {
|
| 633 |
+
const files = [...(imagesInput.files || [])];
|
| 634 |
+
resetInput(imagesInput);
|
| 635 |
+
await replaceSessionFromFiles(files, "images");
|
| 636 |
+
});
|
| 637 |
+
|
| 638 |
+
folderInput?.addEventListener("change", async () => {
|
| 639 |
+
const files = [...(folderInput.files || [])];
|
| 640 |
+
resetInput(folderInput);
|
| 641 |
+
await replaceSessionFromFiles(files, "folder");
|
| 642 |
+
});
|
| 643 |
+
|
| 644 |
+
toggleSelectBtn?.addEventListener("click", () => {
|
| 645 |
+
const total = pageOrder.length;
|
| 646 |
+
if (!total) return;
|
| 647 |
+
if (selectedPageIds.size === total) selectedPageIds.clear();
|
| 648 |
+
else pageOrder.forEach((id) => selectedPageIds.add(id));
|
| 649 |
+
syncUi();
|
| 650 |
+
});
|
| 651 |
+
|
| 652 |
+
reverseOrderBtn?.addEventListener("click", async () => {
|
| 653 |
+
pageOrder.reverse();
|
| 654 |
+
renderSession();
|
| 655 |
+
await persistCurrentOrder();
|
| 656 |
+
setStatus("Reader order reversed.");
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
clearViewerBtn?.addEventListener("click", async () => {
|
| 660 |
+
await clearViewer();
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
zoomOutBtn?.addEventListener("click", () => applyReaderWidth(readerWidth - 120));
|
| 664 |
+
zoomInBtn?.addEventListener("click", () => applyReaderWidth(readerWidth + 120));
|
| 665 |
+
fitWidthBtn?.addEventListener("click", () => {
|
| 666 |
+
const sidebarOffset = window.innerWidth > 1120 ? 420 : 48;
|
| 667 |
+
applyReaderWidth(window.innerWidth - sidebarOffset);
|
| 668 |
+
});
|
| 669 |
+
zoomRange?.addEventListener("input", () => applyReaderWidth(zoomRange.value));
|
| 670 |
+
|
| 671 |
+
document.addEventListener(
|
| 672 |
+
"wheel",
|
| 673 |
+
(event) => {
|
| 674 |
+
if (!(event.ctrlKey || event.metaKey)) return;
|
| 675 |
+
event.preventDefault();
|
| 676 |
+
applyReaderWidth(readerWidth + (event.deltaY > 0 ? -80 : 80));
|
| 677 |
+
},
|
| 678 |
+
{ passive: false },
|
| 679 |
+
);
|
| 680 |
+
|
| 681 |
+
downloadSelectedImagesBtn?.addEventListener("click", async () => {
|
| 682 |
+
await downloadSelected("image");
|
| 683 |
+
});
|
| 684 |
+
|
| 685 |
+
downloadSelectedHtmlBtn?.addEventListener("click", async () => {
|
| 686 |
+
await downloadSelected("html");
|
| 687 |
+
});
|
| 688 |
+
|
| 689 |
+
window.addEventListener("textphantom:image-updated", (event) => {
|
| 690 |
+
handleTranslatedImage(event.detail || {});
|
| 691 |
+
});
|
| 692 |
+
|
| 693 |
+
window.addEventListener("textphantom:overlay-updated", (event) => {
|
| 694 |
+
handleOverlayUpdated(event.detail || {});
|
| 695 |
+
});
|
| 696 |
+
|
| 697 |
+
window.addEventListener("beforeunload", () => {
|
| 698 |
+
revokeObjectUrls();
|
| 699 |
+
});
|
| 700 |
+
|
| 701 |
+
(async () => {
|
| 702 |
+
applyReaderWidth(readerWidth);
|
| 703 |
+
if (!sessionId) {
|
| 704 |
+
renderEmpty();
|
| 705 |
+
setStatus("Missing local viewer session.");
|
| 706 |
+
return;
|
| 707 |
+
}
|
| 708 |
+
await loadSessionIntoView(sessionId);
|
| 709 |
+
})();
|
local_gallery.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function promptKey(lang, model) {
|
| 2 |
+
const l = String(lang || "en").trim() || "en";
|
| 3 |
+
const m = String(model || "auto").trim() || "auto";
|
| 4 |
+
return `${l}::${m}`;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function sanitizeAiPrompt(raw, { maxChars = 1200 } = {}) {
|
| 8 |
+
let s = typeof raw === "string" ? raw : String(raw || "");
|
| 9 |
+
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
| 10 |
+
s = s.trim();
|
| 11 |
+
if (!s) return "";
|
| 12 |
+
s = s.replace(/[\t ]+$/gm, "");
|
| 13 |
+
s = s.replace(/\n{3,}/g, "\n\n");
|
| 14 |
+
if (s.length > maxChars) s = s.slice(0, maxChars).trimEnd();
|
| 15 |
+
return s;
|
| 16 |
+
}
|
logger.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeUrl } from "./url.js";
|
| 2 |
+
|
| 3 |
+
const REMOTE_DEFAULTS_URL ="https://raw.githubusercontent.com/Kuju29/TextPhantomOCR_Overlay/refs/heads/main/defaults_api.json";
|
| 4 |
+
|
| 5 |
+
const DEFAULT_API_URL_FALLBACK = "";
|
| 6 |
+
const RESET_API_URL_FALLBACK = "";
|
| 7 |
+
|
| 8 |
+
const FETCH_TIMEOUT_MS = 2000;
|
| 9 |
+
const TTL_MS = 12 * 60 * 60 * 1000;
|
| 10 |
+
|
| 11 |
+
function now() {
|
| 12 |
+
return Date.now();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function coerceUrl(v) {
|
| 16 |
+
return typeof v === "string" ? normalizeUrl(v) : "";
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function parseMaybeJson(raw) {
|
| 20 |
+
if (typeof raw !== "string") return null;
|
| 21 |
+
const t = raw.trim();
|
| 22 |
+
if (!t) return null;
|
| 23 |
+
try {
|
| 24 |
+
return JSON.parse(t);
|
| 25 |
+
} catch {
|
| 26 |
+
if (!(t.startsWith("{") && t.endsWith("}"))) return null;
|
| 27 |
+
const q1 = t.replace(/([{,]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":');
|
| 28 |
+
const q2 = q1.includes("'") && !q1.includes('"') ? q1.replace(/'/g, '"') : q1;
|
| 29 |
+
try {
|
| 30 |
+
return JSON.parse(q2);
|
| 31 |
+
} catch {
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async function fetchRemoteDefaults() {
|
| 38 |
+
const ctrl = new AbortController();
|
| 39 |
+
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
| 40 |
+
try {
|
| 41 |
+
const res = await fetch(REMOTE_DEFAULTS_URL, {
|
| 42 |
+
method: "GET",
|
| 43 |
+
headers: { accept: "application/json" },
|
| 44 |
+
cache: "no-store",
|
| 45 |
+
signal: ctrl.signal,
|
| 46 |
+
});
|
| 47 |
+
if (!res.ok) return null;
|
| 48 |
+
const raw = await res.text();
|
| 49 |
+
const data = parseMaybeJson(raw);
|
| 50 |
+
if (!data || typeof data !== "object") return null;
|
| 51 |
+
const defaultApiUrl =
|
| 52 |
+
coerceUrl(data.defaultApiUrl) ||
|
| 53 |
+
coerceUrl(data.DEFAULTS_API) ||
|
| 54 |
+
coerceUrl(data.apiUrlDefault) ||
|
| 55 |
+
coerceUrl(data.default_api_url);
|
| 56 |
+
const resetApiUrl =
|
| 57 |
+
coerceUrl(data.resetApiUrl) ||
|
| 58 |
+
coerceUrl(data.apiUrlReset) ||
|
| 59 |
+
coerceUrl(data.reset_api_url);
|
| 60 |
+
if (!defaultApiUrl && !resetApiUrl) return null;
|
| 61 |
+
return {
|
| 62 |
+
defaultApiUrl: defaultApiUrl || "",
|
| 63 |
+
resetApiUrl: resetApiUrl || "",
|
| 64 |
+
};
|
| 65 |
+
} catch {
|
| 66 |
+
return null;
|
| 67 |
+
} finally {
|
| 68 |
+
clearTimeout(t);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export async function ensureApiDefaults({ force = false } = {}) {
|
| 73 |
+
const stored = await chrome.storage.local.get({
|
| 74 |
+
apiUrlDefault: DEFAULT_API_URL_FALLBACK,
|
| 75 |
+
apiUrlReset: RESET_API_URL_FALLBACK,
|
| 76 |
+
apiDefaultsFetchedAt: 0,
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
const current = {
|
| 80 |
+
defaultApiUrl: coerceUrl(stored.apiUrlDefault) || DEFAULT_API_URL_FALLBACK,
|
| 81 |
+
resetApiUrl: coerceUrl(stored.apiUrlReset) || RESET_API_URL_FALLBACK,
|
| 82 |
+
fetchedAt: Number(stored.apiDefaultsFetchedAt) || 0,
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const fresh = current.fetchedAt && now() - current.fetchedAt <= TTL_MS;
|
| 86 |
+
if (!force && fresh) return current;
|
| 87 |
+
|
| 88 |
+
const remote = await fetchRemoteDefaults();
|
| 89 |
+
if (!remote) return current;
|
| 90 |
+
|
| 91 |
+
const next = {
|
| 92 |
+
defaultApiUrl: remote.defaultApiUrl || current.defaultApiUrl,
|
| 93 |
+
resetApiUrl: remote.resetApiUrl || current.resetApiUrl,
|
| 94 |
+
fetchedAt: now(),
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
await chrome.storage.local.set({
|
| 98 |
+
apiUrlDefault: next.defaultApiUrl,
|
| 99 |
+
apiUrlReset: next.resetApiUrl,
|
| 100 |
+
apiDefaultsFetchedAt: next.fetchedAt,
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
return next;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export const API_DEFAULTS = {
|
| 107 |
+
REMOTE_DEFAULTS_URL,
|
| 108 |
+
DEFAULT_API_URL_FALLBACK,
|
| 109 |
+
RESET_API_URL_FALLBACK,
|
| 110 |
+
};
|
manifest.json
ADDED
|
@@ -0,0 +1,2321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLogger } from "./shared/logger.js";
|
| 2 |
+
import { normalizeUrl, toWs } from "./shared/url.js";
|
| 3 |
+
import { ensureApiDefaults } from "./shared/api_defaults.js";
|
| 4 |
+
import { makePromptKey, migratePromptMap, normalizeAiModel, normalizePrompt } from "./shared/prompt.js";
|
| 5 |
+
|
| 6 |
+
const { debug: log, info, warn, error } = createLogger("LensSW");
|
| 7 |
+
|
| 8 |
+
ensureApiDefaults().catch(() => {});
|
| 9 |
+
|
| 10 |
+
const t0 = () =>
|
| 11 |
+
typeof performance !== "undefined" && performance.now
|
| 12 |
+
? performance.now()
|
| 13 |
+
: Date.now();
|
| 14 |
+
|
| 15 |
+
const fmtMs = (ms) => ms.toFixed(1) + "ms";
|
| 16 |
+
|
| 17 |
+
function ev(tag, obj = {}) {
|
| 18 |
+
info(`[EV] ${tag}`, obj);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function evWarn(tag, obj = {}) {
|
| 22 |
+
warn(`[EV] ${tag}`, obj);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const PREFLIGHT_TIMEOUT_MS = 10000;
|
| 26 |
+
const WS_OPEN_TIMEOUT_MS = 20000;
|
| 27 |
+
const WS_RETRIES = 6;
|
| 28 |
+
const MAX_FIRST_TRY_RETRIES = 2;
|
| 29 |
+
const FIRST_TRY_GAP_MS = 3000;
|
| 30 |
+
|
| 31 |
+
const WARMUP_PATH = "/warmup";
|
| 32 |
+
const WARMUP_TIMEOUT_MS = 2500;
|
| 33 |
+
const WARMUP_TTL_MS = 20 * 60 * 1000;
|
| 34 |
+
|
| 35 |
+
const SOFT_MAX_CONCURRENCY_DEFAULT = 15;
|
| 36 |
+
|
| 37 |
+
let MAX_CONCURRENCY = 10;
|
| 38 |
+
let softMaxConcurrency = SOFT_MAX_CONCURRENCY_DEFAULT;
|
| 39 |
+
let forceSoftMaxConcurrency = false;
|
| 40 |
+
let ws = null;
|
| 41 |
+
let wsReady = false;
|
| 42 |
+
let wsStatus = "idle";
|
| 43 |
+
let currentBase = null;
|
| 44 |
+
let currentBatchId = null;
|
| 45 |
+
let blockSendsBecauseWsEnded = false;
|
| 46 |
+
let running = 0;
|
| 47 |
+
|
| 48 |
+
const queue = [];
|
| 49 |
+
const pendingByJob = new Map();
|
| 50 |
+
const pendingByImage = new Map();
|
| 51 |
+
|
| 52 |
+
function shouldPreferRest(base, mode, source) {
|
| 53 |
+
const m = String(mode || "").trim();
|
| 54 |
+
if (m === "lens_images") return true;
|
| 55 |
+
if (m === "lens_text") return true;
|
| 56 |
+
try {
|
| 57 |
+
const host = new URL(String(base || "")).hostname.toLowerCase();
|
| 58 |
+
if (!host) return true;
|
| 59 |
+
if (host.endsWith(".hf.space")) return true;
|
| 60 |
+
if (host.endsWith("huggingface.co")) return true;
|
| 61 |
+
return true;
|
| 62 |
+
} catch {
|
| 63 |
+
return true;
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function effectiveMaxConcurrency() {
|
| 68 |
+
const soft =
|
| 69 |
+
Number(softMaxConcurrency) > 0
|
| 70 |
+
? Number(softMaxConcurrency)
|
| 71 |
+
: SOFT_MAX_CONCURRENCY_DEFAULT;
|
| 72 |
+
const n = Number(MAX_CONCURRENCY) || 0;
|
| 73 |
+
if (forceSoftMaxConcurrency) {
|
| 74 |
+
if (n > 0 && n < soft) return n;
|
| 75 |
+
return soft;
|
| 76 |
+
}
|
| 77 |
+
return n;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function aiSoftMaxConcurrencyFromKey(key) {
|
| 81 |
+
const k = String(key || "").trim();
|
| 82 |
+
if (k.startsWith("hf_")) return 3;
|
| 83 |
+
return 10;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function statusTextFor(code, fallback = "") {
|
| 87 |
+
if (code === 200) return "OK";
|
| 88 |
+
if (code === 201) return "Created";
|
| 89 |
+
if (code === 202) return "Accepted";
|
| 90 |
+
if (code === 204) return "No Content";
|
| 91 |
+
if (code === 400) return "Bad Request";
|
| 92 |
+
if (code === 401) return "Unauthorized";
|
| 93 |
+
if (code === 403) return "Forbidden";
|
| 94 |
+
if (code === 404) return "Not Found";
|
| 95 |
+
if (code === 408) return "Request Timeout";
|
| 96 |
+
if (code === 409) return "Conflict";
|
| 97 |
+
if (code === 413) return "Payload Too Large";
|
| 98 |
+
if (code === 415) return "Unsupported Media Type";
|
| 99 |
+
if (code === 429) return "Too Many Requests";
|
| 100 |
+
if (code === 500) return "Internal Server Error";
|
| 101 |
+
if (code === 502) return "Bad Gateway";
|
| 102 |
+
if (code === 503) return "Service Unavailable";
|
| 103 |
+
if (code === 504) return "Gateway Timeout";
|
| 104 |
+
return fallback || "";
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function logImageRestAccessLine(base, fullUrl, statusCode, statusText = "") {
|
| 108 |
+
try {
|
| 109 |
+
const b = new URL(String(base || ""));
|
| 110 |
+
const u = new URL(String(fullUrl || ""));
|
| 111 |
+
const host = b.hostname === "localhost" ? "127.0.0.1" : b.hostname;
|
| 112 |
+
const st = statusTextFor(Number(statusCode) || 0, statusText);
|
| 113 |
+
info(`${host} - "GET ${u.pathname} HTTP/1.1" ${statusCode} ${st}`);
|
| 114 |
+
} catch {}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const BATCH_TOAST_MIN_INTERVAL_MS = 350;
|
| 118 |
+
const BATCH_RETRY_GAP_MS = 1800;
|
| 119 |
+
const BATCH_TTL_MS = 20 * 60 * 1000;
|
| 120 |
+
|
| 121 |
+
const batches = new Map();
|
| 122 |
+
|
| 123 |
+
let lastBatchStatus = null;
|
| 124 |
+
|
| 125 |
+
function normImgSrc(src) {
|
| 126 |
+
const s = String(src || "").trim();
|
| 127 |
+
if (!s) return "";
|
| 128 |
+
try {
|
| 129 |
+
const u = new URL(s);
|
| 130 |
+
u.hash = "";
|
| 131 |
+
return u.toString();
|
| 132 |
+
} catch {
|
| 133 |
+
return s;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function imageKeyFromPayload(payload) {
|
| 138 |
+
const id = String(payload?.metadata?.image_id || "").trim();
|
| 139 |
+
if (id) return id;
|
| 140 |
+
return normImgSrc(payload?.src);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function ensureBatch(batchId, tabId, frameId) {
|
| 144 |
+
const id = String(batchId || "");
|
| 145 |
+
if (!id) return null;
|
| 146 |
+
const now = Date.now();
|
| 147 |
+
let b = batches.get(id);
|
| 148 |
+
if (!b) {
|
| 149 |
+
b = {
|
| 150 |
+
id,
|
| 151 |
+
tabId: Number.isFinite(tabId) ? tabId : 0,
|
| 152 |
+
frameId: Number(frameId) || 0,
|
| 153 |
+
createdAt: now,
|
| 154 |
+
pass: 1,
|
| 155 |
+
total1: 0,
|
| 156 |
+
total2: 0,
|
| 157 |
+
lastToastTs: 0,
|
| 158 |
+
retryScheduled: false,
|
| 159 |
+
items: new Map(),
|
| 160 |
+
};
|
| 161 |
+
batches.set(id, b);
|
| 162 |
+
} else {
|
| 163 |
+
if (Number.isFinite(tabId)) b.tabId = tabId;
|
| 164 |
+
if (Number.isFinite(frameId)) b.frameId = Number(frameId) || 0;
|
| 165 |
+
}
|
| 166 |
+
return b;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function pruneBatches(now = Date.now()) {
|
| 170 |
+
for (const [id, b] of batches.entries()) {
|
| 171 |
+
if (!b || now - (b.createdAt || now) > BATCH_TTL_MS) batches.delete(id);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function batchPassTotal(b) {
|
| 176 |
+
if (!b) return 0;
|
| 177 |
+
return b.pass === 2 ? Number(b.total2) || 0 : Number(b.total1) || 0;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function batchPassStats(b) {
|
| 181 |
+
const pass = b?.pass || 1;
|
| 182 |
+
const total = batchPassTotal(b);
|
| 183 |
+
let queued = 0;
|
| 184 |
+
let processing = 0;
|
| 185 |
+
let inserting = 0;
|
| 186 |
+
let done = 0;
|
| 187 |
+
let error = 0;
|
| 188 |
+
let aborted = 0;
|
| 189 |
+
|
| 190 |
+
for (const it of b?.items?.values?.() || []) {
|
| 191 |
+
if (!it || it.attempt !== pass) continue;
|
| 192 |
+
const st = it.status;
|
| 193 |
+
if (st === "queued") queued++;
|
| 194 |
+
else if (st === "processing") processing++;
|
| 195 |
+
else if (st === "inserting") inserting++;
|
| 196 |
+
else if (st === "done") done++;
|
| 197 |
+
else if (st === "error") error++;
|
| 198 |
+
else if (st === "aborted") aborted++;
|
| 199 |
+
}
|
| 200 |
+
const finished = done + error + aborted;
|
| 201 |
+
return {
|
| 202 |
+
pass,
|
| 203 |
+
total,
|
| 204 |
+
queued,
|
| 205 |
+
processing,
|
| 206 |
+
inserting,
|
| 207 |
+
done,
|
| 208 |
+
error,
|
| 209 |
+
aborted,
|
| 210 |
+
finished,
|
| 211 |
+
};
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function batchToast(b, text, ms = 2000, force = false) {
|
| 215 |
+
if (!b || !b.tabId || !text) return;
|
| 216 |
+
const now = Date.now();
|
| 217 |
+
if (!force && now - (b.lastToastTs || 0) < BATCH_TOAST_MIN_INTERVAL_MS)
|
| 218 |
+
return;
|
| 219 |
+
b.lastToastTs = now;
|
| 220 |
+
sendToastToTab(b.tabId, b.frameId || 0, text, ms);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function batchUpdateToast(b, stage, force = false) {
|
| 224 |
+
if (!b) return;
|
| 225 |
+
pruneBatches();
|
| 226 |
+
const s = batchPassStats(b);
|
| 227 |
+
const total = s.total;
|
| 228 |
+
const head = b.pass === 2 ? "TextPhantom: รอบแก้ไข" : "TextPhantom:";
|
| 229 |
+
const parts = [];
|
| 230 |
+
if (total) parts.push(`รับภาพ ${total}`);
|
| 231 |
+
if (s.processing || s.inserting || s.queued)
|
| 232 |
+
parts.push(`ประมวลผล ${s.processing + s.inserting}/${total}`);
|
| 233 |
+
if (s.done) parts.push(`แทรกกลับ ${s.done}/${total}`);
|
| 234 |
+
if (s.error) parts.push(`ผิดพลาด ${s.error}`);
|
| 235 |
+
if (s.aborted) parts.push(`ยกเลิก ${s.aborted}`);
|
| 236 |
+
const extra = stage ? `• ${stage}` : "";
|
| 237 |
+
const msg = `${head} ${parts.join(" | ")} ${extra}`.trim();
|
| 238 |
+
const ms = s.finished >= total && total ? 2400 : 60000;
|
| 239 |
+
batchToast(b, msg, ms, force);
|
| 240 |
+
lastBatchStatus = {
|
| 241 |
+
id: b.id,
|
| 242 |
+
tabId: b.tabId || 0,
|
| 243 |
+
frameId: b.frameId || 0,
|
| 244 |
+
pass: s.pass,
|
| 245 |
+
stage: String(stage || ""),
|
| 246 |
+
message: msg,
|
| 247 |
+
stats: s,
|
| 248 |
+
ts: Date.now(),
|
| 249 |
+
};
|
| 250 |
+
try {
|
| 251 |
+
chrome.runtime.sendMessage(
|
| 252 |
+
{ type: "BATCH_STATUS_UPDATE", batch: lastBatchStatus },
|
| 253 |
+
() => void chrome.runtime.lastError,
|
| 254 |
+
);
|
| 255 |
+
} catch {}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function batchMark(batchId, imageKey, patch) {
|
| 259 |
+
const b = batches.get(String(batchId || ""));
|
| 260 |
+
if (!b) return null;
|
| 261 |
+
const k = String(imageKey || "").trim();
|
| 262 |
+
if (!k) return b;
|
| 263 |
+
const cur = b.items.get(k);
|
| 264 |
+
if (!cur) return b;
|
| 265 |
+
b.items.set(k, { ...cur, ...patch });
|
| 266 |
+
return b;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async function batchStopKeepAlive(b) {
|
| 270 |
+
if (!b?.tabId) return;
|
| 271 |
+
try {
|
| 272 |
+
await sendToTab(b.tabId, { type: "TP_KEEPALIVE_STOP" }, b.frameId || 0);
|
| 273 |
+
} catch {}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function batchFinalizeIfComplete(b) {
|
| 277 |
+
if (!b) return;
|
| 278 |
+
const s = batchPassStats(b);
|
| 279 |
+
if (!s.total || s.finished < s.total) return;
|
| 280 |
+
|
| 281 |
+
if (b.pass === 1) {
|
| 282 |
+
if (b.retryScheduled) return;
|
| 283 |
+
const failed = [];
|
| 284 |
+
let permanentErr = 0;
|
| 285 |
+
for (const [k, it] of b.items.entries()) {
|
| 286 |
+
if (it?.attempt !== 1 || it.status !== "error") continue;
|
| 287 |
+
if (it?.permanent) {
|
| 288 |
+
permanentErr++;
|
| 289 |
+
continue;
|
| 290 |
+
}
|
| 291 |
+
failed.push(k);
|
| 292 |
+
}
|
| 293 |
+
if (!failed.length) {
|
| 294 |
+
const msg = permanentErr
|
| 295 |
+
? `เสร็จสิ้น (ผิดพลาดถาวร ${permanentErr})`
|
| 296 |
+
: "เสร็จสิ้น";
|
| 297 |
+
batchUpdateToast(b, msg, true);
|
| 298 |
+
batchStopKeepAlive(b);
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
b.retryScheduled = true;
|
| 302 |
+
b.pass = 2;
|
| 303 |
+
b.total2 = failed.length;
|
| 304 |
+
for (const k of failed) {
|
| 305 |
+
const it = b.items.get(k);
|
| 306 |
+
if (!it) continue;
|
| 307 |
+
const base = it.payload || null;
|
| 308 |
+
const meta =
|
| 309 |
+
base?.metadata && typeof base.metadata === "object"
|
| 310 |
+
? base.metadata
|
| 311 |
+
: {};
|
| 312 |
+
const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : [];
|
| 313 |
+
const nextPayload = {
|
| 314 |
+
...base,
|
| 315 |
+
metadata: {
|
| 316 |
+
...meta,
|
| 317 |
+
pipeline: pipe.concat({
|
| 318 |
+
stage: "retry_failed_once",
|
| 319 |
+
at: new Date().toISOString(),
|
| 320 |
+
}),
|
| 321 |
+
timestamp: new Date().toISOString(),
|
| 322 |
+
},
|
| 323 |
+
};
|
| 324 |
+
b.items.set(k, {
|
| 325 |
+
...it,
|
| 326 |
+
payload: nextPayload,
|
| 327 |
+
attempt: 2,
|
| 328 |
+
status: "queued",
|
| 329 |
+
});
|
| 330 |
+
}
|
| 331 |
+
batchUpdateToast(
|
| 332 |
+
b,
|
| 333 |
+
`พักก่อน แล้วลองแก้ไขอีกครั้ง ${failed.length} ภาพ`,
|
| 334 |
+
true,
|
| 335 |
+
);
|
| 336 |
+
addTask(async () => {
|
| 337 |
+
await new Promise((r) => setTimeout(r, BATCH_RETRY_GAP_MS));
|
| 338 |
+
const again = [];
|
| 339 |
+
for (const it of b.items.values()) {
|
| 340 |
+
if (it?.attempt === 2 && it.status === "queued" && it.payload)
|
| 341 |
+
again.push(it.payload);
|
| 342 |
+
}
|
| 343 |
+
batchUpdateToast(b, "เริ่มรอบแก้ไข", true);
|
| 344 |
+
for (const pl of again) {
|
| 345 |
+
let nextPayload = pl;
|
| 346 |
+
let skip = false;
|
| 347 |
+
try {
|
| 348 |
+
const src = String(pl?.src || "").trim();
|
| 349 |
+
if (src && /^https?:/i.test(src) && !pl?.imageDataUri) {
|
| 350 |
+
const pageUrl = pl?.context?.page_url || "";
|
| 351 |
+
const key = normImgSrc(src);
|
| 352 |
+
pruneMdDataUriCache();
|
| 353 |
+
const cached = key ? mdDataUriCache.get(key) : null;
|
| 354 |
+
const du =
|
| 355 |
+
cached?.du &&
|
| 356 |
+
Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS
|
| 357 |
+
? cached.du
|
| 358 |
+
: await fetchImageDataUriFromUrl(src, pageUrl);
|
| 359 |
+
if (du) {
|
| 360 |
+
const meta =
|
| 361 |
+
pl?.metadata && typeof pl.metadata === "object"
|
| 362 |
+
? pl.metadata
|
| 363 |
+
: {};
|
| 364 |
+
const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : [];
|
| 365 |
+
nextPayload = {
|
| 366 |
+
...pl,
|
| 367 |
+
imageDataUri: du,
|
| 368 |
+
metadata: {
|
| 369 |
+
...meta,
|
| 370 |
+
pipeline: pipe.concat({
|
| 371 |
+
stage: "retry_attach_datauri",
|
| 372 |
+
at: new Date().toISOString(),
|
| 373 |
+
}),
|
| 374 |
+
timestamp: new Date().toISOString(),
|
| 375 |
+
},
|
| 376 |
+
};
|
| 377 |
+
if (key && !cached?.du)
|
| 378 |
+
mdDataUriCache.set(key, { du, ts: Date.now() });
|
| 379 |
+
const k = imageKeyFromPayload(nextPayload);
|
| 380 |
+
if (k && b.items.has(k)) {
|
| 381 |
+
const it = b.items.get(k);
|
| 382 |
+
b.items.set(k, { ...it, payload: nextPayload });
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
} catch (e) {
|
| 387 |
+
const msg = String(e?.message || e);
|
| 388 |
+
const cls = classifyJobError(msg);
|
| 389 |
+
const k = imageKeyFromPayload(pl);
|
| 390 |
+
if (k && b.items.has(k)) {
|
| 391 |
+
const it = b.items.get(k);
|
| 392 |
+
b.items.set(k, {
|
| 393 |
+
...it,
|
| 394 |
+
lastError: msg,
|
| 395 |
+
status: cls?.permanent ? "error" : it.status,
|
| 396 |
+
permanent: !!cls?.permanent,
|
| 397 |
+
});
|
| 398 |
+
if (cls?.permanent) {
|
| 399 |
+
batchUpdateToast(b, "ผิดพลาด (ถาวร)");
|
| 400 |
+
batchFinalizeIfComplete(b);
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
if (cls?.permanent) skip = true;
|
| 404 |
+
}
|
| 405 |
+
if (!skip) enqueue(nextPayload, b.tabId, b.frameId || 0);
|
| 406 |
+
}
|
| 407 |
+
});
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
if (b.pass === 2) {
|
| 412 |
+
const s2 = batchPassStats(b);
|
| 413 |
+
const msg =
|
| 414 |
+
s2.error > 0 ? `เสร็จสิ้น (ยังผิดพลาด ${s2.error})` : "เสร็จสิ้น";
|
| 415 |
+
batchUpdateToast(b, msg, true);
|
| 416 |
+
batchStopKeepAlive(b);
|
| 417 |
+
}
|
| 418 |
+
return;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
let settingsEpoch = 0;
|
| 422 |
+
|
| 423 |
+
chrome.storage.onChanged.addListener((changes, areaName) => {
|
| 424 |
+
if (areaName !== "local" || !changes) return;
|
| 425 |
+
if (!changes.mode && !changes.lang && !changes.sources) return;
|
| 426 |
+
settingsEpoch = (settingsEpoch + 1) >>> 0;
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
const tabSessionById = new Map();
|
| 430 |
+
function bumpTabSession(tabId, href) {
|
| 431 |
+
if (!Number.isFinite(tabId)) return "";
|
| 432 |
+
const id = crypto.randomUUID();
|
| 433 |
+
tabSessionById.set(tabId, { id, href: String(href || ""), ts: Date.now() });
|
| 434 |
+
return id;
|
| 435 |
+
}
|
| 436 |
+
function getTabSessionId(tabId) {
|
| 437 |
+
return tabSessionById.get(tabId)?.id || "";
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
function getTabSession(tabId) {
|
| 441 |
+
return tabSessionById.get(tabId) || null;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
function ensureTabSession(tabId, href) {
|
| 445 |
+
const cur = getTabSession(tabId);
|
| 446 |
+
const h = String(href || "");
|
| 447 |
+
if (!cur?.id) return bumpTabSession(tabId, h);
|
| 448 |
+
if (h && String(cur.href || "") !== h) {
|
| 449 |
+
tabSessionById.set(tabId, { ...cur, href: h, ts: Date.now() });
|
| 450 |
+
}
|
| 451 |
+
return cur.id;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function isMangaDexPageUrl(u) {
|
| 455 |
+
try {
|
| 456 |
+
const host = new URL(String(u || "")).hostname.toLowerCase();
|
| 457 |
+
return host === "mangadex.org" || host.endsWith(".mangadex.org");
|
| 458 |
+
} catch {
|
| 459 |
+
return false;
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
function mdChapterIdFromUrl(u) {
|
| 464 |
+
try {
|
| 465 |
+
const url = new URL(String(u || ""));
|
| 466 |
+
const m = String(url.pathname || "").match(/\/chapter\/([a-f0-9-]{8,})/i);
|
| 467 |
+
return m ? String(m[1] || "") : "";
|
| 468 |
+
} catch {
|
| 469 |
+
return "";
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
function isSameMangaDexChapter(a, b) {
|
| 474 |
+
const ca = mdChapterIdFromUrl(a);
|
| 475 |
+
const cb = mdChapterIdFromUrl(b);
|
| 476 |
+
return !!ca && !!cb && ca === cb;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function cancelTabWork(tabId, reason = "navigation") {
|
| 480 |
+
if (!Number.isFinite(tabId)) return;
|
| 481 |
+
const msg = String(reason || "navigation");
|
| 482 |
+
for (const [jid, ctx] of Array.from(pendingByJob.entries())) {
|
| 483 |
+
if ((ctx?.tabId || 0) !== tabId) continue;
|
| 484 |
+
|
| 485 |
+
const batchId = String(
|
| 486 |
+
ctx?.batchId || ctx?.metadata?.batch_id || "",
|
| 487 |
+
).trim();
|
| 488 |
+
const imageKey = String(
|
| 489 |
+
ctx?.imageKey || ctx?.metadata?.image_id || "",
|
| 490 |
+
).trim();
|
| 491 |
+
const batch = batchId
|
| 492 |
+
? ensureBatch(batchId, tabId, ctx?.frameId || 0)
|
| 493 |
+
: null;
|
| 494 |
+
|
| 495 |
+
pendingByJob.delete(jid);
|
| 496 |
+
if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id);
|
| 497 |
+
|
| 498 |
+
if (batch && imageKey) {
|
| 499 |
+
batchMark(batchId, imageKey, { status: "aborted", lastError: msg });
|
| 500 |
+
batchUpdateToast(batch, "ยกเลิก");
|
| 501 |
+
batchFinalizeIfComplete(batch);
|
| 502 |
+
batchStopKeepAlive(batch);
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
for (const [iid, rec] of Array.from(pendingByImage.entries())) {
|
| 507 |
+
if ((rec?.tabId || 0) === tabId) pendingByImage.delete(iid);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
for (const b of Array.from(batches.values())) {
|
| 511 |
+
if ((b?.tabId || 0) !== tabId) continue;
|
| 512 |
+
batchStopKeepAlive(b);
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
const MD_CACHE_TTL_MS = 15 * 60 * 1000;
|
| 517 |
+
const mdCacheByKey = new Map();
|
| 518 |
+
|
| 519 |
+
const MD_DATAURI_CACHE_TTL_MS = 15 * 60 * 1000;
|
| 520 |
+
const MD_DATAURI_CACHE_MAX = 80;
|
| 521 |
+
const mdDataUriCache = new Map();
|
| 522 |
+
|
| 523 |
+
const API_BASE_FALLBACK = "";
|
| 524 |
+
|
| 525 |
+
const KEEPALIVE_PORT_NAME = "TP_KEEPALIVE";
|
| 526 |
+
const keepAlivePorts = new Set();
|
| 527 |
+
|
| 528 |
+
function bytesToBase64(bytes) {
|
| 529 |
+
let binary = "";
|
| 530 |
+
const chunk = 0x8000;
|
| 531 |
+
for (let i = 0; i < bytes.length; i += chunk) {
|
| 532 |
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
| 533 |
+
}
|
| 534 |
+
return btoa(binary);
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
async function blobToDataUri(blob, mimeOverride) {
|
| 538 |
+
const ab = await blob.arrayBuffer();
|
| 539 |
+
const mime = String(mimeOverride || blob.type || "application/octet-stream");
|
| 540 |
+
const b64 = bytesToBase64(new Uint8Array(ab));
|
| 541 |
+
return `data:${mime};base64,${b64}`;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
async function fetchImageDataUriFromUrl(url, pageUrl) {
|
| 545 |
+
const u = String(url || "").trim();
|
| 546 |
+
if (!u) return "";
|
| 547 |
+
const res = await fetch(u, {
|
| 548 |
+
credentials: "include",
|
| 549 |
+
redirect: "follow",
|
| 550 |
+
cache: "force-cache",
|
| 551 |
+
referrer: pageUrl || "about:client",
|
| 552 |
+
});
|
| 553 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 554 |
+
const ct = String(res.headers.get("content-type") || "");
|
| 555 |
+
const mime = ct.split(";")[0].trim();
|
| 556 |
+
if (mime && !mime.toLowerCase().startsWith("image/")) {
|
| 557 |
+
const body = await readLimitedText(res);
|
| 558 |
+
throw new Error(`Not an image: ${mime}${body ? ` - ${body}` : ""}`);
|
| 559 |
+
}
|
| 560 |
+
const blob = await res.blob();
|
| 561 |
+
if (blob.size < 64) throw new Error("Image too small");
|
| 562 |
+
if (blob.size > 25 * 1024 * 1024) throw new Error("Image too large");
|
| 563 |
+
return await blobToDataUri(blob, mime || blob.type);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function classifyJobError(msg) {
|
| 567 |
+
const m = String(msg || "").toLowerCase();
|
| 568 |
+
if (!m) return { permanent: false };
|
| 569 |
+
if (m.includes("no overlay data")) return { permanent: true };
|
| 570 |
+
if (m.includes("no image data")) return { permanent: true };
|
| 571 |
+
if (/\bhttp\s*(401|403|404|410)\b/.test(m) || /\b(401|403|404|410)\b/.test(m))
|
| 572 |
+
return { permanent: true };
|
| 573 |
+
if (m.includes("not an image")) return { permanent: true };
|
| 574 |
+
if (
|
| 575 |
+
m.includes("cannot identify image") ||
|
| 576 |
+
m.includes("image file is truncated")
|
| 577 |
+
)
|
| 578 |
+
return { permanent: true };
|
| 579 |
+
if (m.includes("unsupported") && m.includes("image"))
|
| 580 |
+
return { permanent: true };
|
| 581 |
+
if (
|
| 582 |
+
m.includes("rate limit") ||
|
| 583 |
+
m.includes("ratelimit") ||
|
| 584 |
+
m.includes("too many requests") ||
|
| 585 |
+
m.includes("http 429") ||
|
| 586 |
+
m.includes(" 429")
|
| 587 |
+
)
|
| 588 |
+
return { permanent: false };
|
| 589 |
+
if (
|
| 590 |
+
m.includes("http 503") ||
|
| 591 |
+
m.includes(" 503") ||
|
| 592 |
+
m.includes("overloaded") ||
|
| 593 |
+
m.includes("temporarily")
|
| 594 |
+
)
|
| 595 |
+
return { permanent: false };
|
| 596 |
+
if (m.includes("timeout")) return { permanent: false };
|
| 597 |
+
return { permanent: false };
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
function mdKeyFromUrl(url) {
|
| 601 |
+
try {
|
| 602 |
+
const u = new URL(String(url || ""));
|
| 603 |
+
const parts = u.pathname.split("/").filter(Boolean);
|
| 604 |
+
for (let i = parts.length - 3; i >= 0; i--) {
|
| 605 |
+
const seg = parts[i];
|
| 606 |
+
if (seg === "data" || seg === "data-saver") {
|
| 607 |
+
const hash = parts[i + 1] || "";
|
| 608 |
+
const file = parts[i + 2] || "";
|
| 609 |
+
if (hash && file) return `md:${seg}/${hash}/${file}`;
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
} catch {}
|
| 613 |
+
return null;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
function mdScopeFromMode(mode) {
|
| 617 |
+
if (mode === "lens_text") return "text";
|
| 618 |
+
if (mode === "lens_images") return "images";
|
| 619 |
+
return String(mode || "");
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
function mdCacheKey(mdKey, lang, mode) {
|
| 623 |
+
const k = String(mdKey || "");
|
| 624 |
+
const l = String(lang || "");
|
| 625 |
+
const s = mdScopeFromMode(mode);
|
| 626 |
+
if (!k || !l || !s) return "";
|
| 627 |
+
return k + "::" + l + "::" + s;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
function stripImageFields(res) {
|
| 631 |
+
if (!res || typeof res !== "object") return res;
|
| 632 |
+
const out = { ...res };
|
| 633 |
+
delete out.imageDataUri;
|
| 634 |
+
delete out.imageDataURI;
|
| 635 |
+
delete out.image;
|
| 636 |
+
delete out.imageUrl;
|
| 637 |
+
delete out.image_url;
|
| 638 |
+
delete out.imageURL;
|
| 639 |
+
return out;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
function pruneMdCache(now = Date.now()) {
|
| 643 |
+
for (const [k, rec] of mdCacheByKey.entries()) {
|
| 644 |
+
if (!rec || now - rec.ts > MD_CACHE_TTL_MS) mdCacheByKey.delete(k);
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
function pruneMdDataUriCache(now = Date.now()) {
|
| 649 |
+
for (const [k, rec] of mdDataUriCache.entries()) {
|
| 650 |
+
if (!rec || now - rec.ts > MD_DATAURI_CACHE_TTL_MS)
|
| 651 |
+
mdDataUriCache.delete(k);
|
| 652 |
+
}
|
| 653 |
+
while (mdDataUriCache.size > MD_DATAURI_CACHE_MAX) {
|
| 654 |
+
const first = mdDataUriCache.keys().next().value;
|
| 655 |
+
if (first === undefined) break;
|
| 656 |
+
mdDataUriCache.delete(first);
|
| 657 |
+
}
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
async function readLimitedText(res, limit = 1600) {
|
| 661 |
+
try {
|
| 662 |
+
const txt = await res.text();
|
| 663 |
+
const s = String(txt || "").trim();
|
| 664 |
+
if (!s) return "";
|
| 665 |
+
return s.length > limit ? s.slice(0, limit) + "…" : s;
|
| 666 |
+
} catch {
|
| 667 |
+
return "";
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
async function submitJobViaRest(base, payload) {
|
| 672 |
+
const url = base.replace(/\/+$/, "") + "/translate";
|
| 673 |
+
const res = await fetch(url, {
|
| 674 |
+
method: "POST",
|
| 675 |
+
headers: { "Content-Type": "application/json" },
|
| 676 |
+
cache: "force-cache",
|
| 677 |
+
redirect: "follow",
|
| 678 |
+
body: JSON.stringify(payload),
|
| 679 |
+
});
|
| 680 |
+
if (!res.ok) {
|
| 681 |
+
const body = await readLimitedText(res);
|
| 682 |
+
throw new Error(
|
| 683 |
+
`REST submit failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`,
|
| 684 |
+
);
|
| 685 |
+
}
|
| 686 |
+
const data = await res.json();
|
| 687 |
+
if (!data?.id) throw new Error("REST submit failed: no id");
|
| 688 |
+
ev("job.submit.rest", { id: data.id });
|
| 689 |
+
return data.id;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
async function pollJobViaRest(
|
| 693 |
+
base,
|
| 694 |
+
jid,
|
| 695 |
+
{ timeoutMs = 180000, intervalMs = 800 } = {},
|
| 696 |
+
) {
|
| 697 |
+
const start = Date.now();
|
| 698 |
+
const url =
|
| 699 |
+
base.replace(/\/+$/, "") + "/translate/" + encodeURIComponent(jid);
|
| 700 |
+
let lastSt = null;
|
| 701 |
+
let ticks = 0;
|
| 702 |
+
while (true) {
|
| 703 |
+
const ctx0 = pendingByJob.get(jid);
|
| 704 |
+
if (!ctx0) return;
|
| 705 |
+
const curSess = ctx0?.tabId ? getTabSessionId(ctx0.tabId) : "";
|
| 706 |
+
const isStale = Boolean(
|
| 707 |
+
ctx0?.sessionId && curSess && ctx0.sessionId !== curSess,
|
| 708 |
+
);
|
| 709 |
+
if (isStale && !ctx0?.keepCacheOnStale) {
|
| 710 |
+
pendingByJob.delete(jid);
|
| 711 |
+
if (ctx0?.metadata?.image_id)
|
| 712 |
+
pendingByImage.delete(ctx0.metadata.image_id);
|
| 713 |
+
ev("job.done", {
|
| 714 |
+
id: jid,
|
| 715 |
+
dt: fmtMs(Date.now() - (ctx0?.startedAt || Date.now())),
|
| 716 |
+
});
|
| 717 |
+
const batchId = String(
|
| 718 |
+
ctx0?.batchId || ctx0?.metadata?.batch_id || "",
|
| 719 |
+
).trim();
|
| 720 |
+
const imageKey = String(
|
| 721 |
+
ctx0?.imageKey || ctx0?.metadata?.image_id || "",
|
| 722 |
+
).trim();
|
| 723 |
+
const batch = batchId
|
| 724 |
+
? ensureBatch(batchId, ctx0.tabId || 0, ctx0.frameId || 0)
|
| 725 |
+
: null;
|
| 726 |
+
if (batch && imageKey) {
|
| 727 |
+
batchMark(batchId, imageKey, { status: "aborted" });
|
| 728 |
+
batchUpdateToast(batch, "ยกเลิก", true);
|
| 729 |
+
batchFinalizeIfComplete(batch);
|
| 730 |
+
}
|
| 731 |
+
return;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
if (Date.now() - start > timeoutMs) throw new Error("REST poll timeout");
|
| 735 |
+
const res = await fetch(url, { cache: "no-store" });
|
| 736 |
+
if (!res.ok) {
|
| 737 |
+
const body = await readLimitedText(res);
|
| 738 |
+
throw new Error(
|
| 739 |
+
`REST poll failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`,
|
| 740 |
+
);
|
| 741 |
+
}
|
| 742 |
+
const data = await res.json();
|
| 743 |
+
const st = data?.status;
|
| 744 |
+
const ctx = pendingByJob.get(jid);
|
| 745 |
+
if (!ctx) return;
|
| 746 |
+
const isImageMode = String(ctx?.mode || "") === "lens_images";
|
| 747 |
+
ticks++;
|
| 748 |
+
if (st && st !== lastSt) {
|
| 749 |
+
lastSt = st;
|
| 750 |
+
ev("job.poll.status", { id: jid, status: st });
|
| 751 |
+
if (isImageMode)
|
| 752 |
+
logImageRestAccessLine(base, url, res.status, res.statusText);
|
| 753 |
+
} else if (ticks % 15 === 0) {
|
| 754 |
+
ev("job.poll.tick", { id: jid, status: st || "" });
|
| 755 |
+
}
|
| 756 |
+
if (st === "done") {
|
| 757 |
+
ev("job.poll.done", { id: jid });
|
| 758 |
+
await handleResult(jid, data.result);
|
| 759 |
+
return;
|
| 760 |
+
} else if (st === "error") {
|
| 761 |
+
handleJobError(
|
| 762 |
+
jid,
|
| 763 |
+
String(data?.result || data?.error || data?.message || "Unknown error"),
|
| 764 |
+
);
|
| 765 |
+
return;
|
| 766 |
+
}
|
| 767 |
+
await new Promise((r) => setTimeout(r, intervalMs));
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
function runtimeBroadcast(msg) {
|
| 772 |
+
try {
|
| 773 |
+
chrome.runtime.sendMessage(msg, () => void chrome.runtime.lastError);
|
| 774 |
+
} catch {}
|
| 775 |
+
}
|
| 776 |
+
const broadcast = runtimeBroadcast;
|
| 777 |
+
|
| 778 |
+
function pingContent(tabId, frameId = 0) {
|
| 779 |
+
return new Promise((resolve) => {
|
| 780 |
+
try {
|
| 781 |
+
chrome.tabs.sendMessage(tabId, { type: "TP_PING" }, { frameId }, () => {
|
| 782 |
+
const ok = !chrome.runtime.lastError;
|
| 783 |
+
resolve(ok);
|
| 784 |
+
});
|
| 785 |
+
} catch {
|
| 786 |
+
resolve(false);
|
| 787 |
+
}
|
| 788 |
+
});
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
async function ensureContentScript(tabId) {
|
| 792 |
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
| 793 |
+
try {
|
| 794 |
+
for (let i = 0; i < 8; i++) {
|
| 795 |
+
const ok = await pingContent(tabId, 0);
|
| 796 |
+
if (ok) return true;
|
| 797 |
+
await wait(120);
|
| 798 |
+
}
|
| 799 |
+
return false;
|
| 800 |
+
} catch (e) {
|
| 801 |
+
evWarn("ping.fail", { tabId, message: e?.message || String(e) });
|
| 802 |
+
return false;
|
| 803 |
+
}
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
async function sendToTab(tabId, message, frameId = 0) {
|
| 807 |
+
const type = String(message?.type || "");
|
| 808 |
+
const opts = { frameId: Number(frameId) || 0 };
|
| 809 |
+
const attempt = (o) =>
|
| 810 |
+
new Promise((resolve) => {
|
| 811 |
+
try {
|
| 812 |
+
chrome.tabs.sendMessage(tabId, message, o, (resp) => {
|
| 813 |
+
const err = chrome.runtime.lastError;
|
| 814 |
+
resolve({ ok: !err, err: err?.message || null, resp: resp || null });
|
| 815 |
+
});
|
| 816 |
+
} catch (e) {
|
| 817 |
+
resolve({ ok: false, err: e?.message || String(e), resp: null });
|
| 818 |
+
}
|
| 819 |
+
});
|
| 820 |
+
|
| 821 |
+
ev("tab.send.begin", { tabId, frameId, type });
|
| 822 |
+
|
| 823 |
+
let r = await attempt(opts);
|
| 824 |
+
if (r.ok) {
|
| 825 |
+
ev("tab.send.ok", { tabId, frameId, type });
|
| 826 |
+
return true;
|
| 827 |
+
}
|
| 828 |
+
evWarn("tab.send.fail", { tabId, frameId, type, err: r.err || "" });
|
| 829 |
+
|
| 830 |
+
if (opts.frameId) {
|
| 831 |
+
const r2 = await attempt({ frameId: 0 });
|
| 832 |
+
if (r2.ok) {
|
| 833 |
+
ev("tab.send.ok.fallback", { tabId, frameId: 0, type });
|
| 834 |
+
return true;
|
| 835 |
+
}
|
| 836 |
+
evWarn("tab.send.fail.fallback", { tabId, type, err: r2.err || "" });
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
const injected = await ensureContentScript(tabId);
|
| 840 |
+
if (!injected) return false;
|
| 841 |
+
|
| 842 |
+
r = await attempt(opts);
|
| 843 |
+
if (r.ok) {
|
| 844 |
+
ev("tab.send.ok.afterInject", { tabId, frameId, type });
|
| 845 |
+
return true;
|
| 846 |
+
}
|
| 847 |
+
evWarn("tab.send.fail.afterInject", {
|
| 848 |
+
tabId,
|
| 849 |
+
frameId,
|
| 850 |
+
type,
|
| 851 |
+
err: r.err || "",
|
| 852 |
+
});
|
| 853 |
+
|
| 854 |
+
if (opts.frameId) {
|
| 855 |
+
const r3 = await attempt({ frameId: 0 });
|
| 856 |
+
if (r3.ok) {
|
| 857 |
+
ev("tab.send.ok.afterInject.fallback", { tabId, frameId: 0, type });
|
| 858 |
+
return true;
|
| 859 |
+
}
|
| 860 |
+
evWarn("tab.send.fail.afterInject.fallback", {
|
| 861 |
+
tabId,
|
| 862 |
+
type,
|
| 863 |
+
err: r3.err || "",
|
| 864 |
+
});
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
return false;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
async function requestFromTab(tabId, message, frameId = 0) {
|
| 871 |
+
const type = String(message?.type || "");
|
| 872 |
+
const attempt = (o) =>
|
| 873 |
+
new Promise((resolve) => {
|
| 874 |
+
try {
|
| 875 |
+
chrome.tabs.sendMessage(tabId, message, o, (resp) => {
|
| 876 |
+
const err = chrome.runtime.lastError;
|
| 877 |
+
if (err) {
|
| 878 |
+
evWarn("tab.request.fail", {
|
| 879 |
+
tabId,
|
| 880 |
+
frameId: o?.frameId ?? 0,
|
| 881 |
+
type,
|
| 882 |
+
err: err.message,
|
| 883 |
+
});
|
| 884 |
+
resolve(null);
|
| 885 |
+
return;
|
| 886 |
+
}
|
| 887 |
+
resolve(resp || null);
|
| 888 |
+
});
|
| 889 |
+
} catch (e) {
|
| 890 |
+
evWarn("tab.request.fail", {
|
| 891 |
+
tabId,
|
| 892 |
+
frameId: o?.frameId ?? 0,
|
| 893 |
+
type,
|
| 894 |
+
err: e?.message || String(e),
|
| 895 |
+
});
|
| 896 |
+
resolve(null);
|
| 897 |
+
}
|
| 898 |
+
});
|
| 899 |
+
|
| 900 |
+
const primary = { frameId: Number(frameId) || 0 };
|
| 901 |
+
const resp = await attempt(primary);
|
| 902 |
+
if (resp != null) return resp;
|
| 903 |
+
if (primary.frameId) return await attempt({ frameId: 0 });
|
| 904 |
+
return null;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
async function requestFromTabEnsured(tabId, message, frameId = 0) {
|
| 908 |
+
const resp = await requestFromTab(tabId, message, frameId);
|
| 909 |
+
if (resp != null) return resp;
|
| 910 |
+
const ok = await ensureContentScript(tabId);
|
| 911 |
+
if (!ok) return null;
|
| 912 |
+
return await requestFromTab(tabId, message, frameId);
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
function sendToastToTab(tabId, frameId, text, ms = 1600) {
|
| 916 |
+
if (!tabId || !text) return;
|
| 917 |
+
const opts = { frameId: Number(frameId) || 0 };
|
| 918 |
+
try {
|
| 919 |
+
chrome.tabs.sendMessage(tabId, { type: "TP_TOAST", text, ms }, opts, () => {
|
| 920 |
+
void chrome.runtime.lastError;
|
| 921 |
+
});
|
| 922 |
+
} catch {}
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
function setWsStatus(s) {
|
| 926 |
+
if (wsStatus !== s) {
|
| 927 |
+
wsStatus = s;
|
| 928 |
+
try {
|
| 929 |
+
broadcast({ type: "WS_STATUS_UPDATE", status: s });
|
| 930 |
+
} catch (e) {}
|
| 931 |
+
info(`[WS] status -> ${s}`);
|
| 932 |
+
}
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
const warmupByBase = new Map();
|
| 936 |
+
async function warmupApi(base) {
|
| 937 |
+
const b = normalizeUrl(base);
|
| 938 |
+
if (!b) return;
|
| 939 |
+
const now = Date.now();
|
| 940 |
+
const last = warmupByBase.get(b) || 0;
|
| 941 |
+
if (now - last < WARMUP_TTL_MS) return;
|
| 942 |
+
warmupByBase.set(b, now);
|
| 943 |
+
const controller = new AbortController();
|
| 944 |
+
const timeout = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS);
|
| 945 |
+
try {
|
| 946 |
+
await fetch(b.replace(/\/+$/, "") + WARMUP_PATH, {
|
| 947 |
+
method: "GET",
|
| 948 |
+
cache: "force-cache",
|
| 949 |
+
signal: controller.signal,
|
| 950 |
+
});
|
| 951 |
+
ev("api.warmup", { base: b });
|
| 952 |
+
} catch {
|
| 953 |
+
} finally {
|
| 954 |
+
clearTimeout(timeout);
|
| 955 |
+
}
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
const getApiBase = () =>
|
| 959 |
+
new Promise((res) => {
|
| 960 |
+
chrome.storage.local.get(
|
| 961 |
+
{ customApiUrl: "", apiUrlDefault: API_BASE_FALLBACK },
|
| 962 |
+
({ customApiUrl, apiUrlDefault }) => {
|
| 963 |
+
const base =
|
| 964 |
+
normalizeUrl(customApiUrl) ||
|
| 965 |
+
normalizeUrl(apiUrlDefault) ||
|
| 966 |
+
API_BASE_FALLBACK;
|
| 967 |
+
info("[getApiBase]", base);
|
| 968 |
+
warmupApi(base);
|
| 969 |
+
res(base);
|
| 970 |
+
},
|
| 971 |
+
);
|
| 972 |
+
});
|
| 973 |
+
|
| 974 |
+
let healthCache = { ok: false, ts: 0, build: "" };
|
| 975 |
+
|
| 976 |
+
function onceOpen(socket) {
|
| 977 |
+
return new Promise((resolve, reject) => {
|
| 978 |
+
const onOpen = () => {
|
| 979 |
+
cleanup();
|
| 980 |
+
resolve();
|
| 981 |
+
};
|
| 982 |
+
const onErr = (e) => {
|
| 983 |
+
cleanup();
|
| 984 |
+
reject(e);
|
| 985 |
+
};
|
| 986 |
+
const onClose = () => {
|
| 987 |
+
cleanup();
|
| 988 |
+
reject(new Error("ws-closed-before-open"));
|
| 989 |
+
};
|
| 990 |
+
function cleanup() {
|
| 991 |
+
socket.removeEventListener("open", onOpen);
|
| 992 |
+
socket.removeEventListener("error", onErr);
|
| 993 |
+
socket.removeEventListener("close", onClose);
|
| 994 |
+
}
|
| 995 |
+
socket.addEventListener("open", onOpen);
|
| 996 |
+
socket.addEventListener("error", onErr);
|
| 997 |
+
socket.addEventListener("close", onClose);
|
| 998 |
+
});
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
function withTimeout(promise, ms, label = "op") {
|
| 1002 |
+
return new Promise((resolve, reject) => {
|
| 1003 |
+
const t = setTimeout(() => reject(new Error(label + "-timeout")), ms);
|
| 1004 |
+
promise.then(
|
| 1005 |
+
(v) => {
|
| 1006 |
+
clearTimeout(t);
|
| 1007 |
+
resolve(v);
|
| 1008 |
+
},
|
| 1009 |
+
(e) => {
|
| 1010 |
+
clearTimeout(t);
|
| 1011 |
+
reject(e);
|
| 1012 |
+
},
|
| 1013 |
+
);
|
| 1014 |
+
});
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
let wsPromise = null;
|
| 1018 |
+
|
| 1019 |
+
async function preflightWs(base, timeoutMs = PREFLIGHT_TIMEOUT_MS) {
|
| 1020 |
+
const tryOnce = async (url, method = "GET") => {
|
| 1021 |
+
const ctrl = new AbortController();
|
| 1022 |
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
| 1023 |
+
try {
|
| 1024 |
+
await fetch(url, { method, cache: "no-store", signal: ctrl.signal });
|
| 1025 |
+
return true;
|
| 1026 |
+
} catch (e) {
|
| 1027 |
+
return false;
|
| 1028 |
+
} finally {
|
| 1029 |
+
clearTimeout(t);
|
| 1030 |
+
}
|
| 1031 |
+
};
|
| 1032 |
+
|
| 1033 |
+
const ok1 = await tryOnce(base.replace(/\/$/, "") + "/health", "GET");
|
| 1034 |
+
if (ok1) return true;
|
| 1035 |
+
const ok2 = await tryOnce(base.replace(/\/$/, "") + "/health", "HEAD");
|
| 1036 |
+
return ok2;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
async function connectWebSocketOnce() {
|
| 1040 |
+
const t_start = t0();
|
| 1041 |
+
ev("ws.connect.begin");
|
| 1042 |
+
const base = await getApiBase();
|
| 1043 |
+
const wsUrl = toWs(base);
|
| 1044 |
+
if (!wsUrl) {
|
| 1045 |
+
setWsStatus("idle");
|
| 1046 |
+
return false;
|
| 1047 |
+
}
|
| 1048 |
+
if (
|
| 1049 |
+
ws &&
|
| 1050 |
+
(ws.readyState === WebSocket.OPEN ||
|
| 1051 |
+
ws.readyState === WebSocket.CONNECTING) &&
|
| 1052 |
+
currentBase === base
|
| 1053 |
+
) {
|
| 1054 |
+
return ws.readyState === WebSocket.OPEN;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
const reachable = await preflightWs(base, PREFLIGHT_TIMEOUT_MS);
|
| 1058 |
+
if (!reachable) {
|
| 1059 |
+
setWsStatus("offline");
|
| 1060 |
+
evWarn("ws.preflight.fail", { base });
|
| 1061 |
+
return false;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
if (wsPromise) {
|
| 1065 |
+
try {
|
| 1066 |
+
await wsPromise;
|
| 1067 |
+
} catch (e) {}
|
| 1068 |
+
const ok = !!(ws && ws.readyState === WebSocket.OPEN);
|
| 1069 |
+
ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) });
|
| 1070 |
+
return ok;
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
wsPromise = (async () => {
|
| 1074 |
+
const TIMEOUT_MS = WS_OPEN_TIMEOUT_MS;
|
| 1075 |
+
const RETRIES = WS_RETRIES;
|
| 1076 |
+
let lastErr = null;
|
| 1077 |
+
|
| 1078 |
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
| 1079 |
+
try {
|
| 1080 |
+
if (ws) {
|
| 1081 |
+
try {
|
| 1082 |
+
ws.close();
|
| 1083 |
+
} catch (e) {}
|
| 1084 |
+
}
|
| 1085 |
+
currentBase = base;
|
| 1086 |
+
wsReady = false;
|
| 1087 |
+
setWsStatus("connecting");
|
| 1088 |
+
blockSendsBecauseWsEnded = false;
|
| 1089 |
+
|
| 1090 |
+
ws = new WebSocket(wsUrl);
|
| 1091 |
+
ws.addEventListener("open", () => {
|
| 1092 |
+
ev("ws.open", { url: wsUrl });
|
| 1093 |
+
wsReady = true;
|
| 1094 |
+
setWsStatus("connected");
|
| 1095 |
+
});
|
| 1096 |
+
ws.addEventListener("message", (ev) => {
|
| 1097 |
+
try {
|
| 1098 |
+
const msg = JSON.parse(ev.data);
|
| 1099 |
+
switch (msg.type) {
|
| 1100 |
+
case "ack":
|
| 1101 |
+
break;
|
| 1102 |
+
case "result":
|
| 1103 |
+
void handleResult(msg.id, msg.result).catch((e) =>
|
| 1104 |
+
evWarn("job.result.handle.fail", {
|
| 1105 |
+
id: msg.id,
|
| 1106 |
+
err: e?.message || String(e),
|
| 1107 |
+
}),
|
| 1108 |
+
);
|
| 1109 |
+
break;
|
| 1110 |
+
case "error":
|
| 1111 |
+
handleJobError(msg.id, msg.error || "Unknown error");
|
| 1112 |
+
break;
|
| 1113 |
+
default:
|
| 1114 |
+
warn("[ws] unknown msg", msg);
|
| 1115 |
+
}
|
| 1116 |
+
} catch (e) {
|
| 1117 |
+
error("WS parse error", e);
|
| 1118 |
+
}
|
| 1119 |
+
});
|
| 1120 |
+
const onEnded = (evx) => {
|
| 1121 |
+
evWarn("ws.end", {
|
| 1122 |
+
code: evx?.code,
|
| 1123 |
+
reason: evx?.reason || evx?.message || evx,
|
| 1124 |
+
});
|
| 1125 |
+
wsReady = false;
|
| 1126 |
+
blockSendsBecauseWsEnded = true;
|
| 1127 |
+
failAllPending(
|
| 1128 |
+
"Connection lost before all images finished. Please run the menu again.",
|
| 1129 |
+
);
|
| 1130 |
+
setWsStatus("idle");
|
| 1131 |
+
};
|
| 1132 |
+
ws.addEventListener("close", onEnded);
|
| 1133 |
+
ws.addEventListener("error", onEnded);
|
| 1134 |
+
|
| 1135 |
+
await withTimeout(onceOpen(ws), TIMEOUT_MS, "ws-open");
|
| 1136 |
+
return true;
|
| 1137 |
+
} catch (e) {
|
| 1138 |
+
lastErr = e;
|
| 1139 |
+
try {
|
| 1140 |
+
if (ws) ws.close();
|
| 1141 |
+
} catch (e2) {}
|
| 1142 |
+
ws = null;
|
| 1143 |
+
wsReady = false;
|
| 1144 |
+
setWsStatus("idle");
|
| 1145 |
+
const base = 500;
|
| 1146 |
+
const jitter = Math.floor(Math.random() * 300);
|
| 1147 |
+
const waitMs = Math.min(5000, base * Math.pow(2, attempt)) + jitter;
|
| 1148 |
+
if (attempt < RETRIES) await new Promise((r) => setTimeout(r, waitMs));
|
| 1149 |
+
}
|
| 1150 |
+
}
|
| 1151 |
+
throw lastErr || new Error("ws-failed");
|
| 1152 |
+
})();
|
| 1153 |
+
|
| 1154 |
+
try {
|
| 1155 |
+
await wsPromise;
|
| 1156 |
+
} finally {
|
| 1157 |
+
wsPromise = null;
|
| 1158 |
+
}
|
| 1159 |
+
const ok = !!(ws && ws.readyState === WebSocket.OPEN);
|
| 1160 |
+
ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) });
|
| 1161 |
+
return ok;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
function addTask(fn) {
|
| 1165 |
+
queue.push(fn);
|
| 1166 |
+
next();
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
function next() {
|
| 1170 |
+
if (!queue.length) return;
|
| 1171 |
+
const max = effectiveMaxConcurrency();
|
| 1172 |
+
if (max && running >= max) return;
|
| 1173 |
+
running++;
|
| 1174 |
+
const task = queue.shift();
|
| 1175 |
+
Promise.resolve(task())
|
| 1176 |
+
.catch(error)
|
| 1177 |
+
.finally(() => {
|
| 1178 |
+
running--;
|
| 1179 |
+
next();
|
| 1180 |
+
});
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
function failJobImmediately(tabId, jobId, imgUrl, message, frameId = 0) {
|
| 1184 |
+
if (tabId) {
|
| 1185 |
+
sendToTab(
|
| 1186 |
+
tabId,
|
| 1187 |
+
{ type: "IMAGE_ERROR", original: imgUrl, message },
|
| 1188 |
+
frameId,
|
| 1189 |
+
);
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
function failAllPending(message) {
|
| 1194 |
+
const jobIds = Array.from(pendingByJob.keys());
|
| 1195 |
+
for (const jobId of jobIds) {
|
| 1196 |
+
const ctx = pendingByJob.get(jobId);
|
| 1197 |
+
|
| 1198 |
+
const batchId = String(
|
| 1199 |
+
ctx?.batchId || ctx?.metadata?.batch_id || "",
|
| 1200 |
+
).trim();
|
| 1201 |
+
const imageKey = String(
|
| 1202 |
+
ctx?.imageKey || ctx?.metadata?.image_id || "",
|
| 1203 |
+
).trim();
|
| 1204 |
+
const batch = batchId
|
| 1205 |
+
? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0)
|
| 1206 |
+
: null;
|
| 1207 |
+
|
| 1208 |
+
failJobImmediately(
|
| 1209 |
+
ctx?.tabId,
|
| 1210 |
+
jobId,
|
| 1211 |
+
ctx?.imgUrl || null,
|
| 1212 |
+
message,
|
| 1213 |
+
ctx?.frameId || 0,
|
| 1214 |
+
);
|
| 1215 |
+
pendingByJob.delete(jobId);
|
| 1216 |
+
ev("job.done", {
|
| 1217 |
+
id: jobId,
|
| 1218 |
+
dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())),
|
| 1219 |
+
});
|
| 1220 |
+
|
| 1221 |
+
if (batch && imageKey) {
|
| 1222 |
+
batchMark(batchId, imageKey, { status: "aborted", lastError: message });
|
| 1223 |
+
batchUpdateToast(batch, "ยกเลิก");
|
| 1224 |
+
batchFinalizeIfComplete(batch);
|
| 1225 |
+
}
|
| 1226 |
+
}
|
| 1227 |
+
pendingByImage.clear();
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
function handleJobError(jobId, errMsg = "Unknown error") {
|
| 1231 |
+
const cls = classifyJobError(errMsg);
|
| 1232 |
+
const ctx = pendingByJob.get(jobId);
|
| 1233 |
+
const curSess = ctx?.tabId ? getTabSessionId(ctx.tabId) : "";
|
| 1234 |
+
const isStale = Boolean(
|
| 1235 |
+
ctx?.sessionId && curSess && ctx.sessionId !== curSess,
|
| 1236 |
+
);
|
| 1237 |
+
|
| 1238 |
+
const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim();
|
| 1239 |
+
const imageKey = String(
|
| 1240 |
+
ctx?.imageKey || ctx?.metadata?.image_id || "",
|
| 1241 |
+
).trim();
|
| 1242 |
+
const batch = batchId
|
| 1243 |
+
? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0)
|
| 1244 |
+
: null;
|
| 1245 |
+
|
| 1246 |
+
if (ctx?.tabId && !isStale)
|
| 1247 |
+
sendToTab(
|
| 1248 |
+
ctx.tabId,
|
| 1249 |
+
{ type: "IMAGE_ERROR", original: ctx.imgUrl, message: errMsg },
|
| 1250 |
+
ctx.frameId || 0,
|
| 1251 |
+
);
|
| 1252 |
+
|
| 1253 |
+
pendingByJob.delete(jobId);
|
| 1254 |
+
ev("job.done", {
|
| 1255 |
+
id: jobId,
|
| 1256 |
+
dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())),
|
| 1257 |
+
});
|
| 1258 |
+
if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id);
|
| 1259 |
+
|
| 1260 |
+
if (batch && imageKey) {
|
| 1261 |
+
batchMark(batchId, imageKey, {
|
| 1262 |
+
status: "error",
|
| 1263 |
+
lastError: errMsg,
|
| 1264 |
+
permanent: !!cls?.permanent,
|
| 1265 |
+
});
|
| 1266 |
+
batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด");
|
| 1267 |
+
batchFinalizeIfComplete(batch);
|
| 1268 |
+
}
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
async function handleResult(jobId, result) {
|
| 1272 |
+
let ctx = pendingByJob.get(jobId);
|
| 1273 |
+
if (!ctx && result?.metadata?.image_id) {
|
| 1274 |
+
const mapped = pendingByImage.get(result.metadata.image_id);
|
| 1275 |
+
ctx = typeof mapped === "string" ? pendingByJob.get(mapped) : mapped;
|
| 1276 |
+
}
|
| 1277 |
+
if (!ctx) {
|
| 1278 |
+
evWarn("job.ctx.missing", {
|
| 1279 |
+
id: jobId,
|
| 1280 |
+
metaImageId: String(result?.metadata?.image_id || ""),
|
| 1281 |
+
});
|
| 1282 |
+
return;
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
const { imgUrl, tabId } = ctx;
|
| 1286 |
+
const frameId = ctx.frameId || 0;
|
| 1287 |
+
const mode = ctx.mode || ctx.metadata?.mode || null;
|
| 1288 |
+
|
| 1289 |
+
const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim();
|
| 1290 |
+
const imageKey = String(
|
| 1291 |
+
ctx?.imageKey ||
|
| 1292 |
+
ctx?.metadata?.image_id ||
|
| 1293 |
+
result?.metadata?.image_id ||
|
| 1294 |
+
"",
|
| 1295 |
+
).trim();
|
| 1296 |
+
const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null;
|
| 1297 |
+
|
| 1298 |
+
ev("job.result.recv", {
|
| 1299 |
+
id: jobId,
|
| 1300 |
+
tabId,
|
| 1301 |
+
frameId,
|
| 1302 |
+
keys: Object.keys(result || {}),
|
| 1303 |
+
});
|
| 1304 |
+
|
| 1305 |
+
const curSess = getTabSessionId(tabId);
|
| 1306 |
+
const isSettingsStale =
|
| 1307 |
+
typeof ctx?.settingsEpoch === "number" &&
|
| 1308 |
+
ctx.settingsEpoch !== settingsEpoch;
|
| 1309 |
+
const isStale =
|
| 1310 |
+
Boolean(ctx?.sessionId && curSess && ctx.sessionId !== curSess) ||
|
| 1311 |
+
isSettingsStale;
|
| 1312 |
+
|
| 1313 |
+
const newImg =
|
| 1314 |
+
result?.imageDataUri ||
|
| 1315 |
+
result?.imageDataURI ||
|
| 1316 |
+
result?.image ||
|
| 1317 |
+
result?.imageUrl ||
|
| 1318 |
+
result?.image_url ||
|
| 1319 |
+
result?.imageURL ||
|
| 1320 |
+
null;
|
| 1321 |
+
const aiHtml = result?.Ai?.aihtml || result?.ai?.aihtml || null;
|
| 1322 |
+
const translatedHtml =
|
| 1323 |
+
result?.translated?.translatedhtml || result?.translatedhtml || null;
|
| 1324 |
+
const originalHtml =
|
| 1325 |
+
result?.original?.originalhtml || result?.originalhtml || null;
|
| 1326 |
+
|
| 1327 |
+
const hasHtml = !!(aiHtml || translatedHtml || originalHtml);
|
| 1328 |
+
|
| 1329 |
+
const mdKey = mdKeyFromUrl(imgUrl);
|
| 1330 |
+
const lang = ctx.lang || ctx.metadata?.lang || null;
|
| 1331 |
+
const cacheKey = mdCacheKey(mdKey, lang, mode);
|
| 1332 |
+
if (cacheKey && (newImg || hasHtml)) {
|
| 1333 |
+
pruneMdCache();
|
| 1334 |
+
const prev = mdCacheByKey.get(cacheKey) || {};
|
| 1335 |
+
mdCacheByKey.set(cacheKey, {
|
| 1336 |
+
newImg: newImg || prev.newImg || null,
|
| 1337 |
+
result: hasHtml ? stripImageFields(result) : prev.result || null,
|
| 1338 |
+
ts: Date.now(),
|
| 1339 |
+
});
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
if (isStale) {
|
| 1343 |
+
pendingByJob.delete(jobId);
|
| 1344 |
+
if (result?.metadata?.image_id)
|
| 1345 |
+
pendingByImage.delete(result.metadata.image_id);
|
| 1346 |
+
ev("job.done", {
|
| 1347 |
+
id: jobId,
|
| 1348 |
+
dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())),
|
| 1349 |
+
});
|
| 1350 |
+
if (batch && imageKey) {
|
| 1351 |
+
if (ctx?.keepCacheOnStale) {
|
| 1352 |
+
batchMark(batchId, imageKey, { status: "done", cachedOnly: true });
|
| 1353 |
+
batchUpdateToast(batch, "บันทึกแล้ว", true);
|
| 1354 |
+
batchFinalizeIfComplete(batch);
|
| 1355 |
+
} else {
|
| 1356 |
+
batchMark(batchId, imageKey, { status: "aborted" });
|
| 1357 |
+
batchUpdateToast(batch, "ยกเลิก", true);
|
| 1358 |
+
batchFinalizeIfComplete(batch);
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
return;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
if (batch && imageKey) {
|
| 1365 |
+
batchMark(batchId, imageKey, { status: "inserting" });
|
| 1366 |
+
batchUpdateToast(batch, "แทรกกลับ");
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
let replaceResp = null;
|
| 1370 |
+
if (newImg && mode !== "lens_text") {
|
| 1371 |
+
ev("job.result.image", { id: jobId });
|
| 1372 |
+
replaceResp = await requestFromTabEnsured(
|
| 1373 |
+
tabId,
|
| 1374 |
+
{ type: "REPLACE_IMAGE", original: imgUrl, newSrc: newImg },
|
| 1375 |
+
frameId,
|
| 1376 |
+
);
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
let overlayResp = null;
|
| 1380 |
+
if (hasHtml) {
|
| 1381 |
+
ev("job.result.html", { id: jobId });
|
| 1382 |
+
overlayResp = await requestFromTabEnsured(
|
| 1383 |
+
tabId,
|
| 1384 |
+
{
|
| 1385 |
+
type: "OVERLAY_HTML",
|
| 1386 |
+
original: imgUrl,
|
| 1387 |
+
result,
|
| 1388 |
+
mode: mode || "",
|
| 1389 |
+
source: ctx?.source || "",
|
| 1390 |
+
},
|
| 1391 |
+
frameId,
|
| 1392 |
+
);
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
let ok = true;
|
| 1396 |
+
let errMsg = "";
|
| 1397 |
+
if (!hasHtml && !(newImg && mode !== "lens_text")) ok = false;
|
| 1398 |
+
if (newImg && mode !== "lens_text" && !replaceResp?.ok) {
|
| 1399 |
+
ok = false;
|
| 1400 |
+
errMsg = "DOM replace failed";
|
| 1401 |
+
}
|
| 1402 |
+
if (hasHtml && !overlayResp?.ok) {
|
| 1403 |
+
ok = false;
|
| 1404 |
+
errMsg = "Overlay insert failed";
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
if (!hasHtml) {
|
| 1408 |
+
const keys = Object.keys(result || {});
|
| 1409 |
+
if (!newImg) {
|
| 1410 |
+
evWarn("job.result.none", { id: jobId, keys });
|
| 1411 |
+
await requestFromTabEnsured(
|
| 1412 |
+
tabId,
|
| 1413 |
+
{
|
| 1414 |
+
type: "IMAGE_ERROR",
|
| 1415 |
+
original: imgUrl,
|
| 1416 |
+
message: "API returned no overlay data",
|
| 1417 |
+
},
|
| 1418 |
+
frameId,
|
| 1419 |
+
);
|
| 1420 |
+
ok = false;
|
| 1421 |
+
errMsg = "API returned no overlay data";
|
| 1422 |
+
} else {
|
| 1423 |
+
ev("job.result.imageOnly", { id: jobId, mode: mode || "unknown" });
|
| 1424 |
+
}
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
+
pendingByJob.delete(jobId);
|
| 1428 |
+
ev("job.done", {
|
| 1429 |
+
id: jobId,
|
| 1430 |
+
dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())),
|
| 1431 |
+
});
|
| 1432 |
+
if (result?.metadata?.image_id)
|
| 1433 |
+
pendingByImage.delete(result.metadata.image_id);
|
| 1434 |
+
|
| 1435 |
+
if (batch && imageKey) {
|
| 1436 |
+
if (ok) {
|
| 1437 |
+
batchMark(batchId, imageKey, { status: "done" });
|
| 1438 |
+
batchUpdateToast(batch, "เสร็จ 1 ภาพ");
|
| 1439 |
+
} else {
|
| 1440 |
+
const cls = classifyJobError(errMsg);
|
| 1441 |
+
batchMark(batchId, imageKey, {
|
| 1442 |
+
status: "error",
|
| 1443 |
+
lastError: errMsg || "Unknown error",
|
| 1444 |
+
permanent: !!cls?.permanent,
|
| 1445 |
+
});
|
| 1446 |
+
batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด");
|
| 1447 |
+
}
|
| 1448 |
+
batchFinalizeIfComplete(batch);
|
| 1449 |
+
}
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
async function processJob(payload, tabId, frameId = 0) {
|
| 1453 |
+
ev("job.enqueue", {
|
| 1454 |
+
type: payload?.type,
|
| 1455 |
+
menu: payload?.menu,
|
| 1456 |
+
src: payload?.src ? "url" : "none",
|
| 1457 |
+
});
|
| 1458 |
+
|
| 1459 |
+
if (!payload || typeof payload !== "object") return;
|
| 1460 |
+
|
| 1461 |
+
if (!payload.metadata || typeof payload.metadata !== "object")
|
| 1462 |
+
payload.metadata = {};
|
| 1463 |
+
const batchId = String(
|
| 1464 |
+
payload.metadata.batch_id || currentBatchId || "",
|
| 1465 |
+
).trim();
|
| 1466 |
+
if (batchId) payload.metadata.batch_id = batchId;
|
| 1467 |
+
const imageKey = imageKeyFromPayload(payload);
|
| 1468 |
+
const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null;
|
| 1469 |
+
|
| 1470 |
+
const pageUrl = payload?.context?.page_url || "";
|
| 1471 |
+
const isMd = isMangaDexPageUrl(pageUrl);
|
| 1472 |
+
|
| 1473 |
+
const originSession = String(
|
| 1474 |
+
payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "",
|
| 1475 |
+
).trim();
|
| 1476 |
+
const curSession = getTabSessionId(tabId);
|
| 1477 |
+
if (originSession && curSession && originSession !== curSession && !isMd) {
|
| 1478 |
+
if (batch && imageKey) {
|
| 1479 |
+
batchMark(batchId, imageKey, {
|
| 1480 |
+
status: "aborted",
|
| 1481 |
+
lastError: "navigation",
|
| 1482 |
+
});
|
| 1483 |
+
batchUpdateToast(batch, "ยกเลิก");
|
| 1484 |
+
batchFinalizeIfComplete(batch);
|
| 1485 |
+
batchStopKeepAlive(batch);
|
| 1486 |
+
}
|
| 1487 |
+
return;
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
+
if (batch && imageKey) {
|
| 1491 |
+
const it = batch.items.get(imageKey);
|
| 1492 |
+
if (it) batch.items.set(imageKey, { ...it, status: "processing" });
|
| 1493 |
+
batchUpdateToast(batch, "ประมวลผล");
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
const base = await getApiBase();
|
| 1497 |
+
const preferRest = shouldPreferRest(base, payload?.mode, payload?.source);
|
| 1498 |
+
|
| 1499 |
+
const src = String(payload?.src || "").trim();
|
| 1500 |
+
const shouldPrefetchDataUri =
|
| 1501 |
+
!payload?.imageDataUri &&
|
| 1502 |
+
Boolean(src) &&
|
| 1503 |
+
(/^(?:https?:|blob:|data:|file:|chrome-extension:)/i.test(src) ||
|
| 1504 |
+
(payload?.mode === "lens_text" &&
|
| 1505 |
+
String(payload?.source || "").toLowerCase() === "ai"));
|
| 1506 |
+
if (shouldPrefetchDataUri) {
|
| 1507 |
+
const key = normImgSrc(src);
|
| 1508 |
+
pruneMdDataUriCache();
|
| 1509 |
+
const cached = key ? mdDataUriCache.get(key) : null;
|
| 1510 |
+
if (
|
| 1511 |
+
cached?.du &&
|
| 1512 |
+
Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS
|
| 1513 |
+
) {
|
| 1514 |
+
payload.imageDataUri = cached.du;
|
| 1515 |
+
ev("image.datauri.cache.hit", { size: payload.imageDataUri.length });
|
| 1516 |
+
} else {
|
| 1517 |
+
try {
|
| 1518 |
+
const du = src.startsWith("data:")
|
| 1519 |
+
? src
|
| 1520 |
+
: await fetchImageDataUriFromUrl(src, pageUrl || "");
|
| 1521 |
+
if (du) {
|
| 1522 |
+
payload.imageDataUri = du;
|
| 1523 |
+
if (key) mdDataUriCache.set(key, { du, ts: Date.now() });
|
| 1524 |
+
const meta =
|
| 1525 |
+
payload.metadata && typeof payload.metadata === "object"
|
| 1526 |
+
? payload.metadata
|
| 1527 |
+
: (payload.metadata = {});
|
| 1528 |
+
const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : [];
|
| 1529 |
+
meta.pipeline = pipe.concat({
|
| 1530 |
+
stage: "prefetch_datauri",
|
| 1531 |
+
at: new Date().toISOString(),
|
| 1532 |
+
});
|
| 1533 |
+
meta.timestamp = new Date().toISOString();
|
| 1534 |
+
if (batch && imageKey) batchMark(batchId, imageKey, { payload });
|
| 1535 |
+
ev("image.datauri.prefetch.ok", { size: du.length });
|
| 1536 |
+
}
|
| 1537 |
+
} catch (e) {
|
| 1538 |
+
const msg = e?.message || String(e);
|
| 1539 |
+
const cls = classifyJobError(msg);
|
| 1540 |
+
evWarn("image.datauri.prefetch.fail", {
|
| 1541 |
+
err: msg,
|
| 1542 |
+
permanent: !!cls?.permanent,
|
| 1543 |
+
});
|
| 1544 |
+
if (cls?.permanent) {
|
| 1545 |
+
if (payload?.metadata?.image_id)
|
| 1546 |
+
pendingByImage.delete(payload.metadata.image_id);
|
| 1547 |
+
if (batch && imageKey) {
|
| 1548 |
+
batchMark(batchId, imageKey, {
|
| 1549 |
+
status: "error",
|
| 1550 |
+
lastError: msg,
|
| 1551 |
+
permanent: true,
|
| 1552 |
+
});
|
| 1553 |
+
batchUpdateToast(batch, "ผิดพลาด (ถาวร)");
|
| 1554 |
+
batchFinalizeIfComplete(batch);
|
| 1555 |
+
}
|
| 1556 |
+
const jobId = crypto.randomUUID();
|
| 1557 |
+
failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId);
|
| 1558 |
+
return;
|
| 1559 |
+
}
|
| 1560 |
+
}
|
| 1561 |
+
}
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
if (blockSendsBecauseWsEnded && !preferRest) {
|
| 1565 |
+
const msg = "Connection closed. Please run the menu again.";
|
| 1566 |
+
if (batch && imageKey) {
|
| 1567 |
+
batchMark(batchId, imageKey, { status: "aborted", lastError: msg });
|
| 1568 |
+
batchUpdateToast(batch, "ยกเลิก");
|
| 1569 |
+
batchFinalizeIfComplete(batch);
|
| 1570 |
+
}
|
| 1571 |
+
const jobId = crypto.randomUUID();
|
| 1572 |
+
failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId);
|
| 1573 |
+
return;
|
| 1574 |
+
}
|
| 1575 |
+
const sessionId = getTabSessionId(tabId) || bumpTabSession(tabId, pageUrl);
|
| 1576 |
+
|
| 1577 |
+
if (payload?.metadata?.image_id) {
|
| 1578 |
+
pendingByImage.set(payload.metadata.image_id, {
|
| 1579 |
+
imgUrl: payload.src,
|
| 1580 |
+
tabId,
|
| 1581 |
+
frameId,
|
| 1582 |
+
mode: payload?.mode || null,
|
| 1583 |
+
lang: payload?.lang || null,
|
| 1584 |
+
source: payload?.source || null,
|
| 1585 |
+
metadata: payload.metadata,
|
| 1586 |
+
imageKey,
|
| 1587 |
+
batchId,
|
| 1588 |
+
pageUrl,
|
| 1589 |
+
sessionId: originSession || sessionId,
|
| 1590 |
+
keepCacheOnStale: isMd,
|
| 1591 |
+
settingsEpoch,
|
| 1592 |
+
});
|
| 1593 |
+
}
|
| 1594 |
+
if (preferRest) {
|
| 1595 |
+
let jid = "";
|
| 1596 |
+
try {
|
| 1597 |
+
jid = await submitJobViaRest(base, payload);
|
| 1598 |
+
pendingByJob.set(jid, {
|
| 1599 |
+
imgUrl: payload.src,
|
| 1600 |
+
tabId,
|
| 1601 |
+
frameId,
|
| 1602 |
+
mode: payload?.mode || null,
|
| 1603 |
+
lang: payload?.lang || null,
|
| 1604 |
+
source: payload?.source || null,
|
| 1605 |
+
metadata: payload.metadata,
|
| 1606 |
+
startedAt: Date.now(),
|
| 1607 |
+
batchId,
|
| 1608 |
+
imageKey,
|
| 1609 |
+
pageUrl,
|
| 1610 |
+
sessionId: originSession || sessionId,
|
| 1611 |
+
keepCacheOnStale: isMd,
|
| 1612 |
+
settingsEpoch,
|
| 1613 |
+
});
|
| 1614 |
+
await pollJobViaRest(base, jid);
|
| 1615 |
+
return;
|
| 1616 |
+
} catch (e) {
|
| 1617 |
+
const msg = e?.message || String(e);
|
| 1618 |
+
if (jid) {
|
| 1619 |
+
handleJobError(jid, msg);
|
| 1620 |
+
return;
|
| 1621 |
+
}
|
| 1622 |
+
if (payload?.metadata?.image_id)
|
| 1623 |
+
pendingByImage.delete(payload.metadata.image_id);
|
| 1624 |
+
if (batch && imageKey) {
|
| 1625 |
+
const cls = classifyJobError(msg);
|
| 1626 |
+
batchMark(batchId, imageKey, {
|
| 1627 |
+
status: "error",
|
| 1628 |
+
lastError: msg,
|
| 1629 |
+
permanent: !!cls?.permanent,
|
| 1630 |
+
});
|
| 1631 |
+
batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด");
|
| 1632 |
+
batchFinalizeIfComplete(batch);
|
| 1633 |
+
}
|
| 1634 |
+
const jobId = crypto.randomUUID();
|
| 1635 |
+
failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId);
|
| 1636 |
+
return;
|
| 1637 |
+
}
|
| 1638 |
+
}
|
| 1639 |
+
|
| 1640 |
+
for (let attempt = 0; attempt <= MAX_FIRST_TRY_RETRIES; attempt++) {
|
| 1641 |
+
if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) {
|
| 1642 |
+
const connected = await connectWebSocketOnce();
|
| 1643 |
+
if (!connected) {
|
| 1644 |
+
if (attempt < MAX_FIRST_TRY_RETRIES) {
|
| 1645 |
+
await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS));
|
| 1646 |
+
continue;
|
| 1647 |
+
} else {
|
| 1648 |
+
let jid = "";
|
| 1649 |
+
try {
|
| 1650 |
+
jid = await submitJobViaRest(base, payload);
|
| 1651 |
+
pendingByJob.set(jid, {
|
| 1652 |
+
imgUrl: payload.src,
|
| 1653 |
+
tabId,
|
| 1654 |
+
frameId,
|
| 1655 |
+
mode: payload?.mode || null,
|
| 1656 |
+
lang: payload?.lang || null,
|
| 1657 |
+
source: payload?.source || null,
|
| 1658 |
+
metadata: payload.metadata,
|
| 1659 |
+
startedAt: Date.now(),
|
| 1660 |
+
batchId,
|
| 1661 |
+
imageKey,
|
| 1662 |
+
pageUrl,
|
| 1663 |
+
sessionId: originSession || sessionId,
|
| 1664 |
+
keepCacheOnStale: isMd,
|
| 1665 |
+
settingsEpoch,
|
| 1666 |
+
});
|
| 1667 |
+
await pollJobViaRest(base, jid);
|
| 1668 |
+
return;
|
| 1669 |
+
} catch (e) {
|
| 1670 |
+
const msg = e?.message || String(e);
|
| 1671 |
+
if (jid) {
|
| 1672 |
+
handleJobError(jid, msg);
|
| 1673 |
+
return;
|
| 1674 |
+
}
|
| 1675 |
+
if (payload?.metadata?.image_id)
|
| 1676 |
+
pendingByImage.delete(payload.metadata.image_id);
|
| 1677 |
+
if (batch && imageKey) {
|
| 1678 |
+
const cls = classifyJobError(msg);
|
| 1679 |
+
batchMark(batchId, imageKey, {
|
| 1680 |
+
status: "error",
|
| 1681 |
+
lastError: msg,
|
| 1682 |
+
permanent: !!cls?.permanent,
|
| 1683 |
+
});
|
| 1684 |
+
batchUpdateToast(
|
| 1685 |
+
batch,
|
| 1686 |
+
cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด",
|
| 1687 |
+
);
|
| 1688 |
+
batchFinalizeIfComplete(batch);
|
| 1689 |
+
}
|
| 1690 |
+
const jobId = crypto.randomUUID();
|
| 1691 |
+
failJobImmediately(
|
| 1692 |
+
tabId,
|
| 1693 |
+
jobId,
|
| 1694 |
+
payload?.src || null,
|
| 1695 |
+
msg,
|
| 1696 |
+
frameId,
|
| 1697 |
+
);
|
| 1698 |
+
return;
|
| 1699 |
+
}
|
| 1700 |
+
}
|
| 1701 |
+
}
|
| 1702 |
+
}
|
| 1703 |
+
|
| 1704 |
+
const jobId = crypto.randomUUID();
|
| 1705 |
+
pendingByJob.set(jobId, {
|
| 1706 |
+
imgUrl: payload.src,
|
| 1707 |
+
tabId,
|
| 1708 |
+
frameId,
|
| 1709 |
+
mode: payload?.mode || null,
|
| 1710 |
+
lang: payload?.lang || null,
|
| 1711 |
+
source: payload?.source || null,
|
| 1712 |
+
metadata: payload.metadata,
|
| 1713 |
+
startedAt: Date.now(),
|
| 1714 |
+
batchId,
|
| 1715 |
+
imageKey,
|
| 1716 |
+
pageUrl,
|
| 1717 |
+
sessionId: originSession || sessionId,
|
| 1718 |
+
keepCacheOnStale: isMd,
|
| 1719 |
+
settingsEpoch,
|
| 1720 |
+
});
|
| 1721 |
+
try {
|
| 1722 |
+
ev("job.send", { id: jobId });
|
| 1723 |
+
ws.send(JSON.stringify({ type: "job", id: jobId, payload }));
|
| 1724 |
+
return;
|
| 1725 |
+
} catch (e) {
|
| 1726 |
+
handleJobError(jobId, "Send failed: " + (e?.message || e));
|
| 1727 |
+
if (attempt < MAX_FIRST_TRY_RETRIES) {
|
| 1728 |
+
await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS));
|
| 1729 |
+
continue;
|
| 1730 |
+
} else {
|
| 1731 |
+
return;
|
| 1732 |
+
}
|
| 1733 |
+
}
|
| 1734 |
+
}
|
| 1735 |
+
}
|
| 1736 |
+
|
| 1737 |
+
const enqueue = (payload, tabId, frameId = 0) => {
|
| 1738 |
+
const expected = String(
|
| 1739 |
+
payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "",
|
| 1740 |
+
).trim();
|
| 1741 |
+
addTask(() => {
|
| 1742 |
+
const cur = getTabSessionId(tabId);
|
| 1743 |
+
if (expected && (!cur || expected !== cur)) return;
|
| 1744 |
+
return processJob(payload, tabId, frameId);
|
| 1745 |
+
});
|
| 1746 |
+
};
|
| 1747 |
+
|
| 1748 |
+
function recreateMenus() {
|
| 1749 |
+
chrome.contextMenus.removeAll(() => {
|
| 1750 |
+
chrome.contextMenus.create({
|
| 1751 |
+
id: "img_one",
|
| 1752 |
+
title: "🔍 Translate this image",
|
| 1753 |
+
contexts: ["image"],
|
| 1754 |
+
});
|
| 1755 |
+
chrome.contextMenus.create({
|
| 1756 |
+
id: "img_all",
|
| 1757 |
+
title: "🔍 Translate all images on page",
|
| 1758 |
+
contexts: ["page", "selection"],
|
| 1759 |
+
});
|
| 1760 |
+
});
|
| 1761 |
+
}
|
| 1762 |
+
|
| 1763 |
+
async function getSettings() {
|
| 1764 |
+
return new Promise((res) => {
|
| 1765 |
+
chrome.storage.local.get(
|
| 1766 |
+
[
|
| 1767 |
+
"mode",
|
| 1768 |
+
"lang",
|
| 1769 |
+
"sources",
|
| 1770 |
+
"maxConcurrency",
|
| 1771 |
+
"aiKey",
|
| 1772 |
+
"aiModel",
|
| 1773 |
+
"aiPromptByLang",
|
| 1774 |
+
"aiPrompt",
|
| 1775 |
+
],
|
| 1776 |
+
(it) => {
|
| 1777 |
+
MAX_CONCURRENCY =
|
| 1778 |
+
Number(it.maxConcurrency) >= 0 ? Number(it.maxConcurrency) : 0;
|
| 1779 |
+
const lang = typeof it.lang === "string" ? it.lang : "en";
|
| 1780 |
+
const aiModel = normalizeAiModel(
|
| 1781 |
+
typeof it.aiModel === "string" ? it.aiModel : "auto",
|
| 1782 |
+
);
|
| 1783 |
+
const rawMap =
|
| 1784 |
+
it.aiPromptByLang && typeof it.aiPromptByLang === "object"
|
| 1785 |
+
? it.aiPromptByLang
|
| 1786 |
+
: {};
|
| 1787 |
+
const mig = migratePromptMap(rawMap);
|
| 1788 |
+
const map = mig.map;
|
| 1789 |
+
const key = makePromptKey(lang, aiModel);
|
| 1790 |
+
const autoKey = makePromptKey(lang, "auto");
|
| 1791 |
+
let changed = mig.changed;
|
| 1792 |
+
let aiPrompt = Object.prototype.hasOwnProperty.call(map, key)
|
| 1793 |
+
? String(map[key] || "")
|
| 1794 |
+
: "";
|
| 1795 |
+
if (!aiPrompt && Object.prototype.hasOwnProperty.call(map, autoKey)) {
|
| 1796 |
+
aiPrompt = String(map[autoKey] || "");
|
| 1797 |
+
if (key !== autoKey) {
|
| 1798 |
+
map[key] = aiPrompt;
|
| 1799 |
+
changed = true;
|
| 1800 |
+
}
|
| 1801 |
+
}
|
| 1802 |
+
if (!aiPrompt) {
|
| 1803 |
+
const legacy = typeof it.aiPrompt === "string" ? it.aiPrompt : "";
|
| 1804 |
+
if (legacy) {
|
| 1805 |
+
aiPrompt = legacy;
|
| 1806 |
+
map[key] = legacy;
|
| 1807 |
+
changed = true;
|
| 1808 |
+
}
|
| 1809 |
+
}
|
| 1810 |
+
aiPrompt = normalizePrompt(aiPrompt);
|
| 1811 |
+
if (changed) {
|
| 1812 |
+
map[key] = aiPrompt;
|
| 1813 |
+
chrome.storage.local.set({ aiPromptByLang: map, aiPrompt: "" });
|
| 1814 |
+
}
|
| 1815 |
+
res({
|
| 1816 |
+
mode: typeof it.mode === "string" ? it.mode : "lens_images",
|
| 1817 |
+
lang,
|
| 1818 |
+
sources: typeof it.sources === "string" ? it.sources : "translated",
|
| 1819 |
+
aiKey: typeof it.aiKey === "string" ? it.aiKey : "",
|
| 1820 |
+
aiModel,
|
| 1821 |
+
aiPrompt,
|
| 1822 |
+
});
|
| 1823 |
+
},
|
| 1824 |
+
);
|
| 1825 |
+
});
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
chrome.contextMenus.onClicked.addListener(async (menuInfo, tab) => {
|
| 1829 |
+
ev("menu.click", { id: menuInfo.menuItemId });
|
| 1830 |
+
try {
|
| 1831 |
+
if (tab?.id) await ensureContentScript(tab.id);
|
| 1832 |
+
const tabSessionId = tab?.id
|
| 1833 |
+
? ensureTabSession(tab.id, tab?.url || "")
|
| 1834 |
+
: "";
|
| 1835 |
+
const { mode, lang, sources, aiKey, aiModel, aiPrompt } =
|
| 1836 |
+
await getSettings();
|
| 1837 |
+
const source =
|
| 1838 |
+
mode === "lens_text" ? sources || "translated" : "translated";
|
| 1839 |
+
const aiPayload =
|
| 1840 |
+
mode === "lens_text" && source === "ai"
|
| 1841 |
+
? {
|
| 1842 |
+
api_key: aiKey || "",
|
| 1843 |
+
model: aiModel || "auto",
|
| 1844 |
+
prompt: aiPrompt || "",
|
| 1845 |
+
}
|
| 1846 |
+
: null;
|
| 1847 |
+
forceSoftMaxConcurrency = mode === "lens_text" && source === "ai";
|
| 1848 |
+
softMaxConcurrency = forceSoftMaxConcurrency
|
| 1849 |
+
? aiSoftMaxConcurrencyFromKey(aiKey)
|
| 1850 |
+
: SOFT_MAX_CONCURRENCY_DEFAULT;
|
| 1851 |
+
ev("batch.concurrency", {
|
| 1852 |
+
soft: softMaxConcurrency,
|
| 1853 |
+
max: MAX_CONCURRENCY || "unlimited",
|
| 1854 |
+
});
|
| 1855 |
+
currentBatchId = crypto.randomUUID();
|
| 1856 |
+
blockSendsBecauseWsEnded = false;
|
| 1857 |
+
const base = await getApiBase();
|
| 1858 |
+
const preferRest = shouldPreferRest(base, mode, source);
|
| 1859 |
+
if (!preferRest) {
|
| 1860 |
+
const connected = await connectWebSocketOnce();
|
| 1861 |
+
if (!connected) {
|
| 1862 |
+
evWarn("ws.unavailable.fallback_rest");
|
| 1863 |
+
}
|
| 1864 |
+
}
|
| 1865 |
+
|
| 1866 |
+
let originalUrl = menuInfo.srcUrl;
|
| 1867 |
+
const frameId = Number(menuInfo.frameId) || 0;
|
| 1868 |
+
const scanFrameId = 0;
|
| 1869 |
+
sendToastToTab(
|
| 1870 |
+
tab?.id,
|
| 1871 |
+
menuInfo.menuItemId === "img_all" ? scanFrameId : frameId,
|
| 1872 |
+
menuInfo.menuItemId === "img_all"
|
| 1873 |
+
? "TextPhantom: กำลังรับภาพ…"
|
| 1874 |
+
: "TextPhantom: กำลังประมวลผล…",
|
| 1875 |
+
60000,
|
| 1876 |
+
);
|
| 1877 |
+
if (
|
| 1878 |
+
tab?.url?.includes("mangadex.org") &&
|
| 1879 |
+
originalUrl?.startsWith("blob:")
|
| 1880 |
+
) {
|
| 1881 |
+
try {
|
| 1882 |
+
const resp = await requestFromTab(
|
| 1883 |
+
tab.id,
|
| 1884 |
+
{ type: "RESOLVE_AND_REPLACE_MANGADEX_BLOB", blobUrl: originalUrl },
|
| 1885 |
+
frameId,
|
| 1886 |
+
);
|
| 1887 |
+
if (resp?.resolved) originalUrl = resp.resolved;
|
| 1888 |
+
} catch (e) {
|
| 1889 |
+
warn("resolve MangaDex blob error", e);
|
| 1890 |
+
}
|
| 1891 |
+
}
|
| 1892 |
+
|
| 1893 |
+
if (menuInfo.menuItemId === "img_one" && tab?.id) {
|
| 1894 |
+
try {
|
| 1895 |
+
await sendToTab(
|
| 1896 |
+
tab.id,
|
| 1897 |
+
{ type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 },
|
| 1898 |
+
frameId,
|
| 1899 |
+
);
|
| 1900 |
+
} catch {}
|
| 1901 |
+
|
| 1902 |
+
let payload = null;
|
| 1903 |
+
const wantsContextPayload =
|
| 1904 |
+
!originalUrl || /^(?:blob:|data:|file:|chrome-extension:)/i.test(String(originalUrl || ""));
|
| 1905 |
+
if (wantsContextPayload) {
|
| 1906 |
+
try {
|
| 1907 |
+
const resp = await requestFromTab(
|
| 1908 |
+
tab.id,
|
| 1909 |
+
{ type: "GET_CONTEXT_IMAGE_PAYLOAD" },
|
| 1910 |
+
frameId,
|
| 1911 |
+
);
|
| 1912 |
+
if (resp?.ok && resp?.payload) payload = resp.payload;
|
| 1913 |
+
} catch (e) {
|
| 1914 |
+
evWarn("context.payload.fail", { err: e?.message || String(e) });
|
| 1915 |
+
}
|
| 1916 |
+
}
|
| 1917 |
+
|
| 1918 |
+
const metadata0 =
|
| 1919 |
+
payload?.metadata && typeof payload.metadata === "object"
|
| 1920 |
+
? payload.metadata
|
| 1921 |
+
: {};
|
| 1922 |
+
const pipeline0 = Array.isArray(metadata0.pipeline) ? metadata0.pipeline : [];
|
| 1923 |
+
const imageId = String(metadata0.image_id || "").trim() || crypto.randomUUID();
|
| 1924 |
+
const sourceUrl = payload?.src || originalUrl || null;
|
| 1925 |
+
|
| 1926 |
+
payload = {
|
| 1927 |
+
...(payload && typeof payload === "object" ? payload : {}),
|
| 1928 |
+
mode,
|
| 1929 |
+
lang,
|
| 1930 |
+
type: "image",
|
| 1931 |
+
src: sourceUrl,
|
| 1932 |
+
imageDataUri:
|
| 1933 |
+
typeof payload?.imageDataUri === "string" && payload.imageDataUri
|
| 1934 |
+
? payload.imageDataUri
|
| 1935 |
+
: null,
|
| 1936 |
+
menu: "img_one",
|
| 1937 |
+
source,
|
| 1938 |
+
ai: aiPayload,
|
| 1939 |
+
context: {
|
| 1940 |
+
...(payload?.context && typeof payload.context === "object"
|
| 1941 |
+
? payload.context
|
| 1942 |
+
: {}),
|
| 1943 |
+
page_url: tab?.url || null,
|
| 1944 |
+
timestamp: new Date().toISOString(),
|
| 1945 |
+
tp_tab_session: tabSessionId,
|
| 1946 |
+
},
|
| 1947 |
+
metadata: {
|
| 1948 |
+
...metadata0,
|
| 1949 |
+
image_id: imageId,
|
| 1950 |
+
batch_id: currentBatchId,
|
| 1951 |
+
original_image_url: sourceUrl,
|
| 1952 |
+
position: metadata0.position || null,
|
| 1953 |
+
ocr_image: null,
|
| 1954 |
+
extra: null,
|
| 1955 |
+
pipeline: pipeline0.concat({
|
| 1956 |
+
stage: "context_menu_single",
|
| 1957 |
+
at: new Date().toISOString(),
|
| 1958 |
+
}),
|
| 1959 |
+
timestamp: new Date().toISOString(),
|
| 1960 |
+
},
|
| 1961 |
+
};
|
| 1962 |
+
|
| 1963 |
+
if (
|
| 1964 |
+
!payload.imageDataUri &&
|
| 1965 |
+
String(sourceUrl || "").startsWith("blob:")
|
| 1966 |
+
) {
|
| 1967 |
+
try {
|
| 1968 |
+
const du = await fetchImageDataUriFromUrl(sourceUrl, tab?.url || null);
|
| 1969 |
+
if (du) payload.imageDataUri = du;
|
| 1970 |
+
ev("image.datauri.ok", { size: du ? du.length : 0 });
|
| 1971 |
+
} catch (e) {
|
| 1972 |
+
evWarn("image.datauri.fail", { err: e?.message || String(e) });
|
| 1973 |
+
}
|
| 1974 |
+
}
|
| 1975 |
+
|
| 1976 |
+
if (!payload.src && !payload.imageDataUri) return;
|
| 1977 |
+
|
| 1978 |
+
const b = ensureBatch(currentBatchId, tab.id, frameId);
|
| 1979 |
+
b.total1 = 1;
|
| 1980 |
+
const k = imageKeyFromPayload(payload);
|
| 1981 |
+
if (k)
|
| 1982 |
+
b.items.set(k, {
|
| 1983 |
+
payload,
|
| 1984 |
+
attempt: 1,
|
| 1985 |
+
status: "queued",
|
| 1986 |
+
lastError: "",
|
| 1987 |
+
});
|
| 1988 |
+
batchUpdateToast(b, "รับภาพ", true);
|
| 1989 |
+
|
| 1990 |
+
enqueue(payload, tab.id, frameId);
|
| 1991 |
+
return;
|
| 1992 |
+
}
|
| 1993 |
+
|
| 1994 |
+
if (menuInfo.menuItemId === "img_all" && tab?.id) {
|
| 1995 |
+
try {
|
| 1996 |
+
await sendToTab(
|
| 1997 |
+
tab.id,
|
| 1998 |
+
{ type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 },
|
| 1999 |
+
scanFrameId,
|
| 2000 |
+
);
|
| 2001 |
+
} catch {}
|
| 2002 |
+
let images = [];
|
| 2003 |
+
let imagesFrameId = scanFrameId;
|
| 2004 |
+
try {
|
| 2005 |
+
images = await requestFromTab(
|
| 2006 |
+
tab.id,
|
| 2007 |
+
{ type: "GET_IMAGES" },
|
| 2008 |
+
scanFrameId,
|
| 2009 |
+
);
|
| 2010 |
+
if ((!images || !images.length) && frameId) {
|
| 2011 |
+
const alt = await requestFromTab(
|
| 2012 |
+
tab.id,
|
| 2013 |
+
{ type: "GET_IMAGES" },
|
| 2014 |
+
frameId,
|
| 2015 |
+
);
|
| 2016 |
+
if (Array.isArray(alt) && alt.length) {
|
| 2017 |
+
images = alt;
|
| 2018 |
+
imagesFrameId = frameId;
|
| 2019 |
+
}
|
| 2020 |
+
}
|
| 2021 |
+
} catch (e) {
|
| 2022 |
+
error("GET_IMAGES failed", e);
|
| 2023 |
+
}
|
| 2024 |
+
let payloads = (Array.isArray(images) ? images : [])
|
| 2025 |
+
.map((meta) => {
|
| 2026 |
+
const m = meta?.metadata || {};
|
| 2027 |
+
const imageId = m.image_id || crypto.randomUUID();
|
| 2028 |
+
const src = m.original_image_url || meta.src || "";
|
| 2029 |
+
return {
|
| 2030 |
+
mode,
|
| 2031 |
+
lang,
|
| 2032 |
+
type: "image",
|
| 2033 |
+
src: src || null,
|
| 2034 |
+
imageDataUri:
|
| 2035 |
+
typeof meta?.imageDataUri === "string" && meta.imageDataUri
|
| 2036 |
+
? meta.imageDataUri
|
| 2037 |
+
: typeof m.imageDataUri === "string" && m.imageDataUri
|
| 2038 |
+
? m.imageDataUri
|
| 2039 |
+
: null,
|
| 2040 |
+
menu: "img_all",
|
| 2041 |
+
source,
|
| 2042 |
+
ai: aiPayload,
|
| 2043 |
+
context: {
|
| 2044 |
+
page_url: tab?.url || null,
|
| 2045 |
+
timestamp: new Date().toISOString(),
|
| 2046 |
+
tp_tab_session: tabSessionId,
|
| 2047 |
+
},
|
| 2048 |
+
metadata: {
|
| 2049 |
+
image_id: imageId,
|
| 2050 |
+
batch_id: currentBatchId,
|
| 2051 |
+
original_image_url: src || null,
|
| 2052 |
+
position: m.position || null,
|
| 2053 |
+
ocr_image: null,
|
| 2054 |
+
extra: null,
|
| 2055 |
+
pipeline: (Array.isArray(m.pipeline) ? m.pipeline : []).concat({
|
| 2056 |
+
stage: "context_menu_all",
|
| 2057 |
+
at: new Date().toISOString(),
|
| 2058 |
+
}),
|
| 2059 |
+
timestamp: new Date().toISOString(),
|
| 2060 |
+
},
|
| 2061 |
+
};
|
| 2062 |
+
})
|
| 2063 |
+
.filter((p) => !!(p.src || p.imageDataUri));
|
| 2064 |
+
|
| 2065 |
+
const seen = new Set();
|
| 2066 |
+
const uniq = [];
|
| 2067 |
+
for (const pl of payloads) {
|
| 2068 |
+
const k = imageKeyFromPayload(pl);
|
| 2069 |
+
if (!k || seen.has(k)) continue;
|
| 2070 |
+
seen.add(k);
|
| 2071 |
+
uniq.push(pl);
|
| 2072 |
+
}
|
| 2073 |
+
payloads = uniq;
|
| 2074 |
+
|
| 2075 |
+
const b = ensureBatch(currentBatchId, tab.id, imagesFrameId);
|
| 2076 |
+
b.total1 = payloads.length;
|
| 2077 |
+
for (const pl of payloads) {
|
| 2078 |
+
const k = imageKeyFromPayload(pl);
|
| 2079 |
+
if (!k || b.items.has(k)) continue;
|
| 2080 |
+
b.items.set(k, {
|
| 2081 |
+
payload: pl,
|
| 2082 |
+
attempt: 1,
|
| 2083 |
+
status: "queued",
|
| 2084 |
+
lastError: "",
|
| 2085 |
+
});
|
| 2086 |
+
}
|
| 2087 |
+
batchUpdateToast(b, "รับภาพ", true);
|
| 2088 |
+
|
| 2089 |
+
payloads.forEach((p) => enqueue(p, tab.id, imagesFrameId));
|
| 2090 |
+
return;
|
| 2091 |
+
}
|
| 2092 |
+
} catch (e) {
|
| 2093 |
+
error("[menu] handler error", e);
|
| 2094 |
+
}
|
| 2095 |
+
});
|
| 2096 |
+
|
| 2097 |
+
chrome.runtime.onConnect.addListener((port) => {
|
| 2098 |
+
if (!port || port.name !== KEEPALIVE_PORT_NAME) return;
|
| 2099 |
+
keepAlivePorts.add(port);
|
| 2100 |
+
const tabId = port.sender?.tab?.id;
|
| 2101 |
+
const frameId = port.sender?.frameId;
|
| 2102 |
+
try {
|
| 2103 |
+
port.onMessage.addListener(() => void 0);
|
| 2104 |
+
port.onDisconnect.addListener(() => {
|
| 2105 |
+
keepAlivePorts.delete(port);
|
| 2106 |
+
if (!Number.isFinite(tabId)) return;
|
| 2107 |
+
if (Number.isFinite(frameId) && frameId !== 0) return;
|
| 2108 |
+
bumpTabSession(tabId, "");
|
| 2109 |
+
cancelTabWork(tabId, "page_unloaded");
|
| 2110 |
+
});
|
| 2111 |
+
} catch {
|
| 2112 |
+
keepAlivePorts.delete(port);
|
| 2113 |
+
}
|
| 2114 |
+
});
|
| 2115 |
+
|
| 2116 |
+
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
| 2117 |
+
if (!Number.isFinite(tabId)) return;
|
| 2118 |
+
if (changeInfo.status !== "loading") return;
|
| 2119 |
+
const href = changeInfo.url || tab?.url || "";
|
| 2120 |
+
|
| 2121 |
+
if (isMangaDexPageUrl(href)) return;
|
| 2122 |
+
|
| 2123 |
+
bumpTabSession(tabId, href);
|
| 2124 |
+
cancelTabWork(tabId, "navigation");
|
| 2125 |
+
});
|
| 2126 |
+
|
| 2127 |
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
| 2128 |
+
cancelTabWork(tabId, "tab_closed");
|
| 2129 |
+
tabSessionById.delete(tabId);
|
| 2130 |
+
});
|
| 2131 |
+
|
| 2132 |
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
| 2133 |
+
const t = String(msg?.type || "");
|
| 2134 |
+
|
| 2135 |
+
if (t === "GET_WS_STATUS") {
|
| 2136 |
+
sendResponse({ status: wsStatus, ready: wsReady });
|
| 2137 |
+
return true;
|
| 2138 |
+
}
|
| 2139 |
+
|
| 2140 |
+
if (t === "GET_BATCH_STATUS") {
|
| 2141 |
+
sendResponse({ ok: true, batch: lastBatchStatus });
|
| 2142 |
+
return true;
|
| 2143 |
+
}
|
| 2144 |
+
|
| 2145 |
+
if (t === "GET_API_STATUS") {
|
| 2146 |
+
sendResponse({
|
| 2147 |
+
ok: healthCache.ok,
|
| 2148 |
+
ts: healthCache.ts,
|
| 2149 |
+
build: healthCache.build,
|
| 2150 |
+
});
|
| 2151 |
+
return true;
|
| 2152 |
+
}
|
| 2153 |
+
|
| 2154 |
+
if (t === "API_URL_CHANGED") {
|
| 2155 |
+
try {
|
| 2156 |
+
ws?.close(1000, "api_url_changed");
|
| 2157 |
+
} catch {}
|
| 2158 |
+
ws = null;
|
| 2159 |
+
wsReady = false;
|
| 2160 |
+
wsPromise = null;
|
| 2161 |
+
wsStatus = "disconnected";
|
| 2162 |
+
blockSendsBecauseWsEnded = false;
|
| 2163 |
+
healthCache.ts = 0;
|
| 2164 |
+
connectWebSocketOnce().catch(() => {});
|
| 2165 |
+
getApiBase()
|
| 2166 |
+
.then((b) => warmupApi(b))
|
| 2167 |
+
.catch(() => {});
|
| 2168 |
+
sendResponse({ ok: true });
|
| 2169 |
+
return true;
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
if (t === "TP_CONTENT_READY") {
|
| 2173 |
+
const tabId = sender?.tab?.id;
|
| 2174 |
+
if (msg?.top && Number.isFinite(tabId)) {
|
| 2175 |
+
ensureTabSession(tabId, msg?.href);
|
| 2176 |
+
}
|
| 2177 |
+
ev("content.ready", {
|
| 2178 |
+
tabId: sender?.tab?.id || 0,
|
| 2179 |
+
frameId: sender?.frameId || 0,
|
| 2180 |
+
href: String(msg?.href || ""),
|
| 2181 |
+
ver: String(msg?.ver || ""),
|
| 2182 |
+
top: Boolean(msg?.top),
|
| 2183 |
+
});
|
| 2184 |
+
sendResponse({ ok: true });
|
| 2185 |
+
return true;
|
| 2186 |
+
}
|
| 2187 |
+
|
| 2188 |
+
if (t === "TP_LOCATION_CHANGED") {
|
| 2189 |
+
const tabId = sender?.tab?.id;
|
| 2190 |
+
if (msg?.top && Number.isFinite(tabId)) {
|
| 2191 |
+
ensureTabSession(tabId, msg?.href);
|
| 2192 |
+
}
|
| 2193 |
+
sendResponse({ ok: true });
|
| 2194 |
+
return true;
|
| 2195 |
+
}
|
| 2196 |
+
|
| 2197 |
+
if (t === "TP_MD_CACHE_GET") {
|
| 2198 |
+
pruneMdCache();
|
| 2199 |
+
const includeNewImg = Boolean(msg?.includeNewImg);
|
| 2200 |
+
const lang = typeof msg?.lang === "string" ? msg.lang : "";
|
| 2201 |
+
const mode = typeof msg?.mode === "string" ? msg.mode : "";
|
| 2202 |
+
if (!lang || !mode) {
|
| 2203 |
+
sendResponse({ items: {} });
|
| 2204 |
+
return true;
|
| 2205 |
+
}
|
| 2206 |
+
const rawKeys = Array.isArray(msg?.keys) ? msg.keys : [];
|
| 2207 |
+
const keys = rawKeys.slice(0, includeNewImg ? 6 : 600);
|
| 2208 |
+
const items = {};
|
| 2209 |
+
for (const k of keys) {
|
| 2210 |
+
const mdKey = String(k || "");
|
| 2211 |
+
if (!mdKey) continue;
|
| 2212 |
+
const cacheKey = mdCacheKey(mdKey, lang, mode);
|
| 2213 |
+
if (!cacheKey) continue;
|
| 2214 |
+
const rec = mdCacheByKey.get(cacheKey);
|
| 2215 |
+
if (!rec) continue;
|
| 2216 |
+
const derivedNewImg =
|
| 2217 |
+
rec.newImg ||
|
| 2218 |
+
rec?.result?.imageDataUri ||
|
| 2219 |
+
rec?.result?.imageDataURI ||
|
| 2220 |
+
rec?.result?.image ||
|
| 2221 |
+
rec?.result?.imageUrl ||
|
| 2222 |
+
rec?.result?.image_url ||
|
| 2223 |
+
rec?.result?.imageURL ||
|
| 2224 |
+
null;
|
| 2225 |
+
const hasNewImg = Boolean(derivedNewImg);
|
| 2226 |
+
items[mdKey] = {
|
| 2227 |
+
hasNewImg,
|
| 2228 |
+
result: stripImageFields(rec.result),
|
| 2229 |
+
...(includeNewImg ? { newImg: derivedNewImg } : {}),
|
| 2230 |
+
};
|
| 2231 |
+
}
|
| 2232 |
+
sendResponse({ items });
|
| 2233 |
+
return true;
|
| 2234 |
+
}
|
| 2235 |
+
|
| 2236 |
+
if (t === "TP_LOG") {
|
| 2237 |
+
const lvl = String(msg?.level || "info");
|
| 2238 |
+
const data = msg?.data || {};
|
| 2239 |
+
const line = "[content] " + String(msg?.msg || "");
|
| 2240 |
+
if (lvl === "error") error(line, data);
|
| 2241 |
+
else if (lvl === "warn") warn(line, data);
|
| 2242 |
+
else info(line, data);
|
| 2243 |
+
sendResponse({ ok: true });
|
| 2244 |
+
return true;
|
| 2245 |
+
}
|
| 2246 |
+
|
| 2247 |
+
if (t === "CANCEL_BATCH") {
|
| 2248 |
+
try {
|
| 2249 |
+
const bid = String(msg.batchId || "");
|
| 2250 |
+
if (bid && typeof pendingByJob?.keys === "function") {
|
| 2251 |
+
for (const [jid, rec] of Array.from(pendingByJob.entries())) {
|
| 2252 |
+
if (rec?.batchId === bid) pendingByJob.delete(jid);
|
| 2253 |
+
}
|
| 2254 |
+
}
|
| 2255 |
+
sendResponse({ success: true });
|
| 2256 |
+
} catch (e) {
|
| 2257 |
+
sendResponse({ success: false, error: String((e && e.message) || e) });
|
| 2258 |
+
}
|
| 2259 |
+
return true;
|
| 2260 |
+
}
|
| 2261 |
+
|
| 2262 |
+
if (t === "fetchImageBlob") {
|
| 2263 |
+
(async () => {
|
| 2264 |
+
try {
|
| 2265 |
+
const u = String(msg.url || "").trim();
|
| 2266 |
+
const res = await fetch(u, {
|
| 2267 |
+
credentials: "include",
|
| 2268 |
+
redirect: "follow",
|
| 2269 |
+
referrer: msg.pageUrl || "about:client",
|
| 2270 |
+
});
|
| 2271 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 2272 |
+
const blob = await res.blob();
|
| 2273 |
+
const dataUrl = await blobToDataUri(blob);
|
| 2274 |
+
const comma = dataUrl.indexOf(",");
|
| 2275 |
+
sendResponse({
|
| 2276 |
+
success: true,
|
| 2277 |
+
blobData: comma >= 0 ? dataUrl.slice(comma + 1) : "",
|
| 2278 |
+
mimeType: blob.type || "application/octet-stream",
|
| 2279 |
+
});
|
| 2280 |
+
} catch (e) {
|
| 2281 |
+
warn("fetchImageBlob failed", e);
|
| 2282 |
+
sendResponse({
|
| 2283 |
+
success: false,
|
| 2284 |
+
error: e && e.message ? e.message : String(e),
|
| 2285 |
+
});
|
| 2286 |
+
}
|
| 2287 |
+
})();
|
| 2288 |
+
return true;
|
| 2289 |
+
}
|
| 2290 |
+
|
| 2291 |
+
return false;
|
| 2292 |
+
});
|
| 2293 |
+
|
| 2294 |
+
chrome.runtime.onInstalled.addListener(() => {
|
| 2295 |
+
recreateMenus();
|
| 2296 |
+
getApiBase()
|
| 2297 |
+
.then((b) => warmupApi(b))
|
| 2298 |
+
.catch(() => {});
|
| 2299 |
+
});
|
| 2300 |
+
|
| 2301 |
+
chrome.runtime.onStartup?.addListener(() => {
|
| 2302 |
+
recreateMenus();
|
| 2303 |
+
getApiBase()
|
| 2304 |
+
.then((b) => warmupApi(b))
|
| 2305 |
+
.catch(() => {});
|
| 2306 |
+
});
|
| 2307 |
+
|
| 2308 |
+
chrome.storage.local.get({ maxConcurrency: 0 }, ({ maxConcurrency }) => {
|
| 2309 |
+
MAX_CONCURRENCY = Number(maxConcurrency) >= 0 ? Number(maxConcurrency) : 0;
|
| 2310 |
+
const eff = effectiveMaxConcurrency();
|
| 2311 |
+
info(
|
| 2312 |
+
"MAX_CONCURRENCY =",
|
| 2313 |
+
MAX_CONCURRENCY || "unlimited",
|
| 2314 |
+
"soft =",
|
| 2315 |
+
softMaxConcurrency,
|
| 2316 |
+
"forceSoft =",
|
| 2317 |
+
forceSoftMaxConcurrency,
|
| 2318 |
+
"effective =",
|
| 2319 |
+
eff || "unlimited",
|
| 2320 |
+
);
|
| 2321 |
+
});
|
popup.html
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #f5f7fa;
|
| 3 |
+
--card-bg: rgba(255, 255, 255, 0.95);
|
| 4 |
+
--radius: 14px;
|
| 5 |
+
--shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
|
| 6 |
+
--border: 1px solid rgba(0, 0, 0, 0.06);
|
| 7 |
+
--radius-sm: 8px;
|
| 8 |
+
--transition: .25s cubic-bezier(.4, 0, .2, 1);
|
| 9 |
+
--text: #1f2d3a;
|
| 10 |
+
--muted: #6e7a89;
|
| 11 |
+
--focus: #5c9fff;
|
| 12 |
+
--input-bg: #ffffff;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@media (prefers-color-scheme: dark) {
|
| 16 |
+
:root {
|
| 17 |
+
--bg: #0f172a;
|
| 18 |
+
--card-bg: rgba(20, 30, 55, 0.9);
|
| 19 |
+
--text: #eef2f7;
|
| 20 |
+
--muted: #9aa3b1;
|
| 21 |
+
--border: 1px solid rgba(255, 255, 255, 0.07);
|
| 22 |
+
--shadow: 0 30px 60px -10px rgba(0, 0, 0, 0.4);
|
| 23 |
+
--focus: #4d9fff;
|
| 24 |
+
--input-bg: rgba(255, 255, 255, 0.05);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
option {
|
| 28 |
+
color: #000 !important;
|
| 29 |
+
background: #fff !important;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
* {
|
| 34 |
+
box-sizing: border-box;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
html,
|
| 38 |
+
body {
|
| 39 |
+
margin: 0;
|
| 40 |
+
padding: 0;
|
| 41 |
+
background: var(--bg);
|
| 42 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 43 |
+
color: var(--text);
|
| 44 |
+
min-width: 260px;
|
| 45 |
+
max-width: 360px;
|
| 46 |
+
width: auto;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
body {
|
| 50 |
+
padding: 14px 16px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.card {
|
| 54 |
+
background: var(--card-bg);
|
| 55 |
+
border-radius: var(--radius);
|
| 56 |
+
padding: 16px 18px;
|
| 57 |
+
box-shadow: var(--shadow);
|
| 58 |
+
border: var(--border);
|
| 59 |
+
display: flex;
|
| 60 |
+
flex-direction: column;
|
| 61 |
+
gap: 10px;
|
| 62 |
+
transition: background var(--transition), box-shadow var(--transition);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.card h2 {
|
| 66 |
+
margin: 0;
|
| 67 |
+
font-size: 18px;
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
letter-spacing: 0.5px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.field {
|
| 73 |
+
display: flex;
|
| 74 |
+
flex-direction: column;
|
| 75 |
+
gap: 4px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
label {
|
| 79 |
+
font-size: 12px;
|
| 80 |
+
font-weight: 600;
|
| 81 |
+
display: flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
gap: 6px;
|
| 84 |
+
margin-bottom: 2px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
input[type="text"],
|
| 88 |
+
input[type="password"],
|
| 89 |
+
select,
|
| 90 |
+
textarea {
|
| 91 |
+
appearance: none;
|
| 92 |
+
background: var(--input-bg);
|
| 93 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 94 |
+
border-radius: var(--radius-sm);
|
| 95 |
+
padding: 8px 10px;
|
| 96 |
+
font-size: 13px;
|
| 97 |
+
outline: none;
|
| 98 |
+
transition: border var(--transition), box-shadow var(--transition);
|
| 99 |
+
cursor: text;
|
| 100 |
+
width: 100%;
|
| 101 |
+
color: inherit;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
select {
|
| 105 |
+
cursor: pointer;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.label-row {
|
| 109 |
+
justify-content: space-between;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.icon-btn {
|
| 113 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 114 |
+
background: transparent;
|
| 115 |
+
border-radius: 999px;
|
| 116 |
+
width: 22px;
|
| 117 |
+
height: 22px;
|
| 118 |
+
display: inline-flex;
|
| 119 |
+
align-items: center;
|
| 120 |
+
justify-content: center;
|
| 121 |
+
font-size: 13px;
|
| 122 |
+
line-height: 1;
|
| 123 |
+
cursor: pointer;
|
| 124 |
+
padding: 0;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.icon-btn:focus {
|
| 128 |
+
outline: none;
|
| 129 |
+
border-color: var(--focus);
|
| 130 |
+
box-shadow: 0 0 0 3px rgba(92, 159, 255, 0.35);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
input:focus,
|
| 134 |
+
select:focus,
|
| 135 |
+
textarea:focus {
|
| 136 |
+
border-color: var(--focus);
|
| 137 |
+
box-shadow: 0 0 0 3px rgba(92, 159, 255, 0.35);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
textarea {
|
| 141 |
+
resize: vertical;
|
| 142 |
+
min-height: 74px;
|
| 143 |
+
line-height: 1.25;
|
| 144 |
+
font-family: inherit;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.hint {
|
| 148 |
+
font-size: 11px;
|
| 149 |
+
color: var(--muted);
|
| 150 |
+
margin-top: 2px;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.note {
|
| 154 |
+
font-size: 11px;
|
| 155 |
+
color: var(--muted);
|
| 156 |
+
margin-top: 4px;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
label span#api-status-emoji {
|
| 160 |
+
font-size: 1.1em;
|
| 161 |
+
vertical-align: middle;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
#reset-api {
|
| 165 |
+
background: transparent;
|
| 166 |
+
border: none;
|
| 167 |
+
padding: 4px;
|
| 168 |
+
font-size: 16px;
|
| 169 |
+
line-height: 1;
|
| 170 |
+
cursor: pointer;
|
| 171 |
+
color: var(--muted);
|
| 172 |
+
transition: color .2s ease;
|
| 173 |
+
border-radius: 6px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
#reset-api:hover {
|
| 177 |
+
color: var(--text);
|
| 178 |
+
background: rgba(0, 0, 0, 0.05);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
#ai-prompt-reset {
|
| 182 |
+
background: transparent;
|
| 183 |
+
border: none;
|
| 184 |
+
padding: 4px;
|
| 185 |
+
font-size: 16px;
|
| 186 |
+
line-height: 1;
|
| 187 |
+
cursor: pointer;
|
| 188 |
+
color: var(--muted);
|
| 189 |
+
transition: color .2s ease;
|
| 190 |
+
border-radius: 6px;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
#ai-prompt-reset:hover {
|
| 194 |
+
color: var(--text);
|
| 195 |
+
background: rgba(0, 0, 0, 0.05);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
#ai-prompt-count {
|
| 199 |
+
text-align: right;
|
| 200 |
+
font-size: 11px;
|
| 201 |
+
margin-top: 4px;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
@media (max-width: 300px) {
|
| 205 |
+
body {
|
| 206 |
+
padding: 8px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.card {
|
| 210 |
+
padding: 12px;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
h2 {
|
| 214 |
+
font-size: 16px;
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
.run-status {
|
| 218 |
+
font-size: 12px;
|
| 219 |
+
color: var(--text);
|
| 220 |
+
padding: 6px 8px;
|
| 221 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 222 |
+
border-radius: var(--radius-sm);
|
| 223 |
+
background: var(--input-bg);
|
| 224 |
+
overflow: hidden;
|
| 225 |
+
text-overflow: ellipsis;
|
| 226 |
+
white-space: nowrap;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.run-progress {
|
| 230 |
+
height: 8px;
|
| 231 |
+
border-radius: 999px;
|
| 232 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 233 |
+
background: rgba(0, 0, 0, 0.06);
|
| 234 |
+
overflow: hidden;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.run-progress-bar {
|
| 238 |
+
height: 100%;
|
| 239 |
+
background: var(--focus);
|
| 240 |
+
width: 0%;
|
| 241 |
+
transition: width var(--transition);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
.file-actions {
|
| 246 |
+
display: grid;
|
| 247 |
+
grid-template-columns: 1fr 1fr;
|
| 248 |
+
gap: 8px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.action-btn {
|
| 252 |
+
appearance: none;
|
| 253 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 254 |
+
border-radius: var(--radius-sm);
|
| 255 |
+
background: var(--input-bg);
|
| 256 |
+
color: inherit;
|
| 257 |
+
padding: 8px 10px;
|
| 258 |
+
font-size: 13px;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
transition: border var(--transition), box-shadow var(--transition), background var(--transition);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.action-btn.secondary {
|
| 265 |
+
font-weight: 500;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.action-btn:hover {
|
| 269 |
+
border-color: rgba(0, 0, 0, 0.2);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.action-btn:focus {
|
| 273 |
+
outline: none;
|
| 274 |
+
border-color: var(--focus);
|
| 275 |
+
box-shadow: 0 0 0 3px rgba(92, 159, 255, 0.35);
|
| 276 |
+
}
|
popup.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>TextPhantom OCR</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 7 |
+
<link rel="stylesheet" href="popup.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="card">
|
| 11 |
+
<h2>TextPhantom OCR</h2>
|
| 12 |
+
|
| 13 |
+
<div class="field">
|
| 14 |
+
<label for="mode">Mode</label>
|
| 15 |
+
<select id="mode" aria-label="Mode"></select>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="field" id="lang-wrap">
|
| 19 |
+
<label for="lang">Language</label>
|
| 20 |
+
<select id="lang" aria-label="Language"></select>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div class="field" id="sources-wrap" style="display: none">
|
| 24 |
+
<label for="sources">Sources</label>
|
| 25 |
+
<select id="sources" aria-label="Sources"></select>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="field" id="ai-key-wrap">
|
| 29 |
+
<label for="ai-key">AI Key (optional)</label>
|
| 30 |
+
<input
|
| 31 |
+
type="password"
|
| 32 |
+
id="ai-key"
|
| 33 |
+
placeholder="Paste your AI_API_KEY (Gemini/OpenRouter/HF/Groq/etc.)"
|
| 34 |
+
/>
|
| 35 |
+
<div class="hint">
|
| 36 |
+
If empty, the extension will use translated overlay (no AI).
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="field" id="ai-model-wrap" style="display: none">
|
| 41 |
+
<label for="ai-model">AI Model (optional)</label>
|
| 42 |
+
<select id="ai-model" aria-label="AI Model"></select>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="field" id="ai-prompt-wrap" style="display: none">
|
| 46 |
+
<label for="ai-prompt" class="label-row">
|
| 47 |
+
<span>AI Style (optional)</span>
|
| 48 |
+
<button
|
| 49 |
+
type="button"
|
| 50 |
+
id="ai-prompt-reset"
|
| 51 |
+
class="icon-btn"
|
| 52 |
+
title="Reset style for this language"
|
| 53 |
+
aria-label="Reset style for this language"
|
| 54 |
+
style="
|
| 55 |
+
background: none;
|
| 56 |
+
border: none;
|
| 57 |
+
padding: 4px;
|
| 58 |
+
font-size: 16px;
|
| 59 |
+
line-height: 1;
|
| 60 |
+
cursor: pointer;
|
| 61 |
+
"
|
| 62 |
+
>
|
| 63 |
+
⟲
|
| 64 |
+
</button>
|
| 65 |
+
</label>
|
| 66 |
+
<textarea
|
| 67 |
+
id="ai-prompt"
|
| 68 |
+
rows="4"
|
| 69 |
+
placeholder="Your translation style notes. Leave empty to use language defaults."
|
| 70 |
+
></textarea>
|
| 71 |
+
<div id="ai-prompt-count" class="prompt-count" aria-live="polite"></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
<div class="field">
|
| 76 |
+
<label>Local Viewer</label>
|
| 77 |
+
<div class="file-actions">
|
| 78 |
+
<button type="button" id="open-local-images" class="action-btn">
|
| 79 |
+
Open image(s)
|
| 80 |
+
</button>
|
| 81 |
+
<button type="button" id="open-local-folder" class="action-btn secondary">
|
| 82 |
+
Open folder
|
| 83 |
+
</button>
|
| 84 |
+
</div>
|
| 85 |
+
<div class="hint">
|
| 86 |
+
Open local manga pages in a reader tab, then right-click an image to translate.
|
| 87 |
+
</div>
|
| 88 |
+
<input id="local-images-input" type="file" accept="image/*" multiple hidden />
|
| 89 |
+
<input id="local-folder-input" type="file" accept="image/*" multiple webkitdirectory hidden />
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="field">
|
| 93 |
+
<label for="api-url">
|
| 94 |
+
Custom API URL
|
| 95 |
+
<span
|
| 96 |
+
id="api-status-emoji"
|
| 97 |
+
aria-live="polite"
|
| 98 |
+
title="Loading…"
|
| 99 |
+
style="margin-left: 6px"
|
| 100 |
+
>⏳</span
|
| 101 |
+
>
|
| 102 |
+
</label>
|
| 103 |
+
<div
|
| 104 |
+
style="
|
| 105 |
+
position: relative;
|
| 106 |
+
display: flex;
|
| 107 |
+
align-items: center;
|
| 108 |
+
gap: 4px;
|
| 109 |
+
"
|
| 110 |
+
>
|
| 111 |
+
<input
|
| 112 |
+
type="text"
|
| 113 |
+
id="api-url"
|
| 114 |
+
placeholder="https://plan291037-api-ocr-test.hf.space"
|
| 115 |
+
/>
|
| 116 |
+
<button
|
| 117 |
+
id="reset-api"
|
| 118 |
+
aria-label="Reset to local"
|
| 119 |
+
title="Reset to local API"
|
| 120 |
+
style="
|
| 121 |
+
background: none;
|
| 122 |
+
border: none;
|
| 123 |
+
padding: 4px;
|
| 124 |
+
font-size: 16px;
|
| 125 |
+
line-height: 1;
|
| 126 |
+
cursor: pointer;
|
| 127 |
+
"
|
| 128 |
+
>
|
| 129 |
+
⟲
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div class="field" id="run-status-wrap">
|
| 135 |
+
<label for="run-status-text">Run Status</label>
|
| 136 |
+
<div id="run-status-text" class="run-status" aria-live="polite">
|
| 137 |
+
Idle
|
| 138 |
+
</div>
|
| 139 |
+
<div
|
| 140 |
+
class="run-progress"
|
| 141 |
+
role="progressbar"
|
| 142 |
+
aria-valuemin="0"
|
| 143 |
+
aria-valuemax="100"
|
| 144 |
+
aria-valuenow="0"
|
| 145 |
+
>
|
| 146 |
+
<div
|
| 147 |
+
id="run-progress-bar"
|
| 148 |
+
class="run-progress-bar"
|
| 149 |
+
style="width: 0%"
|
| 150 |
+
></div>
|
| 151 |
+
</div>
|
| 152 |
+
<div id="run-status-sub" class="hint"></div>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="note">Right-click an image to translate.</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<script type="module" src="popup.js"></script>
|
| 158 |
+
</body>
|
| 159 |
+
</html>
|
prompt.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const DB_NAME = "textphantom_local_gallery";
|
| 2 |
+
const DB_VERSION = 1;
|
| 3 |
+
const STORE_NAME = "sessions";
|
| 4 |
+
const IMAGE_PREFIX = "image/";
|
| 5 |
+
|
| 6 |
+
let dbPromise = null;
|
| 7 |
+
|
| 8 |
+
function openDb() {
|
| 9 |
+
if (dbPromise) return dbPromise;
|
| 10 |
+
dbPromise = new Promise((resolve, reject) => {
|
| 11 |
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
| 12 |
+
req.onupgradeneeded = () => {
|
| 13 |
+
const db = req.result;
|
| 14 |
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
| 15 |
+
db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
req.onsuccess = () => resolve(req.result);
|
| 19 |
+
req.onerror = () => reject(req.error || new Error("indexeddb_open_failed"));
|
| 20 |
+
});
|
| 21 |
+
return dbPromise;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function txComplete(tx) {
|
| 25 |
+
return new Promise((resolve, reject) => {
|
| 26 |
+
tx.oncomplete = () => resolve();
|
| 27 |
+
tx.onabort = () => reject(tx.error || new Error("indexeddb_tx_aborted"));
|
| 28 |
+
tx.onerror = () => reject(tx.error || new Error("indexeddb_tx_failed"));
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function trimString(v) {
|
| 33 |
+
return String(v || "").trim();
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function naturalCompare(a, b) {
|
| 37 |
+
return new Intl.Collator(undefined, {
|
| 38 |
+
numeric: true,
|
| 39 |
+
sensitivity: "base",
|
| 40 |
+
}).compare(String(a || ""), String(b || ""));
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function sortLocalPages(pages) {
|
| 44 |
+
return [...(Array.isArray(pages) ? pages : [])].sort((a, b) => {
|
| 45 |
+
const pa = trimString(a?.relativePath || a?.name || a?.id);
|
| 46 |
+
const pb = trimString(b?.relativePath || b?.name || b?.id);
|
| 47 |
+
const byPath = naturalCompare(pa, pb);
|
| 48 |
+
if (byPath) return byPath;
|
| 49 |
+
return naturalCompare(a?.id, b?.id);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function toLocalPageRecord(file, index = 0) {
|
| 54 |
+
const name = trimString(file?.name) || `image-${index + 1}`;
|
| 55 |
+
const type = trimString(file?.type) || "application/octet-stream";
|
| 56 |
+
const relativePath = trimString(file?.webkitRelativePath || "");
|
| 57 |
+
return {
|
| 58 |
+
id: crypto.randomUUID(),
|
| 59 |
+
name,
|
| 60 |
+
relativePath,
|
| 61 |
+
type,
|
| 62 |
+
size: Number(file?.size) || 0,
|
| 63 |
+
lastModified: Number(file?.lastModified) || 0,
|
| 64 |
+
blob: file,
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function filterImageFiles(files) {
|
| 69 |
+
return [...(files || [])].filter((file) => {
|
| 70 |
+
const type = trimString(file?.type).toLowerCase();
|
| 71 |
+
return type.startsWith(IMAGE_PREFIX);
|
| 72 |
+
});
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export async function saveLocalSession(session) {
|
| 76 |
+
const db = await openDb();
|
| 77 |
+
const record = {
|
| 78 |
+
id: trimString(session?.id) || crypto.randomUUID(),
|
| 79 |
+
createdAt: Number(session?.createdAt) || Date.now(),
|
| 80 |
+
title: trimString(session?.title),
|
| 81 |
+
pages: Array.isArray(session?.pages) ? [...session.pages] : [],
|
| 82 |
+
};
|
| 83 |
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
| 84 |
+
tx.objectStore(STORE_NAME).put(record);
|
| 85 |
+
await txComplete(tx);
|
| 86 |
+
return record;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export async function loadLocalSession(id) {
|
| 90 |
+
const key = trimString(id);
|
| 91 |
+
if (!key) return null;
|
| 92 |
+
const db = await openDb();
|
| 93 |
+
return await new Promise((resolve, reject) => {
|
| 94 |
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
| 95 |
+
const req = tx.objectStore(STORE_NAME).get(key);
|
| 96 |
+
req.onsuccess = () => resolve(req.result || null);
|
| 97 |
+
req.onerror = () => reject(req.error || new Error("indexeddb_get_failed"));
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export async function deleteLocalSession(id) {
|
| 102 |
+
const key = trimString(id);
|
| 103 |
+
if (!key) return;
|
| 104 |
+
const db = await openDb();
|
| 105 |
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
| 106 |
+
tx.objectStore(STORE_NAME).delete(key);
|
| 107 |
+
await txComplete(tx);
|
| 108 |
+
}
|
requirements.txt
CHANGED
|
@@ -1,7 +1,31 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: TextPhantom OCR API
|
| 3 |
+
emoji: "🪄"
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
FastAPI backend for TextPhantom (Chrome extension).
|
| 12 |
+
|
| 13 |
+
## Endpoints
|
| 14 |
+
|
| 15 |
+
- `GET /health`
|
| 16 |
+
- `GET /version`
|
| 17 |
+
- `GET /warmup?lang=th`
|
| 18 |
+
- `POST /translate`
|
| 19 |
+
- `GET /translate/{job_id}`
|
| 20 |
+
- `POST /ai/resolve`
|
| 21 |
+
- `GET /ai/models`
|
| 22 |
+
- `POST /ws` (WebSocket)
|
| 23 |
+
|
| 24 |
+
## Environment
|
| 25 |
+
|
| 26 |
+
- `AI_API_KEY` (optional)
|
| 27 |
+
- `SERVER_MAX_WORKERS` (default: 15)
|
| 28 |
+
- `JOB_TTL_SEC` (default: 3600)
|
| 29 |
+
- `TP_DEBUG` (set to `1` to enable debug logs)
|
| 30 |
+
|
| 31 |
+
For Spaces on small CPU, setting `SERVER_MAX_WORKERS=2` is usually enough.
|
server.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update \
|
| 6 |
+
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt ./requirements.txt
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY backend ./backend
|
| 13 |
+
|
| 14 |
+
ENV PORT=7860
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
CMD ["bash", "-lc", "uvicorn backend.server:app --host 0.0.0.0 --port ${PORT}"]
|
settings_defaults.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
| 2 |
+
let CURRENT_LEVEL_NAME = "debug";
|
| 3 |
+
|
| 4 |
+
try {
|
| 5 |
+
if (typeof window !== "undefined" && window.LOG_LEVEL)
|
| 6 |
+
CURRENT_LEVEL_NAME = String(window.LOG_LEVEL).toLowerCase();
|
| 7 |
+
} catch {}
|
| 8 |
+
|
| 9 |
+
const CURRENT_LEVEL = LEVELS[CURRENT_LEVEL_NAME] ?? 0;
|
| 10 |
+
|
| 11 |
+
function formatTimestamp() {
|
| 12 |
+
return new Date().toISOString();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function safeSerialize(v) {
|
| 16 |
+
try {
|
| 17 |
+
if (typeof v === "string") {
|
| 18 |
+
return v.length > 500 ? v.slice(0, 500) + "…" : v;
|
| 19 |
+
}
|
| 20 |
+
return v;
|
| 21 |
+
} catch {
|
| 22 |
+
return v;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function createLogger(namespace) {
|
| 27 |
+
function log(level, ...args) {
|
| 28 |
+
if (LEVELS[level] >= CURRENT_LEVEL) {
|
| 29 |
+
const prefix = `[${formatTimestamp()}][${namespace}][${level.toUpperCase()}]`;
|
| 30 |
+
const out = args.map(safeSerialize);
|
| 31 |
+
if (level === "debug") console.debug(prefix, ...out);
|
| 32 |
+
else if (level === "info") console.info(prefix, ...out);
|
| 33 |
+
else if (level === "warn") console.warn(prefix, ...out);
|
| 34 |
+
else if (level === "error") console.error(prefix, ...out);
|
| 35 |
+
else console.log(prefix, ...out);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
return {
|
| 39 |
+
debug: (...a) => log("debug", ...a),
|
| 40 |
+
info: (...a) => log("info", ...a),
|
| 41 |
+
warn: (...a) => log("warn", ...a),
|
| 42 |
+
error: (...a) => log("error", ...a),
|
| 43 |
+
};
|
| 44 |
+
}
|
url.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const AI_PROMPT_MAX_CHARS = 1800;
|
| 2 |
+
|
| 3 |
+
export function normalizeAiModel(model) {
|
| 4 |
+
const m = String(model || '').trim();
|
| 5 |
+
return m ? m : 'auto';
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function makePromptKey(lang, model) {
|
| 9 |
+
const l = String(lang || '').trim() || 'en';
|
| 10 |
+
const m = normalizeAiModel(model);
|
| 11 |
+
return `${l}::${m}`;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function normalizePrompt(text, maxChars = AI_PROMPT_MAX_CHARS) {
|
| 15 |
+
let s = String(text ?? '');
|
| 16 |
+
s = s.replace(/\r\n?/g, '\n').trim();
|
| 17 |
+
if (s.length > maxChars) s = s.slice(0, maxChars).trimEnd();
|
| 18 |
+
return s;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function migratePromptMap(input) {
|
| 22 |
+
if (!input || typeof input !== 'object') return { map: {}, changed: false };
|
| 23 |
+
const out = {};
|
| 24 |
+
let changed = false;
|
| 25 |
+
for (const [k, v] of Object.entries(input)) {
|
| 26 |
+
if (typeof v !== 'string') continue;
|
| 27 |
+
const raw = v;
|
| 28 |
+
const val = normalizePrompt(raw);
|
| 29 |
+
if (!val && !raw) continue;
|
| 30 |
+
if (k.includes('::')) {
|
| 31 |
+
out[k] = val;
|
| 32 |
+
if (val !== raw) changed = true;
|
| 33 |
+
continue;
|
| 34 |
+
}
|
| 35 |
+
const nk = makePromptKey(k, 'auto');
|
| 36 |
+
out[nk] = val;
|
| 37 |
+
changed = true;
|
| 38 |
+
}
|
| 39 |
+
return { map: out, changed };
|
| 40 |
+
}
|
viewer.css
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEFAULT_MODE = "lens_text";
|
| 2 |
+
export const DEFAULT_LANG = "en";
|
| 3 |
+
export const DEFAULT_SOURCE = "translated";
|
| 4 |
+
|
| 5 |
+
export function normalizeMode(value) {
|
| 6 |
+
const v = String(value || "").trim();
|
| 7 |
+
return v === "lens_images" || v === "lens_text" ? v : DEFAULT_MODE;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function normalizeLang(value) {
|
| 11 |
+
return String(value || "").trim() || DEFAULT_LANG;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function normalizeSource(value) {
|
| 15 |
+
const v = String(value || "").trim().toLowerCase();
|
| 16 |
+
return v || DEFAULT_SOURCE;
|
| 17 |
+
}
|
viewer.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function normalizeUrl(raw) {
|
| 2 |
+
let url = (raw || "").trim();
|
| 3 |
+
if (!url) return "";
|
| 4 |
+
if (!/^https?:\/\//i.test(url)) url = "http://" + url;
|
| 5 |
+
|
| 6 |
+
try {
|
| 7 |
+
const cleaned = url.replace(/\/+$/, "");
|
| 8 |
+
const u = new URL(cleaned);
|
| 9 |
+
if (u.hostname === "0.0.0.0" || u.hostname === "127.0.0.1") {
|
| 10 |
+
u.hostname = "localhost";
|
| 11 |
+
} else if (u.hostname === "[::1]") {
|
| 12 |
+
u.hostname = "localhost";
|
| 13 |
+
}
|
| 14 |
+
return u.toString().replace(/\/+$/, "");
|
| 15 |
+
} catch {
|
| 16 |
+
return url.replace(/\/+$/, "");
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function toWs(httpBase) {
|
| 21 |
+
const base = normalizeUrl(httpBase);
|
| 22 |
+
if (!base) return "";
|
| 23 |
+
return base.replace(/^http/i, "ws") + "/ws";
|
| 24 |
+
}
|