Invalid JSON:Unexpected token 'i', "import { c"... is not valid JSON
| import { createLogger } from "./shared/logger.js"; | |
| import { normalizeUrl, toWs } from "./shared/url.js"; | |
| import { ensureApiDefaults } from "./shared/api_defaults.js"; | |
| import { makePromptKey, migratePromptMap, normalizeAiModel, normalizePrompt } from "./shared/prompt.js"; | |
| const { debug: log, info, warn, error } = createLogger("LensSW"); | |
| ensureApiDefaults().catch(() => {}); | |
| const t0 = () => | |
| typeof performance !== "undefined" && performance.now | |
| ? performance.now() | |
| : Date.now(); | |
| const fmtMs = (ms) => ms.toFixed(1) + "ms"; | |
| function ev(tag, obj = {}) { | |
| info(`[EV] ${tag}`, obj); | |
| } | |
| function evWarn(tag, obj = {}) { | |
| warn(`[EV] ${tag}`, obj); | |
| } | |
| const PREFLIGHT_TIMEOUT_MS = 10000; | |
| const WS_OPEN_TIMEOUT_MS = 20000; | |
| const WS_RETRIES = 6; | |
| const MAX_FIRST_TRY_RETRIES = 2; | |
| const FIRST_TRY_GAP_MS = 3000; | |
| const WARMUP_PATH = "/warmup"; | |
| const WARMUP_TIMEOUT_MS = 2500; | |
| const WARMUP_TTL_MS = 20 * 60 * 1000; | |
| const SOFT_MAX_CONCURRENCY_DEFAULT = 15; | |
| let MAX_CONCURRENCY = 10; | |
| let softMaxConcurrency = SOFT_MAX_CONCURRENCY_DEFAULT; | |
| let forceSoftMaxConcurrency = false; | |
| let ws = null; | |
| let wsReady = false; | |
| let wsStatus = "idle"; | |
| let currentBase = null; | |
| let currentBatchId = null; | |
| let blockSendsBecauseWsEnded = false; | |
| let running = 0; | |
| const queue = []; | |
| const pendingByJob = new Map(); | |
| const pendingByImage = new Map(); | |
| function shouldPreferRest(base, mode, source) { | |
| const m = String(mode || "").trim(); | |
| if (m === "lens_images") return true; | |
| if (m === "lens_text") return true; | |
| try { | |
| const host = new URL(String(base || "")).hostname.toLowerCase(); | |
| if (!host) return true; | |
| if (host.endsWith(".hf.space")) return true; | |
| if (host.endsWith("huggingface.co")) return true; | |
| return true; | |
| } catch { | |
| return true; | |
| } | |
| } | |
| function effectiveMaxConcurrency() { | |
| const soft = | |
| Number(softMaxConcurrency) > 0 | |
| ? Number(softMaxConcurrency) | |
| : SOFT_MAX_CONCURRENCY_DEFAULT; | |
| const n = Number(MAX_CONCURRENCY) || 0; | |
| if (forceSoftMaxConcurrency) { | |
| if (n > 0 && n < soft) return n; | |
| return soft; | |
| } | |
| return n; | |
| } | |
| function aiSoftMaxConcurrencyFromKey(key) { | |
| const k = String(key || "").trim(); | |
| if (k.startsWith("hf_")) return 3; | |
| return 10; | |
| } | |
| function statusTextFor(code, fallback = "") { | |
| if (code === 200) return "OK"; | |
| if (code === 201) return "Created"; | |
| if (code === 202) return "Accepted"; | |
| if (code === 204) return "No Content"; | |
| if (code === 400) return "Bad Request"; | |
| if (code === 401) return "Unauthorized"; | |
| if (code === 403) return "Forbidden"; | |
| if (code === 404) return "Not Found"; | |
| if (code === 408) return "Request Timeout"; | |
| if (code === 409) return "Conflict"; | |
| if (code === 413) return "Payload Too Large"; | |
| if (code === 415) return "Unsupported Media Type"; | |
| if (code === 429) return "Too Many Requests"; | |
| if (code === 500) return "Internal Server Error"; | |
| if (code === 502) return "Bad Gateway"; | |
| if (code === 503) return "Service Unavailable"; | |
| if (code === 504) return "Gateway Timeout"; | |
| return fallback || ""; | |
| } | |
| function logImageRestAccessLine(base, fullUrl, statusCode, statusText = "") { | |
| try { | |
| const b = new URL(String(base || "")); | |
| const u = new URL(String(fullUrl || "")); | |
| const host = b.hostname === "localhost" ? "127.0.0.1" : b.hostname; | |
| const st = statusTextFor(Number(statusCode) || 0, statusText); | |
| info(`${host} - "GET ${u.pathname} HTTP/1.1" ${statusCode} ${st}`); | |
| } catch {} | |
| } | |
| const BATCH_TOAST_MIN_INTERVAL_MS = 350; | |
| const BATCH_RETRY_GAP_MS = 1800; | |
| const BATCH_TTL_MS = 20 * 60 * 1000; | |
| const batches = new Map(); | |
| let lastBatchStatus = null; | |
| function normImgSrc(src) { | |
| const s = String(src || "").trim(); | |
| if (!s) return ""; | |
| try { | |
| const u = new URL(s); | |
| u.hash = ""; | |
| return u.toString(); | |
| } catch { | |
| return s; | |
| } | |
| } | |
| function imageKeyFromPayload(payload) { | |
| const id = String(payload?.metadata?.image_id || "").trim(); | |
| if (id) return id; | |
| return normImgSrc(payload?.src); | |
| } | |
| function ensureBatch(batchId, tabId, frameId) { | |
| const id = String(batchId || ""); | |
| if (!id) return null; | |
| const now = Date.now(); | |
| let b = batches.get(id); | |
| if (!b) { | |
| b = { | |
| id, | |
| tabId: Number.isFinite(tabId) ? tabId : 0, | |
| frameId: Number(frameId) || 0, | |
| createdAt: now, | |
| pass: 1, | |
| total1: 0, | |
| total2: 0, | |
| lastToastTs: 0, | |
| retryScheduled: false, | |
| items: new Map(), | |
| }; | |
| batches.set(id, b); | |
| } else { | |
| if (Number.isFinite(tabId)) b.tabId = tabId; | |
| if (Number.isFinite(frameId)) b.frameId = Number(frameId) || 0; | |
| } | |
| return b; | |
| } | |
| function pruneBatches(now = Date.now()) { | |
| for (const [id, b] of batches.entries()) { | |
| if (!b || now - (b.createdAt || now) > BATCH_TTL_MS) batches.delete(id); | |
| } | |
| } | |
| function batchPassTotal(b) { | |
| if (!b) return 0; | |
| return b.pass === 2 ? Number(b.total2) || 0 : Number(b.total1) || 0; | |
| } | |
| function batchPassStats(b) { | |
| const pass = b?.pass || 1; | |
| const total = batchPassTotal(b); | |
| let queued = 0; | |
| let processing = 0; | |
| let inserting = 0; | |
| let done = 0; | |
| let error = 0; | |
| let aborted = 0; | |
| for (const it of b?.items?.values?.() || []) { | |
| if (!it || it.attempt !== pass) continue; | |
| const st = it.status; | |
| if (st === "queued") queued++; | |
| else if (st === "processing") processing++; | |
| else if (st === "inserting") inserting++; | |
| else if (st === "done") done++; | |
| else if (st === "error") error++; | |
| else if (st === "aborted") aborted++; | |
| } | |
| const finished = done + error + aborted; | |
| return { | |
| pass, | |
| total, | |
| queued, | |
| processing, | |
| inserting, | |
| done, | |
| error, | |
| aborted, | |
| finished, | |
| }; | |
| } | |
| function batchToast(b, text, ms = 2000, force = false) { | |
| if (!b || !b.tabId || !text) return; | |
| const now = Date.now(); | |
| if (!force && now - (b.lastToastTs || 0) < BATCH_TOAST_MIN_INTERVAL_MS) | |
| return; | |
| b.lastToastTs = now; | |
| sendToastToTab(b.tabId, b.frameId || 0, text, ms); | |
| } | |
| function batchUpdateToast(b, stage, force = false) { | |
| if (!b) return; | |
| pruneBatches(); | |
| const s = batchPassStats(b); | |
| const total = s.total; | |
| const head = b.pass === 2 ? "TextPhantom: รอบแก้ไข" : "TextPhantom:"; | |
| const parts = []; | |
| if (total) parts.push(`รับภาพ ${total}`); | |
| if (s.processing || s.inserting || s.queued) | |
| parts.push(`ประมวลผล ${s.processing + s.inserting}/${total}`); | |
| if (s.done) parts.push(`แทรกกลับ ${s.done}/${total}`); | |
| if (s.error) parts.push(`ผิดพลาด ${s.error}`); | |
| if (s.aborted) parts.push(`ยกเลิก ${s.aborted}`); | |
| const extra = stage ? `• ${stage}` : ""; | |
| const msg = `${head} ${parts.join(" | ")} ${extra}`.trim(); | |
| const ms = s.finished >= total && total ? 2400 : 60000; | |
| batchToast(b, msg, ms, force); | |
| lastBatchStatus = { | |
| id: b.id, | |
| tabId: b.tabId || 0, | |
| frameId: b.frameId || 0, | |
| pass: s.pass, | |
| stage: String(stage || ""), | |
| message: msg, | |
| stats: s, | |
| ts: Date.now(), | |
| }; | |
| try { | |
| chrome.runtime.sendMessage( | |
| { type: "BATCH_STATUS_UPDATE", batch: lastBatchStatus }, | |
| () => void chrome.runtime.lastError, | |
| ); | |
| } catch {} | |
| } | |
| function batchMark(batchId, imageKey, patch) { | |
| const b = batches.get(String(batchId || "")); | |
| if (!b) return null; | |
| const k = String(imageKey || "").trim(); | |
| if (!k) return b; | |
| const cur = b.items.get(k); | |
| if (!cur) return b; | |
| b.items.set(k, { ...cur, ...patch }); | |
| return b; | |
| } | |
| async function batchStopKeepAlive(b) { | |
| if (!b?.tabId) return; | |
| try { | |
| await sendToTab(b.tabId, { type: "TP_KEEPALIVE_STOP" }, b.frameId || 0); | |
| } catch {} | |
| } | |
| function batchFinalizeIfComplete(b) { | |
| if (!b) return; | |
| const s = batchPassStats(b); | |
| if (!s.total || s.finished < s.total) return; | |
| if (b.pass === 1) { | |
| if (b.retryScheduled) return; | |
| const failed = []; | |
| let permanentErr = 0; | |
| for (const [k, it] of b.items.entries()) { | |
| if (it?.attempt !== 1 || it.status !== "error") continue; | |
| if (it?.permanent) { | |
| permanentErr++; | |
| continue; | |
| } | |
| failed.push(k); | |
| } | |
| if (!failed.length) { | |
| const msg = permanentErr | |
| ? `เสร็จสิ้น (ผิดพลาดถาวร ${permanentErr})` | |
| : "เสร็จสิ้น"; | |
| batchUpdateToast(b, msg, true); | |
| batchStopKeepAlive(b); | |
| return; | |
| } | |
| b.retryScheduled = true; | |
| b.pass = 2; | |
| b.total2 = failed.length; | |
| for (const k of failed) { | |
| const it = b.items.get(k); | |
| if (!it) continue; | |
| const base = it.payload || null; | |
| const meta = | |
| base?.metadata && typeof base.metadata === "object" | |
| ? base.metadata | |
| : {}; | |
| const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; | |
| const nextPayload = { | |
| ...base, | |
| metadata: { | |
| ...meta, | |
| pipeline: pipe.concat({ | |
| stage: "retry_failed_once", | |
| at: new Date().toISOString(), | |
| }), | |
| timestamp: new Date().toISOString(), | |
| }, | |
| }; | |
| b.items.set(k, { | |
| ...it, | |
| payload: nextPayload, | |
| attempt: 2, | |
| status: "queued", | |
| }); | |
| } | |
| batchUpdateToast( | |
| b, | |
| `พักก่อน แล้วลองแก้ไขอีกครั้ง ${failed.length} ภาพ`, | |
| true, | |
| ); | |
| addTask(async () => { | |
| await new Promise((r) => setTimeout(r, BATCH_RETRY_GAP_MS)); | |
| const again = []; | |
| for (const it of b.items.values()) { | |
| if (it?.attempt === 2 && it.status === "queued" && it.payload) | |
| again.push(it.payload); | |
| } | |
| batchUpdateToast(b, "เริ่มรอบแก้ไข", true); | |
| for (const pl of again) { | |
| let nextPayload = pl; | |
| let skip = false; | |
| try { | |
| const src = String(pl?.src || "").trim(); | |
| if (src && /^https?:/i.test(src) && !pl?.imageDataUri) { | |
| const pageUrl = pl?.context?.page_url || ""; | |
| const key = normImgSrc(src); | |
| pruneMdDataUriCache(); | |
| const cached = key ? mdDataUriCache.get(key) : null; | |
| const du = | |
| cached?.du && | |
| Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS | |
| ? cached.du | |
| : await fetchImageDataUriFromUrl(src, pageUrl); | |
| if (du) { | |
| const meta = | |
| pl?.metadata && typeof pl.metadata === "object" | |
| ? pl.metadata | |
| : {}; | |
| const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; | |
| nextPayload = { | |
| ...pl, | |
| imageDataUri: du, | |
| metadata: { | |
| ...meta, | |
| pipeline: pipe.concat({ | |
| stage: "retry_attach_datauri", | |
| at: new Date().toISOString(), | |
| }), | |
| timestamp: new Date().toISOString(), | |
| }, | |
| }; | |
| if (key && !cached?.du) | |
| mdDataUriCache.set(key, { du, ts: Date.now() }); | |
| const k = imageKeyFromPayload(nextPayload); | |
| if (k && b.items.has(k)) { | |
| const it = b.items.get(k); | |
| b.items.set(k, { ...it, payload: nextPayload }); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| const msg = String(e?.message || e); | |
| const cls = classifyJobError(msg); | |
| const k = imageKeyFromPayload(pl); | |
| if (k && b.items.has(k)) { | |
| const it = b.items.get(k); | |
| b.items.set(k, { | |
| ...it, | |
| lastError: msg, | |
| status: cls?.permanent ? "error" : it.status, | |
| permanent: !!cls?.permanent, | |
| }); | |
| if (cls?.permanent) { | |
| batchUpdateToast(b, "ผิดพลาด (ถาวร)"); | |
| batchFinalizeIfComplete(b); | |
| } | |
| } | |
| if (cls?.permanent) skip = true; | |
| } | |
| if (!skip) enqueue(nextPayload, b.tabId, b.frameId || 0); | |
| } | |
| }); | |
| return; | |
| } | |
| if (b.pass === 2) { | |
| const s2 = batchPassStats(b); | |
| const msg = | |
| s2.error > 0 ? `เสร็จสิ้น (ยังผิดพลาด ${s2.error})` : "เสร็จสิ้น"; | |
| batchUpdateToast(b, msg, true); | |
| batchStopKeepAlive(b); | |
| } | |
| return; | |
| } | |
| let settingsEpoch = 0; | |
| chrome.storage.onChanged.addListener((changes, areaName) => { | |
| if (areaName !== "local" || !changes) return; | |
| if (!changes.mode && !changes.lang && !changes.sources) return; | |
| settingsEpoch = (settingsEpoch + 1) >>> 0; | |
| }); | |
| const tabSessionById = new Map(); | |
| function bumpTabSession(tabId, href) { | |
| if (!Number.isFinite(tabId)) return ""; | |
| const id = crypto.randomUUID(); | |
| tabSessionById.set(tabId, { id, href: String(href || ""), ts: Date.now() }); | |
| return id; | |
| } | |
| function getTabSessionId(tabId) { | |
| return tabSessionById.get(tabId)?.id || ""; | |
| } | |
| function getTabSession(tabId) { | |
| return tabSessionById.get(tabId) || null; | |
| } | |
| function ensureTabSession(tabId, href) { | |
| const cur = getTabSession(tabId); | |
| const h = String(href || ""); | |
| if (!cur?.id) return bumpTabSession(tabId, h); | |
| if (h && String(cur.href || "") !== h) { | |
| tabSessionById.set(tabId, { ...cur, href: h, ts: Date.now() }); | |
| } | |
| return cur.id; | |
| } | |
| function isMangaDexPageUrl(u) { | |
| try { | |
| const host = new URL(String(u || "")).hostname.toLowerCase(); | |
| return host === "mangadex.org" || host.endsWith(".mangadex.org"); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function mdChapterIdFromUrl(u) { | |
| try { | |
| const url = new URL(String(u || "")); | |
| const m = String(url.pathname || "").match(/\/chapter\/([a-f0-9-]{8,})/i); | |
| return m ? String(m[1] || "") : ""; | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function isSameMangaDexChapter(a, b) { | |
| const ca = mdChapterIdFromUrl(a); | |
| const cb = mdChapterIdFromUrl(b); | |
| return !!ca && !!cb && ca === cb; | |
| } | |
| function cancelTabWork(tabId, reason = "navigation") { | |
| if (!Number.isFinite(tabId)) return; | |
| const msg = String(reason || "navigation"); | |
| for (const [jid, ctx] of Array.from(pendingByJob.entries())) { | |
| if ((ctx?.tabId || 0) !== tabId) continue; | |
| const batchId = String( | |
| ctx?.batchId || ctx?.metadata?.batch_id || "", | |
| ).trim(); | |
| const imageKey = String( | |
| ctx?.imageKey || ctx?.metadata?.image_id || "", | |
| ).trim(); | |
| const batch = batchId | |
| ? ensureBatch(batchId, tabId, ctx?.frameId || 0) | |
| : null; | |
| pendingByJob.delete(jid); | |
| if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id); | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { status: "aborted", lastError: msg }); | |
| batchUpdateToast(batch, "ยกเลิก"); | |
| batchFinalizeIfComplete(batch); | |
| batchStopKeepAlive(batch); | |
| } | |
| } | |
| for (const [iid, rec] of Array.from(pendingByImage.entries())) { | |
| if ((rec?.tabId || 0) === tabId) pendingByImage.delete(iid); | |
| } | |
| for (const b of Array.from(batches.values())) { | |
| if ((b?.tabId || 0) !== tabId) continue; | |
| batchStopKeepAlive(b); | |
| } | |
| } | |
| const MD_CACHE_TTL_MS = 15 * 60 * 1000; | |
| const mdCacheByKey = new Map(); | |
| const MD_DATAURI_CACHE_TTL_MS = 15 * 60 * 1000; | |
| const MD_DATAURI_CACHE_MAX = 80; | |
| const mdDataUriCache = new Map(); | |
| const API_BASE_FALLBACK = ""; | |
| const KEEPALIVE_PORT_NAME = "TP_KEEPALIVE"; | |
| const keepAlivePorts = new Set(); | |
| function bytesToBase64(bytes) { | |
| let binary = ""; | |
| const chunk = 0x8000; | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); | |
| } | |
| return btoa(binary); | |
| } | |
| async function blobToDataUri(blob, mimeOverride) { | |
| const ab = await blob.arrayBuffer(); | |
| const mime = String(mimeOverride || blob.type || "application/octet-stream"); | |
| const b64 = bytesToBase64(new Uint8Array(ab)); | |
| return `data:${mime};base64,${b64}`; | |
| } | |
| async function fetchImageDataUriFromUrl(url, pageUrl) { | |
| const u = String(url || "").trim(); | |
| if (!u) return ""; | |
| const res = await fetch(u, { | |
| credentials: "include", | |
| redirect: "follow", | |
| cache: "force-cache", | |
| referrer: pageUrl || "about:client", | |
| }); | |
| if (!res.ok) throw new Error("HTTP " + res.status); | |
| const ct = String(res.headers.get("content-type") || ""); | |
| const mime = ct.split(";")[0].trim(); | |
| if (mime && !mime.toLowerCase().startsWith("image/")) { | |
| const body = await readLimitedText(res); | |
| throw new Error(`Not an image: ${mime}${body ? ` - ${body}` : ""}`); | |
| } | |
| const blob = await res.blob(); | |
| if (blob.size < 64) throw new Error("Image too small"); | |
| if (blob.size > 25 * 1024 * 1024) throw new Error("Image too large"); | |
| return await blobToDataUri(blob, mime || blob.type); | |
| } | |
| function classifyJobError(msg) { | |
| const m = String(msg || "").toLowerCase(); | |
| if (!m) return { permanent: false }; | |
| if (m.includes("no overlay data")) return { permanent: true }; | |
| if (m.includes("no image data")) return { permanent: true }; | |
| if (/\bhttp\s*(401|403|404|410)\b/.test(m) || /\b(401|403|404|410)\b/.test(m)) | |
| return { permanent: true }; | |
| if (m.includes("not an image")) return { permanent: true }; | |
| if ( | |
| m.includes("cannot identify image") || | |
| m.includes("image file is truncated") | |
| ) | |
| return { permanent: true }; | |
| if (m.includes("unsupported") && m.includes("image")) | |
| return { permanent: true }; | |
| if ( | |
| m.includes("rate limit") || | |
| m.includes("ratelimit") || | |
| m.includes("too many requests") || | |
| m.includes("http 429") || | |
| m.includes(" 429") | |
| ) | |
| return { permanent: false }; | |
| if ( | |
| m.includes("http 503") || | |
| m.includes(" 503") || | |
| m.includes("overloaded") || | |
| m.includes("temporarily") | |
| ) | |
| return { permanent: false }; | |
| if (m.includes("timeout")) return { permanent: false }; | |
| return { permanent: false }; | |
| } | |
| function mdKeyFromUrl(url) { | |
| try { | |
| const u = new URL(String(url || "")); | |
| const parts = u.pathname.split("/").filter(Boolean); | |
| for (let i = parts.length - 3; i >= 0; i--) { | |
| const seg = parts[i]; | |
| if (seg === "data" || seg === "data-saver") { | |
| const hash = parts[i + 1] || ""; | |
| const file = parts[i + 2] || ""; | |
| if (hash && file) return `md:${seg}/${hash}/${file}`; | |
| } | |
| } | |
| } catch {} | |
| return null; | |
| } | |
| function mdScopeFromMode(mode) { | |
| if (mode === "lens_text") return "text"; | |
| if (mode === "lens_images") return "images"; | |
| return String(mode || ""); | |
| } | |
| function mdCacheKey(mdKey, lang, mode) { | |
| const k = String(mdKey || ""); | |
| const l = String(lang || ""); | |
| const s = mdScopeFromMode(mode); | |
| if (!k || !l || !s) return ""; | |
| return k + "::" + l + "::" + s; | |
| } | |
| function stripImageFields(res) { | |
| if (!res || typeof res !== "object") return res; | |
| const out = { ...res }; | |
| delete out.imageDataUri; | |
| delete out.imageDataURI; | |
| delete out.image; | |
| delete out.imageUrl; | |
| delete out.image_url; | |
| delete out.imageURL; | |
| return out; | |
| } | |
| function pruneMdCache(now = Date.now()) { | |
| for (const [k, rec] of mdCacheByKey.entries()) { | |
| if (!rec || now - rec.ts > MD_CACHE_TTL_MS) mdCacheByKey.delete(k); | |
| } | |
| } | |
| function pruneMdDataUriCache(now = Date.now()) { | |
| for (const [k, rec] of mdDataUriCache.entries()) { | |
| if (!rec || now - rec.ts > MD_DATAURI_CACHE_TTL_MS) | |
| mdDataUriCache.delete(k); | |
| } | |
| while (mdDataUriCache.size > MD_DATAURI_CACHE_MAX) { | |
| const first = mdDataUriCache.keys().next().value; | |
| if (first === undefined) break; | |
| mdDataUriCache.delete(first); | |
| } | |
| } | |
| async function readLimitedText(res, limit = 1600) { | |
| try { | |
| const txt = await res.text(); | |
| const s = String(txt || "").trim(); | |
| if (!s) return ""; | |
| return s.length > limit ? s.slice(0, limit) + "…" : s; | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| async function submitJobViaRest(base, payload) { | |
| const url = base.replace(/\/+$/, "") + "/translate"; | |
| const res = await fetch(url, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| cache: "force-cache", | |
| redirect: "follow", | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!res.ok) { | |
| const body = await readLimitedText(res); | |
| throw new Error( | |
| `REST submit failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`, | |
| ); | |
| } | |
| const data = await res.json(); | |
| if (!data?.id) throw new Error("REST submit failed: no id"); | |
| ev("job.submit.rest", { id: data.id }); | |
| return data.id; | |
| } | |
| async function pollJobViaRest( | |
| base, | |
| jid, | |
| { timeoutMs = 180000, intervalMs = 800 } = {}, | |
| ) { | |
| const start = Date.now(); | |
| const url = | |
| base.replace(/\/+$/, "") + "/translate/" + encodeURIComponent(jid); | |
| let lastSt = null; | |
| let ticks = 0; | |
| while (true) { | |
| const ctx0 = pendingByJob.get(jid); | |
| if (!ctx0) return; | |
| const curSess = ctx0?.tabId ? getTabSessionId(ctx0.tabId) : ""; | |
| const isStale = Boolean( | |
| ctx0?.sessionId && curSess && ctx0.sessionId !== curSess, | |
| ); | |
| if (isStale && !ctx0?.keepCacheOnStale) { | |
| pendingByJob.delete(jid); | |
| if (ctx0?.metadata?.image_id) | |
| pendingByImage.delete(ctx0.metadata.image_id); | |
| ev("job.done", { | |
| id: jid, | |
| dt: fmtMs(Date.now() - (ctx0?.startedAt || Date.now())), | |
| }); | |
| const batchId = String( | |
| ctx0?.batchId || ctx0?.metadata?.batch_id || "", | |
| ).trim(); | |
| const imageKey = String( | |
| ctx0?.imageKey || ctx0?.metadata?.image_id || "", | |
| ).trim(); | |
| const batch = batchId | |
| ? ensureBatch(batchId, ctx0.tabId || 0, ctx0.frameId || 0) | |
| : null; | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { status: "aborted" }); | |
| batchUpdateToast(batch, "ยกเลิก", true); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| return; | |
| } | |
| if (Date.now() - start > timeoutMs) throw new Error("REST poll timeout"); | |
| const res = await fetch(url, { cache: "no-store" }); | |
| if (!res.ok) { | |
| const body = await readLimitedText(res); | |
| throw new Error( | |
| `REST poll failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`, | |
| ); | |
| } | |
| const data = await res.json(); | |
| const st = data?.status; | |
| const ctx = pendingByJob.get(jid); | |
| if (!ctx) return; | |
| const isImageMode = String(ctx?.mode || "") === "lens_images"; | |
| ticks++; | |
| if (st && st !== lastSt) { | |
| lastSt = st; | |
| ev("job.poll.status", { id: jid, status: st }); | |
| if (isImageMode) | |
| logImageRestAccessLine(base, url, res.status, res.statusText); | |
| } else if (ticks % 15 === 0) { | |
| ev("job.poll.tick", { id: jid, status: st || "" }); | |
| } | |
| if (st === "done") { | |
| ev("job.poll.done", { id: jid }); | |
| await handleResult(jid, data.result); | |
| return; | |
| } else if (st === "error") { | |
| handleJobError( | |
| jid, | |
| String(data?.result || data?.error || data?.message || "Unknown error"), | |
| ); | |
| return; | |
| } | |
| await new Promise((r) => setTimeout(r, intervalMs)); | |
| } | |
| } | |
| function runtimeBroadcast(msg) { | |
| try { | |
| chrome.runtime.sendMessage(msg, () => void chrome.runtime.lastError); | |
| } catch {} | |
| } | |
| const broadcast = runtimeBroadcast; | |
| function pingContent(tabId, frameId = 0) { | |
| return new Promise((resolve) => { | |
| try { | |
| chrome.tabs.sendMessage(tabId, { type: "TP_PING" }, { frameId }, () => { | |
| const ok = !chrome.runtime.lastError; | |
| resolve(ok); | |
| }); | |
| } catch { | |
| resolve(false); | |
| } | |
| }); | |
| } | |
| async function ensureContentScript(tabId) { | |
| const wait = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| try { | |
| for (let i = 0; i < 8; i++) { | |
| const ok = await pingContent(tabId, 0); | |
| if (ok) return true; | |
| await wait(120); | |
| } | |
| return false; | |
| } catch (e) { | |
| evWarn("ping.fail", { tabId, message: e?.message || String(e) }); | |
| return false; | |
| } | |
| } | |
| async function sendToTab(tabId, message, frameId = 0) { | |
| const type = String(message?.type || ""); | |
| const opts = { frameId: Number(frameId) || 0 }; | |
| const attempt = (o) => | |
| new Promise((resolve) => { | |
| try { | |
| chrome.tabs.sendMessage(tabId, message, o, (resp) => { | |
| const err = chrome.runtime.lastError; | |
| resolve({ ok: !err, err: err?.message || null, resp: resp || null }); | |
| }); | |
| } catch (e) { | |
| resolve({ ok: false, err: e?.message || String(e), resp: null }); | |
| } | |
| }); | |
| ev("tab.send.begin", { tabId, frameId, type }); | |
| let r = await attempt(opts); | |
| if (r.ok) { | |
| ev("tab.send.ok", { tabId, frameId, type }); | |
| return true; | |
| } | |
| evWarn("tab.send.fail", { tabId, frameId, type, err: r.err || "" }); | |
| if (opts.frameId) { | |
| const r2 = await attempt({ frameId: 0 }); | |
| if (r2.ok) { | |
| ev("tab.send.ok.fallback", { tabId, frameId: 0, type }); | |
| return true; | |
| } | |
| evWarn("tab.send.fail.fallback", { tabId, type, err: r2.err || "" }); | |
| } | |
| const injected = await ensureContentScript(tabId); | |
| if (!injected) return false; | |
| r = await attempt(opts); | |
| if (r.ok) { | |
| ev("tab.send.ok.afterInject", { tabId, frameId, type }); | |
| return true; | |
| } | |
| evWarn("tab.send.fail.afterInject", { | |
| tabId, | |
| frameId, | |
| type, | |
| err: r.err || "", | |
| }); | |
| if (opts.frameId) { | |
| const r3 = await attempt({ frameId: 0 }); | |
| if (r3.ok) { | |
| ev("tab.send.ok.afterInject.fallback", { tabId, frameId: 0, type }); | |
| return true; | |
| } | |
| evWarn("tab.send.fail.afterInject.fallback", { | |
| tabId, | |
| type, | |
| err: r3.err || "", | |
| }); | |
| } | |
| return false; | |
| } | |
| async function requestFromTab(tabId, message, frameId = 0) { | |
| const type = String(message?.type || ""); | |
| const attempt = (o) => | |
| new Promise((resolve) => { | |
| try { | |
| chrome.tabs.sendMessage(tabId, message, o, (resp) => { | |
| const err = chrome.runtime.lastError; | |
| if (err) { | |
| evWarn("tab.request.fail", { | |
| tabId, | |
| frameId: o?.frameId ?? 0, | |
| type, | |
| err: err.message, | |
| }); | |
| resolve(null); | |
| return; | |
| } | |
| resolve(resp || null); | |
| }); | |
| } catch (e) { | |
| evWarn("tab.request.fail", { | |
| tabId, | |
| frameId: o?.frameId ?? 0, | |
| type, | |
| err: e?.message || String(e), | |
| }); | |
| resolve(null); | |
| } | |
| }); | |
| const primary = { frameId: Number(frameId) || 0 }; | |
| const resp = await attempt(primary); | |
| if (resp != null) return resp; | |
| if (primary.frameId) return await attempt({ frameId: 0 }); | |
| return null; | |
| } | |
| async function requestFromTabEnsured(tabId, message, frameId = 0) { | |
| const resp = await requestFromTab(tabId, message, frameId); | |
| if (resp != null) return resp; | |
| const ok = await ensureContentScript(tabId); | |
| if (!ok) return null; | |
| return await requestFromTab(tabId, message, frameId); | |
| } | |
| function sendToastToTab(tabId, frameId, text, ms = 1600) { | |
| if (!tabId || !text) return; | |
| const opts = { frameId: Number(frameId) || 0 }; | |
| try { | |
| chrome.tabs.sendMessage(tabId, { type: "TP_TOAST", text, ms }, opts, () => { | |
| void chrome.runtime.lastError; | |
| }); | |
| } catch {} | |
| } | |
| function setWsStatus(s) { | |
| if (wsStatus !== s) { | |
| wsStatus = s; | |
| try { | |
| broadcast({ type: "WS_STATUS_UPDATE", status: s }); | |
| } catch (e) {} | |
| info(`[WS] status -> ${s}`); | |
| } | |
| } | |
| const warmupByBase = new Map(); | |
| async function warmupApi(base) { | |
| const b = normalizeUrl(base); | |
| if (!b) return; | |
| const now = Date.now(); | |
| const last = warmupByBase.get(b) || 0; | |
| if (now - last < WARMUP_TTL_MS) return; | |
| warmupByBase.set(b, now); | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS); | |
| try { | |
| await fetch(b.replace(/\/+$/, "") + WARMUP_PATH, { | |
| method: "GET", | |
| cache: "force-cache", | |
| signal: controller.signal, | |
| }); | |
| ev("api.warmup", { base: b }); | |
| } catch { | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } | |
| const getApiBase = () => | |
| new Promise((res) => { | |
| chrome.storage.local.get( | |
| { customApiUrl: "", apiUrlDefault: API_BASE_FALLBACK }, | |
| ({ customApiUrl, apiUrlDefault }) => { | |
| const base = | |
| normalizeUrl(customApiUrl) || | |
| normalizeUrl(apiUrlDefault) || | |
| API_BASE_FALLBACK; | |
| info("[getApiBase]", base); | |
| warmupApi(base); | |
| res(base); | |
| }, | |
| ); | |
| }); | |
| let healthCache = { ok: false, ts: 0, build: "" }; | |
| function onceOpen(socket) { | |
| return new Promise((resolve, reject) => { | |
| const onOpen = () => { | |
| cleanup(); | |
| resolve(); | |
| }; | |
| const onErr = (e) => { | |
| cleanup(); | |
| reject(e); | |
| }; | |
| const onClose = () => { | |
| cleanup(); | |
| reject(new Error("ws-closed-before-open")); | |
| }; | |
| function cleanup() { | |
| socket.removeEventListener("open", onOpen); | |
| socket.removeEventListener("error", onErr); | |
| socket.removeEventListener("close", onClose); | |
| } | |
| socket.addEventListener("open", onOpen); | |
| socket.addEventListener("error", onErr); | |
| socket.addEventListener("close", onClose); | |
| }); | |
| } | |
| function withTimeout(promise, ms, label = "op") { | |
| return new Promise((resolve, reject) => { | |
| const t = setTimeout(() => reject(new Error(label + "-timeout")), ms); | |
| promise.then( | |
| (v) => { | |
| clearTimeout(t); | |
| resolve(v); | |
| }, | |
| (e) => { | |
| clearTimeout(t); | |
| reject(e); | |
| }, | |
| ); | |
| }); | |
| } | |
| let wsPromise = null; | |
| async function preflightWs(base, timeoutMs = PREFLIGHT_TIMEOUT_MS) { | |
| const tryOnce = async (url, method = "GET") => { | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| try { | |
| await fetch(url, { method, cache: "no-store", signal: ctrl.signal }); | |
| return true; | |
| } catch (e) { | |
| return false; | |
| } finally { | |
| clearTimeout(t); | |
| } | |
| }; | |
| const ok1 = await tryOnce(base.replace(/\/$/, "") + "/health", "GET"); | |
| if (ok1) return true; | |
| const ok2 = await tryOnce(base.replace(/\/$/, "") + "/health", "HEAD"); | |
| return ok2; | |
| } | |
| async function connectWebSocketOnce() { | |
| const t_start = t0(); | |
| ev("ws.connect.begin"); | |
| const base = await getApiBase(); | |
| const wsUrl = toWs(base); | |
| if (!wsUrl) { | |
| setWsStatus("idle"); | |
| return false; | |
| } | |
| if ( | |
| ws && | |
| (ws.readyState === WebSocket.OPEN || | |
| ws.readyState === WebSocket.CONNECTING) && | |
| currentBase === base | |
| ) { | |
| return ws.readyState === WebSocket.OPEN; | |
| } | |
| const reachable = await preflightWs(base, PREFLIGHT_TIMEOUT_MS); | |
| if (!reachable) { | |
| setWsStatus("offline"); | |
| evWarn("ws.preflight.fail", { base }); | |
| return false; | |
| } | |
| if (wsPromise) { | |
| try { | |
| await wsPromise; | |
| } catch (e) {} | |
| const ok = !!(ws && ws.readyState === WebSocket.OPEN); | |
| ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) }); | |
| return ok; | |
| } | |
| wsPromise = (async () => { | |
| const TIMEOUT_MS = WS_OPEN_TIMEOUT_MS; | |
| const RETRIES = WS_RETRIES; | |
| let lastErr = null; | |
| for (let attempt = 0; attempt <= RETRIES; attempt++) { | |
| try { | |
| if (ws) { | |
| try { | |
| ws.close(); | |
| } catch (e) {} | |
| } | |
| currentBase = base; | |
| wsReady = false; | |
| setWsStatus("connecting"); | |
| blockSendsBecauseWsEnded = false; | |
| ws = new WebSocket(wsUrl); | |
| ws.addEventListener("open", () => { | |
| ev("ws.open", { url: wsUrl }); | |
| wsReady = true; | |
| setWsStatus("connected"); | |
| }); | |
| ws.addEventListener("message", (ev) => { | |
| try { | |
| const msg = JSON.parse(ev.data); | |
| switch (msg.type) { | |
| case "ack": | |
| break; | |
| case "result": | |
| void handleResult(msg.id, msg.result).catch((e) => | |
| evWarn("job.result.handle.fail", { | |
| id: msg.id, | |
| err: e?.message || String(e), | |
| }), | |
| ); | |
| break; | |
| case "error": | |
| handleJobError(msg.id, msg.error || "Unknown error"); | |
| break; | |
| default: | |
| warn("[ws] unknown msg", msg); | |
| } | |
| } catch (e) { | |
| error("WS parse error", e); | |
| } | |
| }); | |
| const onEnded = (evx) => { | |
| evWarn("ws.end", { | |
| code: evx?.code, | |
| reason: evx?.reason || evx?.message || evx, | |
| }); | |
| wsReady = false; | |
| blockSendsBecauseWsEnded = true; | |
| failAllPending( | |
| "Connection lost before all images finished. Please run the menu again.", | |
| ); | |
| setWsStatus("idle"); | |
| }; | |
| ws.addEventListener("close", onEnded); | |
| ws.addEventListener("error", onEnded); | |
| await withTimeout(onceOpen(ws), TIMEOUT_MS, "ws-open"); | |
| return true; | |
| } catch (e) { | |
| lastErr = e; | |
| try { | |
| if (ws) ws.close(); | |
| } catch (e2) {} | |
| ws = null; | |
| wsReady = false; | |
| setWsStatus("idle"); | |
| const base = 500; | |
| const jitter = Math.floor(Math.random() * 300); | |
| const waitMs = Math.min(5000, base * Math.pow(2, attempt)) + jitter; | |
| if (attempt < RETRIES) await new Promise((r) => setTimeout(r, waitMs)); | |
| } | |
| } | |
| throw lastErr || new Error("ws-failed"); | |
| })(); | |
| try { | |
| await wsPromise; | |
| } finally { | |
| wsPromise = null; | |
| } | |
| const ok = !!(ws && ws.readyState === WebSocket.OPEN); | |
| ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) }); | |
| return ok; | |
| } | |
| function addTask(fn) { | |
| queue.push(fn); | |
| next(); | |
| } | |
| function next() { | |
| if (!queue.length) return; | |
| const max = effectiveMaxConcurrency(); | |
| if (max && running >= max) return; | |
| running++; | |
| const task = queue.shift(); | |
| Promise.resolve(task()) | |
| .catch(error) | |
| .finally(() => { | |
| running--; | |
| next(); | |
| }); | |
| } | |
| function failJobImmediately(tabId, jobId, imgUrl, message, frameId = 0) { | |
| if (tabId) { | |
| sendToTab( | |
| tabId, | |
| { type: "IMAGE_ERROR", original: imgUrl, message }, | |
| frameId, | |
| ); | |
| } | |
| } | |
| function failAllPending(message) { | |
| const jobIds = Array.from(pendingByJob.keys()); | |
| for (const jobId of jobIds) { | |
| const ctx = pendingByJob.get(jobId); | |
| const batchId = String( | |
| ctx?.batchId || ctx?.metadata?.batch_id || "", | |
| ).trim(); | |
| const imageKey = String( | |
| ctx?.imageKey || ctx?.metadata?.image_id || "", | |
| ).trim(); | |
| const batch = batchId | |
| ? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0) | |
| : null; | |
| failJobImmediately( | |
| ctx?.tabId, | |
| jobId, | |
| ctx?.imgUrl || null, | |
| message, | |
| ctx?.frameId || 0, | |
| ); | |
| pendingByJob.delete(jobId); | |
| ev("job.done", { | |
| id: jobId, | |
| dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), | |
| }); | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { status: "aborted", lastError: message }); | |
| batchUpdateToast(batch, "ยกเลิก"); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| } | |
| pendingByImage.clear(); | |
| } | |
| function handleJobError(jobId, errMsg = "Unknown error") { | |
| const cls = classifyJobError(errMsg); | |
| const ctx = pendingByJob.get(jobId); | |
| const curSess = ctx?.tabId ? getTabSessionId(ctx.tabId) : ""; | |
| const isStale = Boolean( | |
| ctx?.sessionId && curSess && ctx.sessionId !== curSess, | |
| ); | |
| const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim(); | |
| const imageKey = String( | |
| ctx?.imageKey || ctx?.metadata?.image_id || "", | |
| ).trim(); | |
| const batch = batchId | |
| ? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0) | |
| : null; | |
| if (ctx?.tabId && !isStale) | |
| sendToTab( | |
| ctx.tabId, | |
| { type: "IMAGE_ERROR", original: ctx.imgUrl, message: errMsg }, | |
| ctx.frameId || 0, | |
| ); | |
| pendingByJob.delete(jobId); | |
| ev("job.done", { | |
| id: jobId, | |
| dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), | |
| }); | |
| if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id); | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { | |
| status: "error", | |
| lastError: errMsg, | |
| permanent: !!cls?.permanent, | |
| }); | |
| batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| } | |
| async function handleResult(jobId, result) { | |
| let ctx = pendingByJob.get(jobId); | |
| if (!ctx && result?.metadata?.image_id) { | |
| const mapped = pendingByImage.get(result.metadata.image_id); | |
| ctx = typeof mapped === "string" ? pendingByJob.get(mapped) : mapped; | |
| } | |
| if (!ctx) { | |
| evWarn("job.ctx.missing", { | |
| id: jobId, | |
| metaImageId: String(result?.metadata?.image_id || ""), | |
| }); | |
| return; | |
| } | |
| const { imgUrl, tabId } = ctx; | |
| const frameId = ctx.frameId || 0; | |
| const mode = ctx.mode || ctx.metadata?.mode || null; | |
| const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim(); | |
| const imageKey = String( | |
| ctx?.imageKey || | |
| ctx?.metadata?.image_id || | |
| result?.metadata?.image_id || | |
| "", | |
| ).trim(); | |
| const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null; | |
| ev("job.result.recv", { | |
| id: jobId, | |
| tabId, | |
| frameId, | |
| keys: Object.keys(result || {}), | |
| }); | |
| const curSess = getTabSessionId(tabId); | |
| const isSettingsStale = | |
| typeof ctx?.settingsEpoch === "number" && | |
| ctx.settingsEpoch !== settingsEpoch; | |
| const isStale = | |
| Boolean(ctx?.sessionId && curSess && ctx.sessionId !== curSess) || | |
| isSettingsStale; | |
| const newImg = | |
| result?.imageDataUri || | |
| result?.imageDataURI || | |
| result?.image || | |
| result?.imageUrl || | |
| result?.image_url || | |
| result?.imageURL || | |
| null; | |
| const aiHtml = result?.Ai?.aihtml || result?.ai?.aihtml || null; | |
| const translatedHtml = | |
| result?.translated?.translatedhtml || result?.translatedhtml || null; | |
| const originalHtml = | |
| result?.original?.originalhtml || result?.originalhtml || null; | |
| const hasHtml = !!(aiHtml || translatedHtml || originalHtml); | |
| const mdKey = mdKeyFromUrl(imgUrl); | |
| const lang = ctx.lang || ctx.metadata?.lang || null; | |
| const cacheKey = mdCacheKey(mdKey, lang, mode); | |
| if (cacheKey && (newImg || hasHtml)) { | |
| pruneMdCache(); | |
| const prev = mdCacheByKey.get(cacheKey) || {}; | |
| mdCacheByKey.set(cacheKey, { | |
| newImg: newImg || prev.newImg || null, | |
| result: hasHtml ? stripImageFields(result) : prev.result || null, | |
| ts: Date.now(), | |
| }); | |
| } | |
| if (isStale) { | |
| pendingByJob.delete(jobId); | |
| if (result?.metadata?.image_id) | |
| pendingByImage.delete(result.metadata.image_id); | |
| ev("job.done", { | |
| id: jobId, | |
| dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), | |
| }); | |
| if (batch && imageKey) { | |
| if (ctx?.keepCacheOnStale) { | |
| batchMark(batchId, imageKey, { status: "done", cachedOnly: true }); | |
| batchUpdateToast(batch, "บันทึกแล้ว", true); | |
| batchFinalizeIfComplete(batch); | |
| } else { | |
| batchMark(batchId, imageKey, { status: "aborted" }); | |
| batchUpdateToast(batch, "ยกเลิก", true); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| } | |
| return; | |
| } | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { status: "inserting" }); | |
| batchUpdateToast(batch, "แทรกกลับ"); | |
| } | |
| let replaceResp = null; | |
| if (newImg && mode !== "lens_text") { | |
| ev("job.result.image", { id: jobId }); | |
| replaceResp = await requestFromTabEnsured( | |
| tabId, | |
| { type: "REPLACE_IMAGE", original: imgUrl, newSrc: newImg }, | |
| frameId, | |
| ); | |
| } | |
| let overlayResp = null; | |
| if (hasHtml) { | |
| ev("job.result.html", { id: jobId }); | |
| overlayResp = await requestFromTabEnsured( | |
| tabId, | |
| { | |
| type: "OVERLAY_HTML", | |
| original: imgUrl, | |
| result, | |
| mode: mode || "", | |
| source: ctx?.source || "", | |
| }, | |
| frameId, | |
| ); | |
| } | |
| let ok = true; | |
| let errMsg = ""; | |
| if (!hasHtml && !(newImg && mode !== "lens_text")) ok = false; | |
| if (newImg && mode !== "lens_text" && !replaceResp?.ok) { | |
| ok = false; | |
| errMsg = "DOM replace failed"; | |
| } | |
| if (hasHtml && !overlayResp?.ok) { | |
| ok = false; | |
| errMsg = "Overlay insert failed"; | |
| } | |
| if (!hasHtml) { | |
| const keys = Object.keys(result || {}); | |
| if (!newImg) { | |
| evWarn("job.result.none", { id: jobId, keys }); | |
| await requestFromTabEnsured( | |
| tabId, | |
| { | |
| type: "IMAGE_ERROR", | |
| original: imgUrl, | |
| message: "API returned no overlay data", | |
| }, | |
| frameId, | |
| ); | |
| ok = false; | |
| errMsg = "API returned no overlay data"; | |
| } else { | |
| ev("job.result.imageOnly", { id: jobId, mode: mode || "unknown" }); | |
| } | |
| } | |
| pendingByJob.delete(jobId); | |
| ev("job.done", { | |
| id: jobId, | |
| dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), | |
| }); | |
| if (result?.metadata?.image_id) | |
| pendingByImage.delete(result.metadata.image_id); | |
| if (batch && imageKey) { | |
| if (ok) { | |
| batchMark(batchId, imageKey, { status: "done" }); | |
| batchUpdateToast(batch, "เสร็จ 1 ภาพ"); | |
| } else { | |
| const cls = classifyJobError(errMsg); | |
| batchMark(batchId, imageKey, { | |
| status: "error", | |
| lastError: errMsg || "Unknown error", | |
| permanent: !!cls?.permanent, | |
| }); | |
| batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); | |
| } | |
| batchFinalizeIfComplete(batch); | |
| } | |
| } | |
| async function processJob(payload, tabId, frameId = 0) { | |
| ev("job.enqueue", { | |
| type: payload?.type, | |
| menu: payload?.menu, | |
| src: payload?.src ? "url" : "none", | |
| }); | |
| if (!payload || typeof payload !== "object") return; | |
| if (!payload.metadata || typeof payload.metadata !== "object") | |
| payload.metadata = {}; | |
| const batchId = String( | |
| payload.metadata.batch_id || currentBatchId || "", | |
| ).trim(); | |
| if (batchId) payload.metadata.batch_id = batchId; | |
| const imageKey = imageKeyFromPayload(payload); | |
| const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null; | |
| const pageUrl = payload?.context?.page_url || ""; | |
| const isMd = isMangaDexPageUrl(pageUrl); | |
| const originSession = String( | |
| payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "", | |
| ).trim(); | |
| const curSession = getTabSessionId(tabId); | |
| if (originSession && curSession && originSession !== curSession && !isMd) { | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { | |
| status: "aborted", | |
| lastError: "navigation", | |
| }); | |
| batchUpdateToast(batch, "ยกเลิก"); | |
| batchFinalizeIfComplete(batch); | |
| batchStopKeepAlive(batch); | |
| } | |
| return; | |
| } | |
| if (batch && imageKey) { | |
| const it = batch.items.get(imageKey); | |
| if (it) batch.items.set(imageKey, { ...it, status: "processing" }); | |
| batchUpdateToast(batch, "ประมวลผล"); | |
| } | |
| const base = await getApiBase(); | |
| const preferRest = shouldPreferRest(base, payload?.mode, payload?.source); | |
| const src = String(payload?.src || "").trim(); | |
| const shouldPrefetchDataUri = | |
| !payload?.imageDataUri && | |
| Boolean(src) && | |
| (/^(?:https?:|blob:|data:|file:|chrome-extension:)/i.test(src) || | |
| (payload?.mode === "lens_text" && | |
| String(payload?.source || "").toLowerCase() === "ai")); | |
| if (shouldPrefetchDataUri) { | |
| const key = normImgSrc(src); | |
| pruneMdDataUriCache(); | |
| const cached = key ? mdDataUriCache.get(key) : null; | |
| if ( | |
| cached?.du && | |
| Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS | |
| ) { | |
| payload.imageDataUri = cached.du; | |
| ev("image.datauri.cache.hit", { size: payload.imageDataUri.length }); | |
| } else { | |
| try { | |
| const du = src.startsWith("data:") | |
| ? src | |
| : await fetchImageDataUriFromUrl(src, pageUrl || ""); | |
| if (du) { | |
| payload.imageDataUri = du; | |
| if (key) mdDataUriCache.set(key, { du, ts: Date.now() }); | |
| const meta = | |
| payload.metadata && typeof payload.metadata === "object" | |
| ? payload.metadata | |
| : (payload.metadata = {}); | |
| const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; | |
| meta.pipeline = pipe.concat({ | |
| stage: "prefetch_datauri", | |
| at: new Date().toISOString(), | |
| }); | |
| meta.timestamp = new Date().toISOString(); | |
| if (batch && imageKey) batchMark(batchId, imageKey, { payload }); | |
| ev("image.datauri.prefetch.ok", { size: du.length }); | |
| } | |
| } catch (e) { | |
| const msg = e?.message || String(e); | |
| const cls = classifyJobError(msg); | |
| evWarn("image.datauri.prefetch.fail", { | |
| err: msg, | |
| permanent: !!cls?.permanent, | |
| }); | |
| if (cls?.permanent) { | |
| if (payload?.metadata?.image_id) | |
| pendingByImage.delete(payload.metadata.image_id); | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { | |
| status: "error", | |
| lastError: msg, | |
| permanent: true, | |
| }); | |
| batchUpdateToast(batch, "ผิดพลาด (ถาวร)"); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| const jobId = crypto.randomUUID(); | |
| failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| if (blockSendsBecauseWsEnded && !preferRest) { | |
| const msg = "Connection closed. Please run the menu again."; | |
| if (batch && imageKey) { | |
| batchMark(batchId, imageKey, { status: "aborted", lastError: msg }); | |
| batchUpdateToast(batch, "ยกเลิก"); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| const jobId = crypto.randomUUID(); | |
| failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); | |
| return; | |
| } | |
| const sessionId = getTabSessionId(tabId) || bumpTabSession(tabId, pageUrl); | |
| if (payload?.metadata?.image_id) { | |
| pendingByImage.set(payload.metadata.image_id, { | |
| imgUrl: payload.src, | |
| tabId, | |
| frameId, | |
| mode: payload?.mode || null, | |
| lang: payload?.lang || null, | |
| source: payload?.source || null, | |
| metadata: payload.metadata, | |
| imageKey, | |
| batchId, | |
| pageUrl, | |
| sessionId: originSession || sessionId, | |
| keepCacheOnStale: isMd, | |
| settingsEpoch, | |
| }); | |
| } | |
| if (preferRest) { | |
| let jid = ""; | |
| try { | |
| jid = await submitJobViaRest(base, payload); | |
| pendingByJob.set(jid, { | |
| imgUrl: payload.src, | |
| tabId, | |
| frameId, | |
| mode: payload?.mode || null, | |
| lang: payload?.lang || null, | |
| source: payload?.source || null, | |
| metadata: payload.metadata, | |
| startedAt: Date.now(), | |
| batchId, | |
| imageKey, | |
| pageUrl, | |
| sessionId: originSession || sessionId, | |
| keepCacheOnStale: isMd, | |
| settingsEpoch, | |
| }); | |
| await pollJobViaRest(base, jid); | |
| return; | |
| } catch (e) { | |
| const msg = e?.message || String(e); | |
| if (jid) { | |
| handleJobError(jid, msg); | |
| return; | |
| } | |
| if (payload?.metadata?.image_id) | |
| pendingByImage.delete(payload.metadata.image_id); | |
| if (batch && imageKey) { | |
| const cls = classifyJobError(msg); | |
| batchMark(batchId, imageKey, { | |
| status: "error", | |
| lastError: msg, | |
| permanent: !!cls?.permanent, | |
| }); | |
| batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| const jobId = crypto.randomUUID(); | |
| failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); | |
| return; | |
| } | |
| } | |
| for (let attempt = 0; attempt <= MAX_FIRST_TRY_RETRIES; attempt++) { | |
| if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) { | |
| const connected = await connectWebSocketOnce(); | |
| if (!connected) { | |
| if (attempt < MAX_FIRST_TRY_RETRIES) { | |
| await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS)); | |
| continue; | |
| } else { | |
| let jid = ""; | |
| try { | |
| jid = await submitJobViaRest(base, payload); | |
| pendingByJob.set(jid, { | |
| imgUrl: payload.src, | |
| tabId, | |
| frameId, | |
| mode: payload?.mode || null, | |
| lang: payload?.lang || null, | |
| source: payload?.source || null, | |
| metadata: payload.metadata, | |
| startedAt: Date.now(), | |
| batchId, | |
| imageKey, | |
| pageUrl, | |
| sessionId: originSession || sessionId, | |
| keepCacheOnStale: isMd, | |
| settingsEpoch, | |
| }); | |
| await pollJobViaRest(base, jid); | |
| return; | |
| } catch (e) { | |
| const msg = e?.message || String(e); | |
| if (jid) { | |
| handleJobError(jid, msg); | |
| return; | |
| } | |
| if (payload?.metadata?.image_id) | |
| pendingByImage.delete(payload.metadata.image_id); | |
| if (batch && imageKey) { | |
| const cls = classifyJobError(msg); | |
| batchMark(batchId, imageKey, { | |
| status: "error", | |
| lastError: msg, | |
| permanent: !!cls?.permanent, | |
| }); | |
| batchUpdateToast( | |
| batch, | |
| cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด", | |
| ); | |
| batchFinalizeIfComplete(batch); | |
| } | |
| const jobId = crypto.randomUUID(); | |
| failJobImmediately( | |
| tabId, | |
| jobId, | |
| payload?.src || null, | |
| msg, | |
| frameId, | |
| ); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| const jobId = crypto.randomUUID(); | |
| pendingByJob.set(jobId, { | |
| imgUrl: payload.src, | |
| tabId, | |
| frameId, | |
| mode: payload?.mode || null, | |
| lang: payload?.lang || null, | |
| source: payload?.source || null, | |
| metadata: payload.metadata, | |
| startedAt: Date.now(), | |
| batchId, | |
| imageKey, | |
| pageUrl, | |
| sessionId: originSession || sessionId, | |
| keepCacheOnStale: isMd, | |
| settingsEpoch, | |
| }); | |
| try { | |
| ev("job.send", { id: jobId }); | |
| ws.send(JSON.stringify({ type: "job", id: jobId, payload })); | |
| return; | |
| } catch (e) { | |
| handleJobError(jobId, "Send failed: " + (e?.message || e)); | |
| if (attempt < MAX_FIRST_TRY_RETRIES) { | |
| await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS)); | |
| continue; | |
| } else { | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| const enqueue = (payload, tabId, frameId = 0) => { | |
| const expected = String( | |
| payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "", | |
| ).trim(); | |
| addTask(() => { | |
| const cur = getTabSessionId(tabId); | |
| if (expected && (!cur || expected !== cur)) return; | |
| return processJob(payload, tabId, frameId); | |
| }); | |
| }; | |
| function recreateMenus() { | |
| chrome.contextMenus.removeAll(() => { | |
| chrome.contextMenus.create({ | |
| id: "img_one", | |
| title: "🔍 Translate this image", | |
| contexts: ["image"], | |
| }); | |
| chrome.contextMenus.create({ | |
| id: "img_all", | |
| title: "🔍 Translate all images on page", | |
| contexts: ["page", "selection"], | |
| }); | |
| }); | |
| } | |
| async function getSettings() { | |
| return new Promise((res) => { | |
| chrome.storage.local.get( | |
| [ | |
| "mode", | |
| "lang", | |
| "sources", | |
| "maxConcurrency", | |
| "aiKey", | |
| "aiModel", | |
| "aiPromptByLang", | |
| "aiPrompt", | |
| ], | |
| (it) => { | |
| MAX_CONCURRENCY = | |
| Number(it.maxConcurrency) >= 0 ? Number(it.maxConcurrency) : 0; | |
| const lang = typeof it.lang === "string" ? it.lang : "en"; | |
| const aiModel = normalizeAiModel( | |
| typeof it.aiModel === "string" ? it.aiModel : "auto", | |
| ); | |
| const rawMap = | |
| it.aiPromptByLang && typeof it.aiPromptByLang === "object" | |
| ? it.aiPromptByLang | |
| : {}; | |
| const mig = migratePromptMap(rawMap); | |
| const map = mig.map; | |
| const key = makePromptKey(lang, aiModel); | |
| const autoKey = makePromptKey(lang, "auto"); | |
| let changed = mig.changed; | |
| let aiPrompt = Object.prototype.hasOwnProperty.call(map, key) | |
| ? String(map[key] || "") | |
| : ""; | |
| if (!aiPrompt && Object.prototype.hasOwnProperty.call(map, autoKey)) { | |
| aiPrompt = String(map[autoKey] || ""); | |
| if (key !== autoKey) { | |
| map[key] = aiPrompt; | |
| changed = true; | |
| } | |
| } | |
| if (!aiPrompt) { | |
| const legacy = typeof it.aiPrompt === "string" ? it.aiPrompt : ""; | |
| if (legacy) { | |
| aiPrompt = legacy; | |
| map[key] = legacy; | |
| changed = true; | |
| } | |
| } | |
| aiPrompt = normalizePrompt(aiPrompt); | |
| if (changed) { | |
| map[key] = aiPrompt; | |
| chrome.storage.local.set({ aiPromptByLang: map, aiPrompt: "" }); | |
| } | |
| res({ | |
| mode: typeof it.mode === "string" ? it.mode : "lens_images", | |
| lang, | |
| sources: typeof it.sources === "string" ? it.sources : "translated", | |
| aiKey: typeof it.aiKey === "string" ? it.aiKey : "", | |
| aiModel, | |
| aiPrompt, | |
| }); | |
| }, | |
| ); | |
| }); | |
| } | |
| chrome.contextMenus.onClicked.addListener(async (menuInfo, tab) => { | |
| ev("menu.click", { id: menuInfo.menuItemId }); | |
| try { | |
| if (tab?.id) await ensureContentScript(tab.id); | |
| const tabSessionId = tab?.id | |
| ? ensureTabSession(tab.id, tab?.url || "") | |
| : ""; | |
| const { mode, lang, sources, aiKey, aiModel, aiPrompt } = | |
| await getSettings(); | |
| const source = | |
| mode === "lens_text" ? sources || "translated" : "translated"; | |
| const aiPayload = | |
| mode === "lens_text" && source === "ai" | |
| ? { | |
| api_key: aiKey || "", | |
| model: aiModel || "auto", | |
| prompt: aiPrompt || "", | |
| } | |
| : null; | |
| forceSoftMaxConcurrency = mode === "lens_text" && source === "ai"; | |
| softMaxConcurrency = forceSoftMaxConcurrency | |
| ? aiSoftMaxConcurrencyFromKey(aiKey) | |
| : SOFT_MAX_CONCURRENCY_DEFAULT; | |
| ev("batch.concurrency", { | |
| soft: softMaxConcurrency, | |
| max: MAX_CONCURRENCY || "unlimited", | |
| }); | |
| currentBatchId = crypto.randomUUID(); | |
| blockSendsBecauseWsEnded = false; | |
| const base = await getApiBase(); | |
| const preferRest = shouldPreferRest(base, mode, source); | |
| if (!preferRest) { | |
| const connected = await connectWebSocketOnce(); | |
| if (!connected) { | |
| evWarn("ws.unavailable.fallback_rest"); | |
| } | |
| } | |
| let originalUrl = menuInfo.srcUrl; | |
| const frameId = Number(menuInfo.frameId) || 0; | |
| const scanFrameId = 0; | |
| sendToastToTab( | |
| tab?.id, | |
| menuInfo.menuItemId === "img_all" ? scanFrameId : frameId, | |
| menuInfo.menuItemId === "img_all" | |
| ? "TextPhantom: กำลังรับภาพ…" | |
| : "TextPhantom: กำลังประมวลผล…", | |
| 60000, | |
| ); | |
| if ( | |
| tab?.url?.includes("mangadex.org") && | |
| originalUrl?.startsWith("blob:") | |
| ) { | |
| try { | |
| const resp = await requestFromTab( | |
| tab.id, | |
| { type: "RESOLVE_AND_REPLACE_MANGADEX_BLOB", blobUrl: originalUrl }, | |
| frameId, | |
| ); | |
| if (resp?.resolved) originalUrl = resp.resolved; | |
| } catch (e) { | |
| warn("resolve MangaDex blob error", e); | |
| } | |
| } | |
| if (menuInfo.menuItemId === "img_one" && tab?.id) { | |
| try { | |
| await sendToTab( | |
| tab.id, | |
| { type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 }, | |
| frameId, | |
| ); | |
| } catch {} | |
| let payload = null; | |
| const wantsContextPayload = | |
| !originalUrl || /^(?:blob:|data:|file:|chrome-extension:)/i.test(String(originalUrl || "")); | |
| if (wantsContextPayload) { | |
| try { | |
| const resp = await requestFromTab( | |
| tab.id, | |
| { type: "GET_CONTEXT_IMAGE_PAYLOAD" }, | |
| frameId, | |
| ); | |
| if (resp?.ok && resp?.payload) payload = resp.payload; | |
| } catch (e) { | |
| evWarn("context.payload.fail", { err: e?.message || String(e) }); | |
| } | |
| } | |
| const metadata0 = | |
| payload?.metadata && typeof payload.metadata === "object" | |
| ? payload.metadata | |
| : {}; | |
| const pipeline0 = Array.isArray(metadata0.pipeline) ? metadata0.pipeline : []; | |
| const imageId = String(metadata0.image_id || "").trim() || crypto.randomUUID(); | |
| const sourceUrl = payload?.src || originalUrl || null; | |
| payload = { | |
| ...(payload && typeof payload === "object" ? payload : {}), | |
| mode, | |
| lang, | |
| type: "image", | |
| src: sourceUrl, | |
| imageDataUri: | |
| typeof payload?.imageDataUri === "string" && payload.imageDataUri | |
| ? payload.imageDataUri | |
| : null, | |
| menu: "img_one", | |
| source, | |
| ai: aiPayload, | |
| context: { | |
| ...(payload?.context && typeof payload.context === "object" | |
| ? payload.context | |
| : {}), | |
| page_url: tab?.url || null, | |
| timestamp: new Date().toISOString(), | |
| tp_tab_session: tabSessionId, | |
| }, | |
| metadata: { | |
| ...metadata0, | |
| image_id: imageId, | |
| batch_id: currentBatchId, | |
| original_image_url: sourceUrl, | |
| position: metadata0.position || null, | |
| ocr_image: null, | |
| extra: null, | |
| pipeline: pipeline0.concat({ | |
| stage: "context_menu_single", | |
| at: new Date().toISOString(), | |
| }), | |
| timestamp: new Date().toISOString(), | |
| }, | |
| }; | |
| if ( | |
| !payload.imageDataUri && | |
| String(sourceUrl || "").startsWith("blob:") | |
| ) { | |
| try { | |
| const du = await fetchImageDataUriFromUrl(sourceUrl, tab?.url || null); | |
| if (du) payload.imageDataUri = du; | |
| ev("image.datauri.ok", { size: du ? du.length : 0 }); | |
| } catch (e) { | |
| evWarn("image.datauri.fail", { err: e?.message || String(e) }); | |
| } | |
| } | |
| if (!payload.src && !payload.imageDataUri) return; | |
| const b = ensureBatch(currentBatchId, tab.id, frameId); | |
| b.total1 = 1; | |
| const k = imageKeyFromPayload(payload); | |
| if (k) | |
| b.items.set(k, { | |
| payload, | |
| attempt: 1, | |
| status: "queued", | |
| lastError: "", | |
| }); | |
| batchUpdateToast(b, "รับภาพ", true); | |
| enqueue(payload, tab.id, frameId); | |
| return; | |
| } | |
| if (menuInfo.menuItemId === "img_all" && tab?.id) { | |
| try { | |
| await sendToTab( | |
| tab.id, | |
| { type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 }, | |
| scanFrameId, | |
| ); | |
| } catch {} | |
| let images = []; | |
| let imagesFrameId = scanFrameId; | |
| try { | |
| images = await requestFromTab( | |
| tab.id, | |
| { type: "GET_IMAGES" }, | |
| scanFrameId, | |
| ); | |
| if ((!images || !images.length) && frameId) { | |
| const alt = await requestFromTab( | |
| tab.id, | |
| { type: "GET_IMAGES" }, | |
| frameId, | |
| ); | |
| if (Array.isArray(alt) && alt.length) { | |
| images = alt; | |
| imagesFrameId = frameId; | |
| } | |
| } | |
| } catch (e) { | |
| error("GET_IMAGES failed", e); | |
| } | |
| let payloads = (Array.isArray(images) ? images : []) | |
| .map((meta) => { | |
| const m = meta?.metadata || {}; | |
| const imageId = m.image_id || crypto.randomUUID(); | |
| const src = m.original_image_url || meta.src || ""; | |
| return { | |
| mode, | |
| lang, | |
| type: "image", | |
| src: src || null, | |
| imageDataUri: | |
| typeof meta?.imageDataUri === "string" && meta.imageDataUri | |
| ? meta.imageDataUri | |
| : typeof m.imageDataUri === "string" && m.imageDataUri | |
| ? m.imageDataUri | |
| : null, | |
| menu: "img_all", | |
| source, | |
| ai: aiPayload, | |
| context: { | |
| page_url: tab?.url || null, | |
| timestamp: new Date().toISOString(), | |
| tp_tab_session: tabSessionId, | |
| }, | |
| metadata: { | |
| image_id: imageId, | |
| batch_id: currentBatchId, | |
| original_image_url: src || null, | |
| position: m.position || null, | |
| ocr_image: null, | |
| extra: null, | |
| pipeline: (Array.isArray(m.pipeline) ? m.pipeline : []).concat({ | |
| stage: "context_menu_all", | |
| at: new Date().toISOString(), | |
| }), | |
| timestamp: new Date().toISOString(), | |
| }, | |
| }; | |
| }) | |
| .filter((p) => !!(p.src || p.imageDataUri)); | |
| const seen = new Set(); | |
| const uniq = []; | |
| for (const pl of payloads) { | |
| const k = imageKeyFromPayload(pl); | |
| if (!k || seen.has(k)) continue; | |
| seen.add(k); | |
| uniq.push(pl); | |
| } | |
| payloads = uniq; | |
| const b = ensureBatch(currentBatchId, tab.id, imagesFrameId); | |
| b.total1 = payloads.length; | |
| for (const pl of payloads) { | |
| const k = imageKeyFromPayload(pl); | |
| if (!k || b.items.has(k)) continue; | |
| b.items.set(k, { | |
| payload: pl, | |
| attempt: 1, | |
| status: "queued", | |
| lastError: "", | |
| }); | |
| } | |
| batchUpdateToast(b, "รับภาพ", true); | |
| payloads.forEach((p) => enqueue(p, tab.id, imagesFrameId)); | |
| return; | |
| } | |
| } catch (e) { | |
| error("[menu] handler error", e); | |
| } | |
| }); | |
| chrome.runtime.onConnect.addListener((port) => { | |
| if (!port || port.name !== KEEPALIVE_PORT_NAME) return; | |
| keepAlivePorts.add(port); | |
| const tabId = port.sender?.tab?.id; | |
| const frameId = port.sender?.frameId; | |
| try { | |
| port.onMessage.addListener(() => void 0); | |
| port.onDisconnect.addListener(() => { | |
| keepAlivePorts.delete(port); | |
| if (!Number.isFinite(tabId)) return; | |
| if (Number.isFinite(frameId) && frameId !== 0) return; | |
| bumpTabSession(tabId, ""); | |
| cancelTabWork(tabId, "page_unloaded"); | |
| }); | |
| } catch { | |
| keepAlivePorts.delete(port); | |
| } | |
| }); | |
| chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { | |
| if (!Number.isFinite(tabId)) return; | |
| if (changeInfo.status !== "loading") return; | |
| const href = changeInfo.url || tab?.url || ""; | |
| if (isMangaDexPageUrl(href)) return; | |
| bumpTabSession(tabId, href); | |
| cancelTabWork(tabId, "navigation"); | |
| }); | |
| chrome.tabs.onRemoved.addListener((tabId) => { | |
| cancelTabWork(tabId, "tab_closed"); | |
| tabSessionById.delete(tabId); | |
| }); | |
| chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { | |
| const t = String(msg?.type || ""); | |
| if (t === "GET_WS_STATUS") { | |
| sendResponse({ status: wsStatus, ready: wsReady }); | |
| return true; | |
| } | |
| if (t === "GET_BATCH_STATUS") { | |
| sendResponse({ ok: true, batch: lastBatchStatus }); | |
| return true; | |
| } | |
| if (t === "GET_API_STATUS") { | |
| sendResponse({ | |
| ok: healthCache.ok, | |
| ts: healthCache.ts, | |
| build: healthCache.build, | |
| }); | |
| return true; | |
| } | |
| if (t === "API_URL_CHANGED") { | |
| try { | |
| ws?.close(1000, "api_url_changed"); | |
| } catch {} | |
| ws = null; | |
| wsReady = false; | |
| wsPromise = null; | |
| wsStatus = "disconnected"; | |
| blockSendsBecauseWsEnded = false; | |
| healthCache.ts = 0; | |
| connectWebSocketOnce().catch(() => {}); | |
| getApiBase() | |
| .then((b) => warmupApi(b)) | |
| .catch(() => {}); | |
| sendResponse({ ok: true }); | |
| return true; | |
| } | |
| if (t === "TP_CONTENT_READY") { | |
| const tabId = sender?.tab?.id; | |
| if (msg?.top && Number.isFinite(tabId)) { | |
| ensureTabSession(tabId, msg?.href); | |
| } | |
| ev("content.ready", { | |
| tabId: sender?.tab?.id || 0, | |
| frameId: sender?.frameId || 0, | |
| href: String(msg?.href || ""), | |
| ver: String(msg?.ver || ""), | |
| top: Boolean(msg?.top), | |
| }); | |
| sendResponse({ ok: true }); | |
| return true; | |
| } | |
| if (t === "TP_LOCATION_CHANGED") { | |
| const tabId = sender?.tab?.id; | |
| if (msg?.top && Number.isFinite(tabId)) { | |
| ensureTabSession(tabId, msg?.href); | |
| } | |
| sendResponse({ ok: true }); | |
| return true; | |
| } | |
| if (t === "TP_MD_CACHE_GET") { | |
| pruneMdCache(); | |
| const includeNewImg = Boolean(msg?.includeNewImg); | |
| const lang = typeof msg?.lang === "string" ? msg.lang : ""; | |
| const mode = typeof msg?.mode === "string" ? msg.mode : ""; | |
| if (!lang || !mode) { | |
| sendResponse({ items: {} }); | |
| return true; | |
| } | |
| const rawKeys = Array.isArray(msg?.keys) ? msg.keys : []; | |
| const keys = rawKeys.slice(0, includeNewImg ? 6 : 600); | |
| const items = {}; | |
| for (const k of keys) { | |
| const mdKey = String(k || ""); | |
| if (!mdKey) continue; | |
| const cacheKey = mdCacheKey(mdKey, lang, mode); | |
| if (!cacheKey) continue; | |
| const rec = mdCacheByKey.get(cacheKey); | |
| if (!rec) continue; | |
| const derivedNewImg = | |
| rec.newImg || | |
| rec?.result?.imageDataUri || | |
| rec?.result?.imageDataURI || | |
| rec?.result?.image || | |
| rec?.result?.imageUrl || | |
| rec?.result?.image_url || | |
| rec?.result?.imageURL || | |
| null; | |
| const hasNewImg = Boolean(derivedNewImg); | |
| items[mdKey] = { | |
| hasNewImg, | |
| result: stripImageFields(rec.result), | |
| ...(includeNewImg ? { newImg: derivedNewImg } : {}), | |
| }; | |
| } | |
| sendResponse({ items }); | |
| return true; | |
| } | |
| if (t === "TP_LOG") { | |
| const lvl = String(msg?.level || "info"); | |
| const data = msg?.data || {}; | |
| const line = "[content] " + String(msg?.msg || ""); | |
| if (lvl === "error") error(line, data); | |
| else if (lvl === "warn") warn(line, data); | |
| else info(line, data); | |
| sendResponse({ ok: true }); | |
| return true; | |
| } | |
| if (t === "CANCEL_BATCH") { | |
| try { | |
| const bid = String(msg.batchId || ""); | |
| if (bid && typeof pendingByJob?.keys === "function") { | |
| for (const [jid, rec] of Array.from(pendingByJob.entries())) { | |
| if (rec?.batchId === bid) pendingByJob.delete(jid); | |
| } | |
| } | |
| sendResponse({ success: true }); | |
| } catch (e) { | |
| sendResponse({ success: false, error: String((e && e.message) || e) }); | |
| } | |
| return true; | |
| } | |
| if (t === "fetchImageBlob") { | |
| (async () => { | |
| try { | |
| const u = String(msg.url || "").trim(); | |
| const res = await fetch(u, { | |
| credentials: "include", | |
| redirect: "follow", | |
| referrer: msg.pageUrl || "about:client", | |
| }); | |
| if (!res.ok) throw new Error("HTTP " + res.status); | |
| const blob = await res.blob(); | |
| const dataUrl = await blobToDataUri(blob); | |
| const comma = dataUrl.indexOf(","); | |
| sendResponse({ | |
| success: true, | |
| blobData: comma >= 0 ? dataUrl.slice(comma + 1) : "", | |
| mimeType: blob.type || "application/octet-stream", | |
| }); | |
| } catch (e) { | |
| warn("fetchImageBlob failed", e); | |
| sendResponse({ | |
| success: false, | |
| error: e && e.message ? e.message : String(e), | |
| }); | |
| } | |
| })(); | |
| return true; | |
| } | |
| return false; | |
| }); | |
| chrome.runtime.onInstalled.addListener(() => { | |
| recreateMenus(); | |
| getApiBase() | |
| .then((b) => warmupApi(b)) | |
| .catch(() => {}); | |
| }); | |
| chrome.runtime.onStartup?.addListener(() => { | |
| recreateMenus(); | |
| getApiBase() | |
| .then((b) => warmupApi(b)) | |
| .catch(() => {}); | |
| }); | |
| chrome.storage.local.get({ maxConcurrency: 0 }, ({ maxConcurrency }) => { | |
| MAX_CONCURRENCY = Number(maxConcurrency) >= 0 ? Number(maxConcurrency) : 0; | |
| const eff = effectiveMaxConcurrency(); | |
| info( | |
| "MAX_CONCURRENCY =", | |
| MAX_CONCURRENCY || "unlimited", | |
| "soft =", | |
| softMaxConcurrency, | |
| "forceSoft =", | |
| forceSoftMaxConcurrency, | |
| "effective =", | |
| eff || "unlimited", | |
| ); | |
| }); | |