// 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 ''; } } function normalizeText(s) { return String(s || '') .toLowerCase() .replace(/[_-]+/g, ' ') .replace(/[^a-z0-9\s]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function parseProfileJson(raw) { if (!raw || !String(raw).trim()) return {}; try { const data = JSON.parse(raw); return data && typeof data === 'object' ? data : {}; } catch (_) { return {}; } } function profileIndex(profile) { const out = { ...(profile || {}) }; const full = String(out.full_name || '').trim(); if (full) { const parts = full.split(/\s+/); if (!out.first_name && parts.length) out.first_name = parts[0]; if (!out.last_name && parts.length > 1) out.last_name = parts.slice(1).join(' '); } return out; } function valueOf(profile, key) { const v = profile ? profile[key] : ''; return typeof v === 'string' || typeof v === 'number' ? String(v).trim() : ''; } function fieldText(field) { return normalizeText([ field.label, field.name, field.placeholder, field.help_text, field.type, ].filter(Boolean).join(' ')); } function isLongAnswerField(field) { return field.tag === 'textarea' || field.type === 'textarea' || (field.type === 'text' && /cover letter|why|tell us|describe|summary|experience|motivation|fit/.test(fieldText(field))); } function localAutofillValue(field, profile) { const text = fieldText(field); const fullName = valueOf(profile, 'full_name'); const firstName = valueOf(profile, 'first_name'); const lastName = valueOf(profile, 'last_name'); if (!text) return ''; if (/first name|given name/.test(text)) return firstName; if (/last name|family name|surname/.test(text)) return lastName; if (/full name|your name|applicant name|candidate name/.test(text)) return fullName; if (/email|e mail/.test(text)) return valueOf(profile, 'email'); if (/phone|mobile|contact number|telephone/.test(text)) return valueOf(profile, 'phone'); if (/linkedin/.test(text)) return valueOf(profile, 'linkedin_url'); if (/github/.test(text)) return valueOf(profile, 'github_url'); if (/portfolio|website|personal site/.test(text)) return valueOf(profile, 'portfolio_url'); if (/current company|current employer|employer|organization/.test(text)) return valueOf(profile, 'current_company'); if (/current title|job title|designation|headline/.test(text)) return valueOf(profile, 'current_title'); if (/location|current city|city state|address/.test(text)) return valueOf(profile, 'location'); if (/years of experience|total experience|experience in years|how many years/.test(text)) { return valueOf(profile, 'years_experience'); } if (/notice period|joining period|available to join|when can you start|start date/.test(text)) { return valueOf(profile, 'notice_period'); } if (/authorized to work|work authorization/.test(text)) return valueOf(profile, 'work_authorization'); if (/require sponsorship|need sponsorship|visa sponsorship/.test(text)) { return valueOf(profile, 'requires_sponsorship'); } if (/visa status/.test(text)) return valueOf(profile, 'visa_status'); if (/current salary|current ctc/.test(text)) return valueOf(profile, 'current_salary'); if (/expected salary|salary expectation|expected ctc|compensation expectation/.test(text)) { return valueOf(profile, 'expected_salary'); } if (/notice|relocation|work mode|remote|hybrid/.test(text)) return valueOf(profile, 'additional_notes'); return ''; } function pickFieldsForApi(fields, localAnswers) { const answered = new Set((localAnswers || []).map((a) => a.id)); return (fields || []).filter((field) => { if (!field || !field.id || answered.has(field.id)) return false; const text = fieldText(field); if (!text) return false; if (/search|filter|sort/.test(text)) return false; return isLongAnswerField(field) || (field.tag === 'select' || field.type === 'radio') || /why|motivation|about you|introduce yourself|cover letter|summary|fit|salary|authorization|sponsorship|relocate/.test(text); }); } async function runFormAssist({ data, resumeLatex, profile, fields, jd_text, job_title, company }) { const formData = new FormData(); formData.append('resume_latex', resumeLatex || ''); formData.append('profile_json', JSON.stringify(profile || {})); formData.append('fields_json', JSON.stringify(fields || [])); formData.append('jd_text', jd_text || ''); formData.append('job_title', job_title || ''); formData.append('company', company || ''); let resp; try { resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/form-assist`, { 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; } async function handleAutofillForm({ fields, jd_text, job_title, company }) { const data = await new Promise(res => chrome.storage.local.get( ['api_url', 'api_token', 'resume_latex', 'autofill_profile_json'], res )); let resumeLatex = data.resume_latex; if ((!resumeLatex || !resumeLatex.trim()) && self.DEFAULT_RESUME_LATEX) { resumeLatex = self.DEFAULT_RESUME_LATEX; } const profile = profileIndex(parseProfileJson(data.autofill_profile_json)); const localAnswers = []; for (const field of (fields || [])) { const value = localAutofillValue(field, profile); if (value) { localAnswers.push({ id: field.id, value, confidence: 'high', source: 'profile', }); } } const needsApi = pickFieldsForApi(fields, localAnswers); let apiAnswers = []; let warning = ''; if (needsApi.length) { if (!data.api_url || !data.api_token) { warning = 'API not configured. Filled only the common profile fields.'; } else if (!resumeLatex || !resumeLatex.trim()) { warning = 'Resume LaTeX is not configured. Filled only the common profile fields.'; } else { const apiResult = await runFormAssist({ data, resumeLatex, profile, fields: needsApi, jd_text, job_title, company, }); if (apiResult && apiResult.error) { warning = apiResult.detail || apiResult.error; } else { apiAnswers = Array.isArray(apiResult?.answers) ? apiResult.answers : []; } } } const merged = new Map(); for (const answer of [...apiAnswers, ...localAnswers]) { if (!answer || !answer.id || !String(answer.value || '').trim()) continue; merged.set(answer.id, answer); } return { answers: Array.from(merged.values()), local_count: localAnswers.length, api_count: apiAnswers.filter((a) => String(a?.value || '').trim()).length, field_count: (fields || []).length, warning, }; } // 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; } if (msg.type === 'AUTOFILL_FORM') { handleAutofillForm(msg) .then((data) => safeRespond(sendResponse, data)) .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); return true; } }); // ─── REPAIR (External ATS Feedback Repair Mode) ─────────────────────────────── async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url, version }) { 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); formData.append('version', version || 'v1'); // 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, version }) { // 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, version }); // 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, version }) { // 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); formData.append('version', version || 'v1'); 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); } }