JAA-ATS-Tool / extension /background.js
saitejatirunagari's picture
feat(09-01): hardcoded default resume for HF + extension [R20]
a52b643
Raw
History Blame
13.2 kB
// 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);
}
}