Spaces:
Sleeping
Sleeping
| // background.js β ATS Resume Generator service worker | |
| // No ATS or model-inference logic here. Only: read storage, call API, trigger download. | |
| // R20: bundled default resume (self.DEFAULT_RESUME_LATEX) so a brand-new user can | |
| // Run before configuring anything. importScripts works in a classic MV3 worker. | |
| try { importScripts('default_resume.js'); } catch (_) { /* default optional */ } | |
| function safeRespond(sendResponse, data) { | |
| try { sendResponse(data); } catch (_) { /* popup closed before reply */ } | |
| } | |
| // βββ Storage source-of-truth (resilient Run; survives popup close + worker suspend) ββ | |
| // The background service worker OWNS generation. It persists the run lifecycle to | |
| // chrome.storage.local keyed by the normalized job URL, so a reopened popup (or a | |
| // recycled MV3 worker) can always surface the running marker or the final result. | |
| // Mirrors the popup's `ats_results` map + `normalizeUrl` contract exactly. | |
| const RESULTS_KEY = 'ats_results'; // map: { [urlKey]: {status, result, job_title, company, startedAt, savedAt} } | |
| const MAX_SAVED = 40; // retained history entries (R19); quota-guarded below | |
| const BYTE_BUDGET = 4500000; // ~4.5MB headroom under the ~5MB local quota | |
| function normalizeUrl(u) { | |
| // Drop the #fragment so an in-page refresh still matches; keep the query. | |
| return (u || '').split('#')[0]; | |
| } | |
| // Approximate serialized size of the results map (bytes ~= JSON length). | |
| function approxSize(map) { | |
| try { return JSON.stringify(map).length; } catch (_) { return 0; } | |
| } | |
| // Strip base64 artifacts from the OLDEST entries (keeping the newest `keepNewest` | |
| // full) so the history row still renders (metadata + scores) but the heavy | |
| // docx/pdf/tex payloads are dropped. Flags pruned entries as metadata-only. | |
| function stripOldestArtifacts(map, keepNewest) { | |
| const ks = Object.keys(map).sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| for (const k of ks.slice(0, Math.max(0, ks.length - keepNewest))) { | |
| const r = map[k] && map[k].result; | |
| if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { | |
| delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; | |
| r.artifacts_pruned = true; | |
| } | |
| } | |
| } | |
| async function resolveActiveTabUrl() { | |
| try { | |
| const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); | |
| return tab ? tab.url : ''; | |
| } catch (_) { | |
| return ''; | |
| } | |
| } | |
| // Shallow-merge a patch into the entry for `urlKey`, preserving existing fields, | |
| // stamping savedAt, and pruning to the most-recent MAX_SAVED entries. | |
| async function writeEntry(urlKey, patch) { | |
| if (!urlKey) return; | |
| try { | |
| const store = await new Promise(res => chrome.storage.local.get([RESULTS_KEY], res)); | |
| const map = store[RESULTS_KEY] || {}; | |
| map[urlKey] = { ...(map[urlKey] || {}), ...patch, savedAt: Date.now() }; | |
| // 1. Prune to the MAX_SAVED most-recent entries by recency. | |
| let ks = Object.keys(map); | |
| if (ks.length > MAX_SAVED) { | |
| ks.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| for (const k of ks.slice(0, ks.length - MAX_SAVED)) delete map[k]; | |
| } | |
| // 2. Byte-budget guard: progressively strip artifacts from the oldest | |
| // entries (keeping fewer full each pass), then drop whole oldest entries, | |
| // until under budget. The just-written (newest) entry stays full. | |
| let keep = MAX_SAVED; | |
| while (approxSize(map) > BYTE_BUDGET && keep > 1) { | |
| keep = Math.max(1, Math.floor(keep / 2)); | |
| stripOldestArtifacts(map, keep); | |
| } | |
| while (approxSize(map) > BYTE_BUDGET && Object.keys(map).length > 1) { | |
| const oldest = Object.keys(map) | |
| .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0))[0]; | |
| delete map[oldest]; | |
| } | |
| // 3. Write; on a QUOTA_BYTES error, degrade gracefully (strip oldest | |
| // artifacts, else drop oldest entry) and retry β never lose the newest. | |
| let attempts = 0; | |
| while (attempts++ < MAX_SAVED + 2) { | |
| try { | |
| await new Promise((res, rej) => chrome.storage.local.set({ [RESULTS_KEY]: map }, () => { | |
| const e = chrome.runtime.lastError; | |
| if (e) rej(new Error(e.message)); else res(); | |
| })); | |
| return; // success | |
| } catch (_) { | |
| const ksOld = Object.keys(map) | |
| .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| if (ksOld.length <= 1) return; // can't reduce further without losing newest | |
| const oldest = ksOld[0]; | |
| const r = map[oldest] && map[oldest].result; | |
| if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { | |
| delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; r.artifacts_pruned = true; | |
| } else { | |
| delete map[oldest]; | |
| } | |
| } | |
| } | |
| } catch (_) { /* quota or transient storage error β non-fatal */ } | |
| } | |
| chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { | |
| if (msg.type === 'GENERATE') { | |
| handleGenerate(msg) | |
| .then((data) => safeRespond(sendResponse, data)) | |
| .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); | |
| return true; | |
| } | |
| if (msg.type === 'REPAIR') { | |
| handleRepair(msg) | |
| .then((data) => safeRespond(sendResponse, data)) | |
| .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); | |
| return true; | |
| } | |
| if (msg.type === 'DOWNLOAD') { | |
| handleDownload(msg).catch(console.error); | |
| return false; | |
| } | |
| }); | |
| // βββ REPAIR (External ATS Feedback Repair Mode) βββββββββββββββββββββββββββββββ | |
| async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url }) { | |
| const data = await new Promise(res => chrome.storage.local.get( | |
| ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res | |
| )); | |
| if (!data.api_url || !data.api_token) { | |
| return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; | |
| } | |
| // R20: fall back to the bundled default resume when nothing is stored. | |
| let resumeLatex = data.resume_latex; | |
| if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { | |
| resumeLatex = self.DEFAULT_RESUME_LATEX; | |
| } | |
| const hasLatex = !!(resumeLatex && resumeLatex.trim()); | |
| if (!hasLatex && !data.resume_b64) { | |
| return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; | |
| } | |
| const formData = new FormData(); | |
| formData.append('jd_text', jd_text); | |
| formData.append('job_title', job_title || ''); | |
| formData.append('company', company || ''); | |
| formData.append('feedback', feedback || ''); | |
| formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); | |
| if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); | |
| // LaTeX takes priority; only attach the PDF when no LaTeX is saved. | |
| if (hasLatex) { | |
| formData.append('resume_latex', resumeLatex); | |
| } else { | |
| const binaryStr = atob(data.resume_b64); | |
| const bytes = new Uint8Array(binaryStr.length); | |
| for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); | |
| const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); | |
| formData.append('resume', resumeBlob, 'resume.pdf'); | |
| } | |
| let resp; | |
| try { | |
| resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/repair-with-feedback`, { | |
| method: 'POST', | |
| headers: { 'X-Api-Token': data.api_token }, | |
| body: formData, | |
| }); | |
| } catch (networkErr) { | |
| return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; | |
| } | |
| let result; | |
| try { | |
| result = await resp.json(); | |
| } catch { | |
| return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; | |
| } | |
| if (!resp.ok) { | |
| return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; | |
| } | |
| // An improved result is also persisted so reopening surfaces it (source of truth). | |
| const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); | |
| await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); | |
| return result; | |
| } | |
| // βββ GENERATE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url }) { | |
| // 1. Read settings from storage | |
| const data = await new Promise(res => chrome.storage.local.get( | |
| ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res | |
| )); | |
| if (!data.api_url || !data.api_token) { | |
| return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; | |
| } | |
| // R20: fall back to the bundled default resume when nothing is stored. | |
| let resumeLatex = data.resume_latex; | |
| if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { | |
| resumeLatex = self.DEFAULT_RESUME_LATEX; | |
| } | |
| const hasLatex = !!(resumeLatex && resumeLatex.trim()); | |
| if (!hasLatex && !data.resume_b64) { | |
| return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; | |
| } | |
| // The popup sends the job URL so persistence is keyed consistently; if it's | |
| // missing (e.g. a recycled worker handling a queued message), fall back to the | |
| // active tab. Storage β not in-memory worker state β is the source of truth. | |
| const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); | |
| // 2. Write the running marker BEFORE awaiting the API so a closed/reopened | |
| // popup restores a spinner instead of resetting to idle "Run". | |
| await writeEntry(urlKey, { | |
| status: 'running', | |
| startedAt: Date.now(), | |
| job_title: job_title || '', | |
| company: company || '', | |
| }); | |
| // 3. Perform the network round-trip (no popup dependency). | |
| const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms }); | |
| // 4. Overwrite the marker with the terminal state β done OR error β so the | |
| // popup never shows a false/forever spinner. Done regardless of popup state. | |
| if (result && result.error) { | |
| await writeEntry(urlKey, { status: 'error', result }); | |
| } else { | |
| await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); | |
| } | |
| return result; // still returned to the popup so an open popup updates immediately | |
| } | |
| // Network/parse layer for GENERATE. Returns a result object (success or {error}). | |
| async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms }) { | |
| // Build multipart/form-data. LaTeX takes priority over the PDF. | |
| const formData = new FormData(); | |
| formData.append('jd_text', jd_text); | |
| formData.append('job_title', job_title || ''); | |
| formData.append('company', company || ''); | |
| formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); | |
| if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); | |
| if (hasLatex) { | |
| formData.append('resume_latex', resumeLatex || data.resume_latex); | |
| } else { | |
| const binaryStr = atob(data.resume_b64); | |
| const bytes = new Uint8Array(binaryStr.length); | |
| for (let i = 0; i < binaryStr.length; i++) { | |
| bytes[i] = binaryStr.charCodeAt(i); | |
| } | |
| const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); | |
| formData.append('resume', resumeBlob, 'resume.pdf'); | |
| } | |
| let resp; | |
| try { | |
| resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/generate`, { | |
| method: 'POST', | |
| headers: { 'X-Api-Token': data.api_token }, | |
| body: formData, | |
| }); | |
| } catch (networkErr) { | |
| return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; | |
| } | |
| let result; | |
| try { | |
| result = await resp.json(); | |
| } catch { | |
| return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; | |
| } | |
| if (!resp.ok) { | |
| return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; | |
| } | |
| return result; | |
| } | |
| // βββ DOWNLOAD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleDownload({ format, b64, filename }) { | |
| if (!b64) return; | |
| // MV3 service workers do NOT support URL.createObjectURL β it's undefined here, | |
| // which silently broke the download. Use a data: URL instead (chrome.downloads | |
| // accepts data: URLs directly from the service worker). | |
| let mimeType; | |
| if (format === 'pdf') { | |
| mimeType = 'application/pdf'; | |
| } else if (format === 'tex') { | |
| mimeType = 'application/x-tex'; | |
| } else { | |
| mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; | |
| } | |
| const url = `data:${mimeType};base64,${b64}`; | |
| try { | |
| await chrome.downloads.download({ | |
| url, | |
| filename: filename || `resume.${format}`, | |
| saveAs: false, | |
| }); | |
| } catch (err) { | |
| console.error('[download] failed:', err); | |
| } | |
| } | |