-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathprofile.js
More file actions
1841 lines (1660 loc) · 81 KB
/
profile.js
File metadata and controls
1841 lines (1660 loc) · 81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @file profile.js
* @description Manages the full-page Profile tab for the JobMatchAI Chrome extension.
*
* Responsibilities:
* - Resume upload and text extraction (PDF via pdf.js, DOCX via mammoth)
* - AI-powered resume parsing via the background service worker (PARSE_RESUME)
* - Editable profile form: contact info, skills, certifications, experience,
* education, and projects — all kept in sync with the in-memory `profileData` object
* - Multi-slot resume management: up to 3 named resume profiles that can be
* switched, renamed, and persisted independently in chrome.storage.local
* - Q&A list: a set of pre-filled answers to common job-application questions,
* backed by DEFAULT_QA_QUESTIONS; supports category filtering and migration of
* stored entries to keep type/options in sync with the current defaults
* - AI provider settings: provider dropdown, model selection, API key, temperature
* - Applied jobs tracker: loads the saved application log and renders a sortable table
* - Stats dashboard: computes aggregate match-score stats and top missing skills
* directly from the jm_analysisCache entry in chrome.storage.local
* - Hash-based navigation so external pages can deep-link to a specific tab
* (e.g. profile.html#settings)
*/
// ─── State variables ─────────────────────────────────────────────────────────
/**
* In-memory representation of the currently active resume profile.
* Populated from chrome.storage via GET_PROFILE on init, updated by the form,
* and flushed to the active slot on every save.
* @type {{
* name: string, email: string, phone: string, location: string,
* linkedin: string, website: string, summary: string,
* skills: string[], experience: Object[], education: Object[],
* certifications: string[], projects: Object[],
* resumeFileName?: string
* }}
*/
let profileData = {
name: '', email: '', phone: '', location: '',
linkedin: '', website: '', summary: '',
skills: [], experience: [], education: [],
certifications: [], projects: []
};
/**
* Tracks whether the profile form has unsaved changes.
* Set to true on any form edit; reset to false after a successful save.
* @type {boolean}
*/
let profileDirty = false;
/**
* Timer ID for the debounced autosave. Cleared and reset on every edit
* so we only save once the user stops typing for 2 seconds.
* @type {number|null}
*/
let autosaveTimer = null;
/**
* Marks the profile as dirty, highlights the save button, and schedules
* an autosave after 2 seconds of inactivity.
*/
function markProfileDirty() {
profileDirty = true;
const btn = document.getElementById('saveProfileBtn');
if (btn) btn.style.background = '#f59e0b';
// Debounced autosave: reset timer on every change, save after 2s idle
if (autosaveTimer) clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => autoSaveProfile(), 2000);
}
/**
* Silently saves the profile without user interaction.
* Syncs form fields, saves to storage, and updates slot data.
*/
async function autoSaveProfile() {
if (!profileDirty) return;
// Sync plain text fields from the form
profileData.name = document.getElementById('pName').value.trim();
profileData.email = document.getElementById('pEmail').value.trim();
profileData.phone = document.getElementById('pPhone').value.trim();
profileData.location = document.getElementById('pLocation').value.trim();
profileData.linkedin = document.getElementById('pLinkedin').value.trim();
profileData.website = document.getElementById('pWebsite').value.trim();
profileData.summary = document.getElementById('pSummary').value.trim();
try {
await sendMessage({ type: 'SAVE_PROFILE', profile: profileData });
profileSlots[activeSlot] = JSON.parse(JSON.stringify(profileData));
await chrome.storage.local.set({ profileSlots });
updateSlotButtons();
markProfileClean();
const btn = document.getElementById('saveProfileBtn');
if (btn) {
btn.textContent = 'Saved';
setTimeout(() => { btn.textContent = 'Save Profile'; }, 1500);
}
} catch (_) {
// Silent fail — user can still use the manual save button
}
}
/**
* Marks the profile as clean and reverts the save button to its default style.
*/
function markProfileClean() {
profileDirty = false;
const btn = document.getElementById('saveProfileBtn');
if (btn) btn.style.background = '';
}
// Warn the user when navigating away with unsaved profile changes
window.addEventListener('beforeunload', (e) => {
if (profileDirty) { e.preventDefault(); }
});
/**
* In-memory list of Q&A entries displayed in the Q&A tab.
* Each entry: { question, answer, category, type, options? }
* Loaded from storage on init and flushed via SAVE_QA_LIST.
* @type {Array<{question: string, answer: string, category: string, type: string, options?: string[]}>}
*/
let qaList = [];
/**
* Registry of available AI providers fetched from the background on init.
* Keyed by provider ID (e.g. 'anthropic', 'openai'). Used to populate the
* provider dropdown and drive per-provider model lists / key placeholders.
* @type {Object.<string, {name: string, models: Object[], defaultModel: string, keyPlaceholder: string, hint: string, free?: boolean}>}
*/
let providerData = {};
// ─── Helper utilities ─────────────────────────────────────────────────────────
/**
* Wraps chrome.runtime.sendMessage in a Promise so callers can use async/await.
* Rejects on runtime errors, missing responses, or when the background signals
* `success: false`.
*
* @param {Object} msg - Message object with at minimum a `type` string field.
* @returns {Promise<*>} Resolves with `resp.data` from the background handler.
*/
function sendMessage(msg) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(msg, (resp) => {
// chrome.runtime.lastError is set when the message could not be delivered
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
// A null/undefined response means the background script did not reply at all
if (!resp) return reject(new Error('No response from background'));
// The background signals logical failure via resp.success === false
if (!resp.success) return reject(new Error(resp.error));
resolve(resp.data);
});
});
}
/**
* Briefly displays a toast notification at the bottom of the page.
* The 'show' class triggers a CSS transition; it is removed after 2.5 s.
*
* @param {string} msg - Human-readable message to display.
*/
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2500);
}
/**
* Updates the status text below the upload zone with a semantic type class
* ('loading' | 'success' | 'error') so CSS can colour it appropriately.
*
* @param {string} text - Status message.
* @param {string} type - One of 'loading', 'success', or 'error'.
*/
function setUploadStatus(text, type) {
const el = document.getElementById('uploadStatus');
el.textContent = text;
// Replace all existing type classes with the new one
el.className = 'upload-status ' + type;
}
/**
* Replaces the upload zone's inner HTML with a "resume loaded" confirmation
* that shows the file name and a hint to re-upload if desired.
*
* @param {string|null} fileName - The resume file name (or profile name) to display.
*/
function showResumeLoaded(fileName) {
const zone = document.getElementById('uploadZone');
const name = fileName || 'Resume';
zone.innerHTML = `
<div class="icon" style="color: #059669;">✅</div>
<div class="text" style="color: #059669; font-weight: 600;">${escapeHTML(name)}</div>
<div class="hint">Resume loaded. Click or drag to upload a different one.</div>
`;
}
// ─── Tab switching ────────────────────────────────────────────────────────────
/**
* Attach click listeners to every `.tab` button.
* Activating a tab deactivates all others and shows the matching `.tab-content`
* panel. Lazy-loads data for the 'applied' and 'stats' tabs on first reveal.
*/
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
// Deactivate all tabs and panels
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
// Show the corresponding panel; panel IDs follow the convention "tab-<name>"
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
// Refresh data-heavy tabs every time they become visible
if (tab.dataset.tab === 'applied') loadAppliedJobs();
if (tab.dataset.tab === 'stats') renderStats();
});
});
// ─── Resume upload ────────────────────────────────────────────────────────────
/** DOM references kept at module scope so multiple listeners can share them. */
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
// Clicking anywhere in the drop zone opens the OS file picker
uploadZone.addEventListener('click', () => fileInput.click());
// Drag-over: prevent default to allow the drop event and add visual feedback
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
// Drag-leave: remove visual feedback when the dragged item leaves the zone
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
// Drop: extract the first dropped file and process it
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
// Standard <input type="file"> change event — also feeds into handleFile
fileInput.addEventListener('change', () => {
if (fileInput.files.length) handleFile(fileInput.files[0]);
});
/**
* Validates, extracts text from, and AI-parses an uploaded resume file.
* Supports PDF (via pdf.js) and DOCX (via mammoth).
* On success: merges parsed fields into `profileData`, repopulates the form,
* and updates the upload zone to reflect the loaded file.
*
* @param {File} file - The File object supplied by the input or drop event.
*/
async function handleFile(file) {
// Derive the file extension to decide which extractor to use
const ext = file.name.split('.').pop().toLowerCase();
if (!['pdf', 'docx'].includes(ext)) {
setUploadStatus('Please upload a PDF or DOCX file.', 'error');
return;
}
const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15 MB
if (file.size > MAX_FILE_SIZE) {
setUploadStatus('File is too large (max 15 MB). Please upload a smaller file.', 'error');
return;
}
setUploadStatus('Extracting text from ' + file.name + '...', 'loading');
try {
let rawText;
if (ext === 'pdf') {
rawText = await extractPDF(file);
} else {
rawText = await extractDOCX(file);
}
// A very short extraction usually means a scanned image PDF with no text layer
if (!rawText || rawText.trim().length < 20) {
setUploadStatus('Could not extract enough text from file.', 'error');
return;
}
setUploadStatus('Parsing resume with AI... This may take a moment.', 'loading');
// Hand off raw text to the background script which calls the configured AI provider
const parsed = await sendMessage({ type: 'PARSE_RESUME', rawText });
// Merge parsed fields into existing profileData while preserving any extra keys
// (e.g. resumeFileName from a previous save) and stamp the new file name
profileData = { ...profileData, ...parsed, resumeFileName: file.name, resumeFileType: ext };
populateProfileForm();
showResumeLoaded(file.name);
setUploadStatus('Resume parsed successfully! Review and edit below.', 'success');
markProfileDirty();
// Store raw DOCX bytes for tailored resume generation (direct DOCX editing)
if (ext === 'docx') {
const ab = await file.arrayBuffer();
const bytes = new Uint8Array(ab);
// Convert to base64 in chunks to avoid call stack overflow on large files
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
}
const base64 = btoa(binary);
await sendMessage({ type: 'SAVE_RAW_RESUME', rawResumeBase64: base64, fileType: ext });
} else {
await sendMessage({ type: 'SAVE_RAW_RESUME', rawResumeBase64: null, fileType: ext });
}
// Auto-fill Q&A answers from parsed resume data
prefillQAFromProfile(profileData);
} catch (err) {
setUploadStatus('Error: ' + err.message, 'error');
}
}
/**
* Extracts plain text from a PDF file using pdf.js.
* Iterates through every page and concatenates the text items, separated by
* newlines between pages.
*
* @param {File} file - A File object whose content is a valid PDF.
* @returns {Promise<string>} Concatenated text from all pages.
*/
async function extractPDF(file) {
const arrayBuffer = await file.arrayBuffer();
// Point pdf.js at the bundled worker script shipped with the extension
pdfjsLib.GlobalWorkerOptions.workerSrc = 'libs/pdf.worker.min.js';
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let text = '';
// pdf.js pages are 1-indexed
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
// Each item in the content stream has a `str` property; join with spaces
text += content.items.map(item => item.str).join(' ') + '\n';
}
return text;
}
/**
* Extracts plain text from a DOCX file using the mammoth library.
*
* @param {File} file - A File object whose content is a valid DOCX.
* @returns {Promise<string>} Extracted raw text.
*/
async function extractDOCX(file) {
const arrayBuffer = await file.arrayBuffer();
// mammoth.extractRawText strips all formatting and returns plain text
const result = await mammoth.extractRawText({ arrayBuffer });
return result.value;
}
// ─── Profile form population ──────────────────────────────────────────────────
/**
* Writes all fields from the in-memory `profileData` object into the HTML form.
* Also triggers re-renders of all list sections (skills, certs, experience,
* education, projects).
*/
function populateProfileForm() {
document.getElementById('pName').value = profileData.name || '';
document.getElementById('pEmail').value = profileData.email || '';
document.getElementById('pPhone').value = profileData.phone || '';
document.getElementById('pLocation').value = profileData.location || '';
document.getElementById('pLinkedin').value = profileData.linkedin || '';
document.getElementById('pWebsite').value = profileData.website || '';
document.getElementById('pSummary').value = profileData.summary || '';
renderSkills();
renderCerts();
renderExperience();
renderEducation();
renderProjects();
}
// ─── Dirty tracking for personal info fields ─────────────────────────────────
['pName', 'pEmail', 'pPhone', 'pLocation', 'pLinkedin', 'pWebsite', 'pSummary'].forEach(id => {
document.getElementById(id).addEventListener('input', markProfileDirty);
});
// ─── Skills ───────────────────────────────────────────────────────────────────
/**
* Clears and re-renders the skills tag list from `profileData.skills`.
* Each tag contains an inline remove button whose click handler splices the
* corresponding index from the array and triggers a re-render.
*/
function renderSkills() {
const container = document.getElementById('skillsContainer');
container.innerHTML = '';
(profileData.skills || []).forEach((skill, i) => {
const tag = document.createElement('span');
tag.className = 'skill-tag';
// Embed the array index in a data attribute so the remove handler knows what to splice
tag.innerHTML = `${escapeHTML(skill)} <span class="remove" data-idx="${i}">×</span>`;
container.appendChild(tag);
});
// Wire remove buttons after all tags exist in the DOM
container.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', () => {
profileData.skills.splice(parseInt(btn.dataset.idx), 1);
renderSkills();
markProfileDirty();
});
});
}
/**
* Reads the skill input field, deduplicates against the existing list,
* pushes a new entry, and re-renders the tag list.
*/
function addSkill() {
const input = document.getElementById('skillInput');
const val = input.value.trim();
if (!val) return;
// Guard against undefined array in case profileData was freshly created
if (!profileData.skills) profileData.skills = [];
if (!profileData.skills.includes(val)) {
profileData.skills.push(val);
renderSkills();
markProfileDirty();
}
input.value = '';
}
document.getElementById('addSkillBtn').addEventListener('click', addSkill);
// Allow Enter key in the skill input to trigger the same add action
document.getElementById('skillInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addSkill(); }
});
// ─── Certifications ───────────────────────────────────────────────────────────
/**
* Clears and re-renders the certifications tag list from `profileData.certifications`.
* Follows the same pattern as renderSkills: tags with inline remove buttons.
*/
function renderCerts() {
const container = document.getElementById('certsContainer');
container.innerHTML = '';
(profileData.certifications || []).forEach((cert, i) => {
const tag = document.createElement('span');
tag.className = 'skill-tag';
tag.innerHTML = `${escapeHTML(cert)} <span class="remove" data-idx="${i}">×</span>`;
container.appendChild(tag);
});
container.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', () => {
profileData.certifications.splice(parseInt(btn.dataset.idx), 1);
renderCerts();
markProfileDirty();
});
});
}
/**
* Reads the certification input, deduplicates, and appends to the list.
*/
function addCert() {
const input = document.getElementById('certInput');
const val = input.value.trim();
if (!val) return;
if (!profileData.certifications) profileData.certifications = [];
if (!profileData.certifications.includes(val)) {
profileData.certifications.push(val);
renderCerts();
markProfileDirty();
}
input.value = '';
}
document.getElementById('addCertBtn').addEventListener('click', addCert);
document.getElementById('certInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addCert(); }
});
// ─── Experience ───────────────────────────────────────────────────────────────
/**
* Clears and re-renders all experience entries from `profileData.experience`.
*/
function renderExperience() {
const list = document.getElementById('experienceList');
list.innerHTML = '';
(profileData.experience || []).forEach((exp, i) => {
list.appendChild(createExperienceEntry(exp, i));
});
}
/**
* Creates a single editable experience card as a DOM element.
* Input/textarea changes are immediately mirrored back to `profileData.experience[idx]`
* via the `data-field` attribute, so no additional "collect form" step is needed on save.
*
* @param {Object} exp - Experience object: { title, company, dates, description }.
* @param {number} idx - Array index within profileData.experience (used for removal and live sync).
* @returns {HTMLDivElement} The fully wired card element.
*/
function createExperienceEntry(exp, idx) {
const div = document.createElement('div');
div.className = 'entry';
div.innerHTML = `
<div class="entry-header">
<h4>Experience #${idx + 1}</h4>
<button class="btn btn-danger btn-sm remove-entry" data-idx="${idx}">Remove</button>
</div>
<div class="form-row">
<div><label>Job Title</label><input type="text" data-field="title" value="${escapeAttr(exp.title || '')}"></div>
<div><label>Company</label><input type="text" data-field="company" value="${escapeAttr(exp.company || '')}"></div>
</div>
<label>Dates</label><input type="text" data-field="dates" value="${escapeAttr(exp.dates || '')}">
<label>Description</label><textarea data-field="description" rows="3">${escapeHTML(exp.description || '')}</textarea>
`;
// Remove button: splice this entry and re-render the entire list (indices shift)
div.querySelector('.remove-entry').addEventListener('click', () => {
profileData.experience.splice(idx, 1);
renderExperience();
markProfileDirty();
});
// Sync edits back to state — each field uses data-field to identify which key to update
div.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('input', () => {
profileData.experience[idx][input.dataset.field] = input.value;
markProfileDirty();
});
});
return div;
}
// Add a blank experience entry when the user clicks the button
document.getElementById('addExpBtn').addEventListener('click', () => {
if (!profileData.experience) profileData.experience = [];
profileData.experience.push({ title: '', company: '', dates: '', description: '' });
renderExperience();
markProfileDirty();
});
// ─── Education ────────────────────────────────────────────────────────────────
/**
* Clears and re-renders all education entries from `profileData.education`.
*/
function renderEducation() {
const list = document.getElementById('educationList');
list.innerHTML = '';
(profileData.education || []).forEach((edu, i) => {
list.appendChild(createEducationEntry(edu, i));
});
}
/**
* Creates a single editable education card.
* Live-syncs changes back to `profileData.education[idx]` via data-field attributes.
*
* @param {Object} edu - Education object: { degree, school, dates, details }.
* @param {number} idx - Array index within profileData.education.
* @returns {HTMLDivElement} Fully wired card element.
*/
function createEducationEntry(edu, idx) {
const div = document.createElement('div');
div.className = 'entry';
div.innerHTML = `
<div class="entry-header">
<h4>Education #${idx + 1}</h4>
<button class="btn btn-danger btn-sm remove-entry" data-idx="${idx}">Remove</button>
</div>
<div class="form-row">
<div><label>Degree</label><input type="text" data-field="degree" value="${escapeAttr(edu.degree || '')}"></div>
<div><label>School</label><input type="text" data-field="school" value="${escapeAttr(edu.school || '')}"></div>
</div>
<label>Dates</label><input type="text" data-field="dates" value="${escapeAttr(edu.dates || '')}">
<label>Details</label><textarea data-field="details" rows="2">${escapeHTML(edu.details || '')}</textarea>
`;
div.querySelector('.remove-entry').addEventListener('click', () => {
profileData.education.splice(idx, 1);
renderEducation();
markProfileDirty();
});
div.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('input', () => {
profileData.education[idx][input.dataset.field] = input.value;
markProfileDirty();
});
});
return div;
}
document.getElementById('addEduBtn').addEventListener('click', () => {
if (!profileData.education) profileData.education = [];
profileData.education.push({ degree: '', school: '', dates: '', details: '' });
renderEducation();
markProfileDirty();
});
// ─── Projects ─────────────────────────────────────────────────────────────────
/**
* Clears and re-renders all project entries from `profileData.projects`.
*/
function renderProjects() {
const list = document.getElementById('projectsList');
list.innerHTML = '';
(profileData.projects || []).forEach((proj, i) => {
list.appendChild(createProjectEntry(proj, i));
});
}
/**
* Creates a single editable project card.
* The 'technologies' field is stored as an array but displayed as a
* comma-separated string; the input handler splits it back on save.
*
* @param {Object} proj - Project object: { name, description, technologies: string[] }.
* @param {number} idx - Array index within profileData.projects.
* @returns {HTMLDivElement} Fully wired card element.
*/
function createProjectEntry(proj, idx) {
const div = document.createElement('div');
div.className = 'entry';
div.innerHTML = `
<div class="entry-header">
<h4>Project #${idx + 1}</h4>
<button class="btn btn-danger btn-sm remove-entry" data-idx="${idx}">Remove</button>
</div>
<label>Project Name</label>
<input type="text" data-field="name" value="${escapeAttr(proj.name || '')}">
<label>Description</label>
<textarea data-field="description" rows="2">${escapeHTML(proj.description || '')}</textarea>
<label>Technologies (comma-separated)</label>
<input type="text" data-field="technologies" value="${escapeAttr((proj.technologies || []).join(', '))}">
`;
div.querySelector('.remove-entry').addEventListener('click', () => {
profileData.projects.splice(idx, 1);
renderProjects();
markProfileDirty();
});
div.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('input', () => {
const field = input.dataset.field;
if (field === 'technologies') {
// Convert the comma-separated display string back to an array, stripping blanks
profileData.projects[idx][field] = input.value.split(',').map(s => s.trim()).filter(Boolean);
} else {
profileData.projects[idx][field] = input.value;
}
markProfileDirty();
});
});
return div;
}
document.getElementById('addProjBtn').addEventListener('click', () => {
if (!profileData.projects) profileData.projects = [];
profileData.projects.push({ name: '', description: '', technologies: [] });
renderProjects();
markProfileDirty();
});
// ─── Save profile ─────────────────────────────────────────────────────────────
/**
* Save-profile button handler.
* 1. Reads the plain-text fields from the form into `profileData` (list fields
* are already kept in sync by their individual input listeners).
* 2. Persists via the background (SAVE_PROFILE message).
* 3. Deep-copies the updated profile into the active slot and writes
* profileSlots back to chrome.storage.local so slot state stays consistent.
*/
document.getElementById('saveProfileBtn').addEventListener('click', async () => {
// Sync the plain text fields that are not live-updated by sub-component listeners
profileData.name = document.getElementById('pName').value.trim();
profileData.email = document.getElementById('pEmail').value.trim();
profileData.phone = document.getElementById('pPhone').value.trim();
profileData.location = document.getElementById('pLocation').value.trim();
profileData.linkedin = document.getElementById('pLinkedin').value.trim();
profileData.website = document.getElementById('pWebsite').value.trim();
profileData.summary = document.getElementById('pSummary').value.trim();
// ── Basic validation (only if fields are filled in) ──
if (profileData.email && (!/[@]/.test(profileData.email) || !/[.]/.test(profileData.email))) {
showToast('Please enter a valid email address');
return;
}
if (profileData.phone && (profileData.phone.replace(/\D/g, '').length < 10)) {
showToast('Please enter a valid phone number');
return;
}
try {
await sendMessage({ type: 'SAVE_PROFILE', profile: profileData });
// Deep-copy into the active slot so the slot array always reflects the latest save
profileSlots[activeSlot] = JSON.parse(JSON.stringify(profileData));
await chrome.storage.local.set({ profileSlots });
updateSlotButtons();
markProfileClean();
showToast('Profile saved!');
} catch (err) {
showToast('Error saving: ' + err.message);
}
});
// ─── Q&A rendering ────────────────────────────────────────────────────────────
/**
* Clears and re-renders the entire Q&A list from the `qaList` array.
*
* Rendering rules per entry type:
* - 'custom' (no category or category === 'custom'): editable question label +
* textarea answer — the user owns both fields.
* - 'dropdown': fixed question label + <select> populated from entry.options.
* - 'short': fixed question label + single-line <input>.
* - 'text' (fallback): fixed question label + multi-line <textarea>.
*
* Compact display (qa-compact class) is applied to 'short' and 'dropdown' entries
* that belong to a built-in category, keeping the list visually dense.
*
* Applies the active category filter (`activeQAFilter`) to hide irrelevant entries.
*/
function renderQA() {
const list = document.getElementById('qaList');
list.innerHTML = '';
// Show current count near the Q&A section header
const countEl = document.getElementById('qaCount');
if (countEl) countEl.textContent = `${qaList.length} / 200`;
// Only show the category filter toolbar if at least one entry has a category
const hasCategorized = qaList.some(q => q.category);
const filterEl = document.getElementById('qaCategoryFilter');
if (filterEl) filterEl.style.display = hasCategorized ? 'block' : 'none';
// Once the list is large enough the "Load common questions" button is no longer useful
const loadBtn = document.getElementById('loadDefaultQABtn');
if (loadBtn && qaList.length >= 10) loadBtn.style.display = 'none';
// Human-readable labels for each category slug used in badge rendering
const categoryLabels = {
'personal': 'Personal',
'work-auth': 'Work Auth',
'availability': 'Availability',
'salary': 'Salary',
'background': 'Background',
'relocation': 'Relocation',
'referral': 'Referral',
'demographics': 'Demographics',
'general': 'General',
'custom': 'Custom'
};
let visibleCount = 0;
qaList.forEach((qa, i) => {
// Treat entries without a category as 'custom' for filter matching
const cat = qa.category || 'custom';
// Skip entries that don't match the active filter (unless filter is 'all')
if (activeQAFilter !== 'all' && cat !== activeQAFilter) return;
visibleCount++;
const qType = qa.type || 'text';
// An entry is "custom" if it has no category or its category is literally 'custom'
const isCustom = !qa.category || qa.category === 'custom';
// Compact layout is used for brief built-in questions to reduce vertical space
const isCompact = (qType === 'short' || qType === 'dropdown') && !isCustom;
const div = document.createElement('div');
div.className = 'qa-entry' + (isCompact ? ' qa-compact' : '');
// Build the coloured category badge HTML (empty string if no category)
const badge = qa.category
? `<span class="qa-category-badge qa-cat-${cat}">${categoryLabels[cat] || cat}</span>`
: '';
if (isCustom) {
// Custom entries: both the question text and the answer are user-editable
div.innerHTML = `
<div class="qa-compact-header">
<label>Q&A #${i + 1}${badge}</label>
<button class="btn btn-danger btn-sm remove-qa" data-idx="${i}">×</button>
</div>
<input type="text" data-field="question" value="${escapeAttr(qa.question || '')}" placeholder="Enter your question...">
<textarea data-field="answer" rows="2" placeholder="Your answer...">${escapeHTML(qa.answer || '')}</textarea>
`;
} else if (qType === 'dropdown') {
// Dropdown: question is fixed, answer is chosen from a <select>
const optionsHTML = (qa.options || []).map(opt =>
`<option value="${escapeAttr(opt)}"${qa.answer === opt ? ' selected' : ''}>${escapeHTML(opt || '-- Select --')}</option>`
).join('');
div.innerHTML = `
<div class="qa-compact-header">
<label>${escapeHTML(qa.question)}${badge}</label>
<button class="btn btn-danger btn-sm remove-qa" data-idx="${i}">×</button>
</div>
<select data-field="answer">${optionsHTML}</select>
`;
} else if (qType === 'short') {
// Short text: single-line input for brief answers (name, salary, dates, etc.)
div.innerHTML = `
<div class="qa-compact-header">
<label>${escapeHTML(qa.question)}${badge}</label>
<button class="btn btn-danger btn-sm remove-qa" data-idx="${i}">×</button>
</div>
<input type="text" data-field="answer" value="${escapeAttr(qa.answer || '')}" placeholder="Enter...">
`;
} else {
// Textarea (type === 'text'): multi-line input for longer free-text answers
div.innerHTML = `
<div class="qa-compact-header">
<label>${escapeHTML(qa.question)}${badge}</label>
<button class="btn btn-danger btn-sm remove-qa" data-idx="${i}">×</button>
</div>
<textarea data-field="answer" rows="2" placeholder="Your answer...">${escapeHTML(qa.answer || '')}</textarea>
`;
}
// Remove: splice from qaList and re-render (all indices above i shift down by 1)
div.querySelector('.remove-qa').addEventListener('click', () => {
qaList.splice(i, 1);
renderQA();
});
// Live-sync: mirror every field change back to qaList[i] immediately
// SELECTs fire 'change'; inputs and textareas fire 'input'
div.querySelectorAll('input, textarea, select').forEach(el => {
const evt = el.tagName === 'SELECT' ? 'change' : 'input';
el.addEventListener(evt, () => {
qaList[i][el.dataset.field] = el.value;
});
});
list.appendChild(div);
});
// Friendly empty-state message when a filter yields no results
if (visibleCount === 0 && activeQAFilter !== 'all') {
list.innerHTML = '<p style="text-align:center;color:#94a3b8;padding:20px;">No questions in this category.</p>';
}
}
// ─── DEFAULT_QA_QUESTIONS categories ─────────────────────────────────────────
// The US states list is used by the 'State / Province' dropdown option set.
/**
* Abbreviated two-letter codes for all US states and DC.
* Used as the option values for the "State / Province" dropdown question.
* @type {string[]}
*/
/**
* Auto-fills Q&A answers from parsed resume profile data.
* Only fills answers that are currently empty — never overwrites user edits.
* Maps profile fields to matching Q&A questions by question text.
*
* @param {Object} profile - The parsed resume profile object.
*/
function prefillQAFromProfile(profile) {
if (!profile || qaList.length === 0) return;
// Split full name into first/last
const nameParts = (profile.name || '').trim().split(/\s+/);
const firstName = nameParts[0] || '';
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : '';
// Parse location: try "City, State ZIP" or "City, State" patterns
const loc = (profile.location || '').trim();
let city = '', state = '', zip = '';
const locMatch = loc.match(/^([^,]+),?\s*([A-Z]{2})?\s*(\d{5})?/i);
if (locMatch) {
city = (locMatch[1] || '').trim();
state = (locMatch[2] || '').toUpperCase();
zip = locMatch[3] || '';
} else {
city = loc; // fallback: use full location as city
}
// Get current job title and company from most recent experience
let currentTitle = '', currentCompany = '';
if (Array.isArray(profile.experience) && profile.experience.length > 0) {
currentTitle = profile.experience[0].title || '';
currentCompany = profile.experience[0].company || '';
}
// Get highest education level
let educationLevel = '';
if (Array.isArray(profile.education) && profile.education.length > 0) {
const deg = (profile.education[0].degree || '').toLowerCase();
if (deg.includes('doctor') || deg.includes('phd') || deg.includes('edd')) educationLevel = 'Doctorate (PhD/EdD)';
else if (deg.includes('master') || deg.includes('mba') || deg.includes('m.s') || deg.includes('m.a')) educationLevel = "Master's Degree (MA/MS/MBA)";
else if (deg.includes('bachelor') || deg.includes('b.s') || deg.includes('b.a') || deg.includes('b.e')) educationLevel = "Bachelor's Degree (BA/BS)";
else if (deg.includes('associate')) educationLevel = "Associate's Degree";
}
// Get certifications as comma-separated string
const certs = (profile.certifications || []).join(', ');
// Map of Q&A question text (lowercased) → value to fill
const mappings = {
'first name': firstName,
'last name': lastName,
'email address': profile.email || '',
'phone number': profile.phone || '',
'city': city,
'zip / postal code': zip,
'current job title': currentTitle,
'current employer / company': currentCompany,
'linkedin profile url': profile.linkedin || '',
'portfolio / personal website url': profile.website || '',
'github profile url': profile.github || '',
'relevant certifications or professional licenses': certs,
};
// Add state mapping only if we found a valid state abbreviation
if (state) mappings['state / province'] = state;
// Add education level only if we identified it
if (educationLevel) mappings['highest level of education completed'] = educationLevel;
let filled = 0;
qaList.forEach(qa => {
const key = qa.question.toLowerCase().trim();
if (mappings[key] && !qa.answer) {
qa.answer = mappings[key];
filled++;
}
});
if (filled > 0) {
renderQA();
showToast(`Auto-filled ${filled} Q&A answers from your resume.`);
}
}
const US_STATES = [
'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI','ID','IL','IN',
'IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH',
'NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT',
'VT','VA','WA','WV','WI','WY'
];
/**
* Canonical set of Q&A entries representing the most common questions asked on
* US job applications. Grouped into labelled categories:
*
* personal — name, address, contact details, current employer
* work-auth — legal right to work, visa sponsorship, age gate
* availability — start date, notice period, employment type, overtime
* salary — desired salary / hourly rate
* background — background check, drug test, prior employment, non-compete,
* driver's licence
* relocation — willingness to relocate, relocation assistance, work
* arrangement preference, travel percentage
* referral — source of the job lead, employee referral, social/portfolio links
* demographics — voluntary EEO / diversity fields (all "Prefer not to say" friendly)
* general — education level, certifications, clearance, accommodation,
* open-ended cover note
*
* Each entry shape: { question, answer, category, type, options? }
* type: 'short' — single-line text input
* 'dropdown' — <select> with the provided options array
* 'text' — multi-line textarea
*
* The `answer` field is intentionally empty here; it gets filled in by the user
* (or pre-populated from profileData during future enhancements).
*
* @type {Array<{question: string, answer: string, category: string, type: string, options?: string[]}>}
*/
const DEFAULT_QA_QUESTIONS = [
// ── Personal / Address ──
{ question: 'First Name', answer: '', category: 'personal', type: 'short' },
{ question: 'Last Name', answer: '', category: 'personal', type: 'short' },
{ question: 'Email Address', answer: '', category: 'personal', type: 'short' },
{ question: 'Phone Number', answer: '', category: 'personal', type: 'short' },
{ question: 'Street Address', answer: '', category: 'personal', type: 'short' },
{ question: 'Street Address Line 2 (Apt, Suite, Unit)', answer: '', category: 'personal', type: 'short' },
{ question: 'City', answer: '', category: 'personal', type: 'short' },
// State dropdown: blank sentinel + all 50 states + DC + Other
{ question: 'State / Province', answer: '', category: 'personal', type: 'dropdown', options: [''].concat(US_STATES, ['Other']) },
{ question: 'ZIP / Postal Code', answer: '', category: 'personal', type: 'short' },
{ question: 'Country', answer: '', category: 'personal', type: 'dropdown', options: ['', 'United States', 'Canada', 'United Kingdom', 'India', 'Australia', 'Germany', 'France', 'Mexico', 'Brazil', 'Other'] },
{ question: 'Current Job Title', answer: '', category: 'personal', type: 'short' },
{ question: 'Current Employer / Company', answer: '', category: 'personal', type: 'short' },
// ── Work Authorization ──
{ question: 'Are you legally authorized to work in the United States?', answer: '', category: 'work-auth', type: 'dropdown', options: ['', 'Yes', 'No'] },
{ question: 'Will you now or in the future require sponsorship for employment visa status (e.g., H-1B)?', answer: '', category: 'work-auth', type: 'dropdown', options: ['', 'Yes', 'No'] },
{ question: 'Are you at least 18 years of age?', answer: '', category: 'work-auth', type: 'dropdown', options: ['', 'Yes', 'No'] },
{ question: 'Work authorization status', answer: '', category: 'work-auth', type: 'dropdown', options: ['', 'U.S. Citizen', 'Green Card Holder', 'H-1B Visa', 'EAD / OPT', 'TN Visa', 'L-1 Visa', 'Other'] },
// ── Availability ──
{ question: 'Earliest available start date', answer: '', category: 'availability', type: 'short' },
{ question: 'Notice period for current employer', answer: '', category: 'availability', type: 'dropdown', options: ['', 'Immediately available', '1 week', '2 weeks', '3 weeks', '1 month', 'More than 1 month'] },
{ question: 'Desired employment type', answer: '', category: 'availability', type: 'dropdown', options: ['', 'Full-time', 'Part-time', 'Contract', 'Internship', 'Any'] },
{ question: 'Available to work overtime/weekends if needed?', answer: '', category: 'availability', type: 'dropdown', options: ['', 'Yes', 'No'] },
// ── Salary ──