diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index b055701..066e64e 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -206,6 +206,47 @@ export const translations = { profileCvStructureParsed: "CV structure analyzed.", profileCvStructureParseFailed: "Failed to analyze CV structure.", profileCvStructureEmpty: "No parsed sections yet.", + profileCvStructuredEditor: "Structured CV editor", + profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.", + profileCvContactFullName: "Full name", + profileCvContactHeadline: "Professional headline", + profileCvContactEmail: "Contact email", + profileCvContactPhone: "Phone", + profileCvContactLocation: "Location", + profileCvContactWebsite: "Website", + profileCvContactLinkedIn: "LinkedIn", + profileCvStructuredSummary: "Summary bullets", + profileCvStructuredSkills: "Core skills", + profileCvStructuredInterests: "Interests", + profileCvStructuredLanguages: "Languages", + profileCvStructuredJobs: "Work experience", + profileCvStructuredEducation: "Education", + profileCvStructuredOtherSections: "Other sections", + profileCvStructuredAddLanguage: "Add language", + profileCvStructuredAddJob: "Add job", + profileCvStructuredAddEducation: "Add education", + profileCvStructuredAddOtherSection: "Add section", + profileCvStructuredRemove: "Remove", + profileCvStructuredListHelp: "One item per line.", + profileCvStructuredEmpty: "Nothing added yet.", + profileCvLanguageName: "Language", + profileCvLanguageLevel: "Level", + profileCvLanguageNotes: "Notes", + profileCvJobTitle: "Job title", + profileCvJobCompany: "Company", + profileCvJobLocation: "Location", + profileCvJobStart: "Start", + profileCvJobEnd: "End", + profileCvJobBullets: "Job bullets", + profileCvJobSkills: "Job skills", + profileCvEducationQualification: "Qualification", + profileCvEducationInstitution: "Institution", + profileCvEducationLocation: "Location", + profileCvEducationStart: "Start", + profileCvEducationEnd: "End", + profileCvEducationDetails: "Education details", + profileCvOtherSectionTitle: "Section title", + profileCvOtherSectionItems: "Section items", profileCvSectionWordCount: "{count} words", profileCvSectionToolsHelp: "Generate sharper versions of one CV section at a time before pasting them back into your master CV.", profileCvSectionLabel: "Section", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 59c5d82..2e86a97 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -10,12 +10,15 @@ import GoogleAuthCard from "../components/GoogleAuthCard"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { + emptyStructuredCv, + joinLines, + normalizeStructuredCv, + parseStructuredCvJson, + splitLines, + StructuredCvProfile, +} from "../profileCv"; -type ParsedCvSection = { - name: string; - content: string; - wordCount: number; -}; type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; type CvSectionStyle = "balanced" | "concise" | "impact" | "ats"; @@ -102,7 +105,7 @@ export default function ProfilePage() { const [cvSectionTargetRole, setCvSectionTargetRole] = useState(""); const [cvSectionDraft, setCvSectionDraft] = useState(""); const [parsingCvSections, setParsingCvSections] = useState(false); - const [parsedCvSections, setParsedCvSections] = useState([]); + const [structuredCv, setStructuredCv] = useState(emptyStructuredCv()); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -116,26 +119,12 @@ export default function ProfilePage() { setLastName(r.data?.lastName ?? ""); setDisplayName(r.data?.displayName ?? ""); setProfileCvText(r.data?.profileCvText ?? ""); - try { - const parsed = r.data?.profileCvStructureJson ? JSON.parse(r.data.profileCvStructureJson) : []; - const normalized = Array.isArray(parsed) - ? parsed.map((section: any) => { - const content = typeof section?.content === "string" ? section.content : ""; - const name = typeof section?.name === "string" && section.name.trim() ? section.name : t("profileCvSectionSummary"); - const computedWordCount = content.trim() ? content.trim().split(/\s+/).length : 0; - const wordCount = Number.isFinite(Number(section?.wordCount)) ? Number(section.wordCount) : computedWordCount; - return { name, content, wordCount }; - }) - : []; - setParsedCvSections(normalized); - } catch { - setParsedCvSections([]); - } + setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson)); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); } catch { setMe(null); } - }, [t]); + }, []); useEffect(() => { void loadProfile(); @@ -371,17 +360,8 @@ export default function ProfilePage() { onClick={async () => { setParsingCvSections(true); try { - const res = await api.post<{ sections?: ParsedCvSection[] }>("/profile-cv/parse", { text: profileCvText }); - const normalized = Array.isArray(res.data?.sections) - ? res.data.sections.map((section: any) => { - const content = typeof section?.content === "string" ? section.content : ""; - const name = typeof section?.name === "string" && section.name.trim() ? section.name : t("profileCvSectionSummary"); - const computedWordCount = content.trim() ? content.trim().split(/\s+/).length : 0; - const wordCount = Number.isFinite(Number(section?.wordCount)) ? Number(section.wordCount) : computedWordCount; - return { name, content, wordCount }; - }) - : []; - setParsedCvSections(normalized); + const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText }); + setStructuredCv(normalizeStructuredCv(res.data?.structuredCv)); toast(t("profileCvStructureParsed"), "success"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error"); @@ -393,25 +373,158 @@ export default function ProfilePage() { {parsingCvSections ? t("profileCvStructureParsing") : t("profileCvStructureParse")} - {parsedCvSections.length > 0 ? ( + {structuredCv.sections.length > 0 ? ( - {parsedCvSections.map((section) => { + {structuredCv.sections.map((section) => { const safeContent = typeof section.content === "string" ? section.content : ""; const safeWordCount = Number.isFinite(Number(section.wordCount)) ? Number(section.wordCount) : (safeContent.trim() ? safeContent.trim().split(/\s+/).length : 0); return ( - - - {section.name} - + + + {section.name} + + + {safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""} - {safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""} - - )})} + ); + })} ) : ( {t("profileCvStructureEmpty")} )} + + + {t("profileCvStructuredEditor")} + {t("profileCvStructuredEditorHelp")} + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={4} + fullWidth + /> + + + + + {t("profileCvStructuredLanguages")} + + + {structuredCv.languages.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.languages.map((language, index) => ( + + + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, level: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, notes: e.target.value || undefined } : entry) }))} fullWidth /> + + + + ))} + + + + + {t("profileCvStructuredJobs")} + + + {structuredCv.jobs.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.jobs.map((job, index) => ( + + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, company: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || entry.isCurrent } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, bullets: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, skills: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + + + + {t("profileCvStructuredEducation")} + + + {structuredCv.education.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.education.map((education, index) => ( + + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, qualification: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, institution: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, details: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + + + + {t("profileCvStructuredOtherSections")} + + + {structuredCv.otherSections.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.otherSections.map((section, index) => ( + + + setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, items: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + ))} + + @@ -495,7 +608,7 @@ export default function ProfilePage() { onClick={async () => { setLoading(true); try { - await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText, profileCvStructureJson: JSON.stringify(parsedCvSections) }); + await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) }); window.localStorage.setItem("profileHeadline", headline.trim()); await loadProfile(); toast(t("profileUpdated"), "success"); diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 73fc300..c4cfd56 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -22,6 +22,37 @@ jest.mock('./components/CropImageDialog', () => () => null); const mockedApi = api as jest.Mocked; +const structuredCv = { + version: '1', + contact: { + fullName: 'Demo User', + headline: 'Backend Developer', + email: 'demo@example.com', + }, + summary: ['Built backend systems'], + jobs: [ + { + title: 'System Developer', + company: 'Demo Co', + location: 'Oslo', + start: '2020', + end: '2024', + isCurrent: false, + bullets: ['Built backend systems'], + skills: ['.NET', 'SQL'], + }, + ], + education: [], + skills: ['.NET', 'SQL'], + languages: [{ name: 'English', level: 'Native' }], + interests: ['Cooking'], + otherSections: [], + sections: [ + { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, + { name: 'Skills', content: '.NET\nSQL', wordCount: 2 }, + ], +}; + function renderPage() { return render( @@ -44,9 +75,7 @@ beforeEach(() => { lastName: 'User', displayName: 'Demo User', profileCvText: 'Professional Summary\nBuilt backend systems', - profileCvStructureJson: JSON.stringify([ - { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, - ]), + profileCvStructureJson: JSON.stringify(structuredCv), googleLink: { linked: false }, }, } as any); @@ -57,10 +86,14 @@ beforeEach(() => { if (url === '/profile-cv/parse') { return Promise.resolve({ data: { - sections: [ - { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, - { name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 }, - ], + structuredCv: { + ...structuredCv, + sections: [ + { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, + { name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 }, + ], + skills: ['.NET', 'SQL', 'Azure'], + }, }, } as any); } @@ -74,12 +107,14 @@ afterEach(() => { jest.clearAllMocks(); }); -test('profile page loads persisted cv sections and can re-parse them', async () => { +test('profile page loads persisted structured cv and can re-parse it', async () => { renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument(); + expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument(); expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0); + expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'); const analyzeButton = screen.getByRole('button', { name: /analyze sections/i }); await waitFor(() => expect(analyzeButton).toBeEnabled()); @@ -89,22 +124,27 @@ test('profile page loads persisted cv sections and can re-parse them', async () expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/parse', { text: 'Professional Summary\nBuilt backend systems' }); }); - expect(await screen.findByText(/core skills/i)).toBeInTheDocument(); + expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0); }); test('saving profile persists structured cv json', async () => { renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); + const fullNameInput = screen.getByLabelText(/full name/i); + fireEvent.change(fullNameInput, { target: { value: 'Updated Demo User' } }); + const saveButton = screen.getByRole('button', { name: /save changes/i }); await waitFor(() => expect(saveButton).toBeEnabled()); fireEvent.click(saveButton); await waitFor(() => { - expect(mockedApi.put).toHaveBeenCalledWith('/auth/profile', expect.objectContaining({ - profileCvStructureJson: JSON.stringify([ - { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, - ]), - })); + expect(mockedApi.put).toHaveBeenCalled(); }); + + const payload = mockedApi.put.mock.calls[0][1] as any; + const parsed = JSON.parse(payload.profileCvStructureJson); + expect(parsed.contact.fullName).toBe('Updated Demo User'); + expect(parsed.skills).toEqual(['.NET', 'SQL']); + expect(parsed.jobs[0].title).toBe('System Developer'); }); diff --git a/job-tracker-ui/src/profileCv.ts b/job-tracker-ui/src/profileCv.ts new file mode 100644 index 0000000..7121c99 --- /dev/null +++ b/job-tracker-ui/src/profileCv.ts @@ -0,0 +1,223 @@ +export type ParsedCvSection = { + name: string; + content: string; + wordCount: number; +}; + +export type StructuredCvContact = { + fullName?: string; + headline?: string; + email?: string; + phone?: string; + location?: string; + website?: string; + linkedIn?: string; +}; + +export type StructuredCvJob = { + title?: string; + company?: string; + location?: string; + start?: string; + end?: string; + isCurrent?: boolean; + bullets: string[]; + skills: string[]; +}; + +export type StructuredCvEducation = { + qualification?: string; + institution?: string; + location?: string; + start?: string; + end?: string; + details: string[]; +}; + +export type StructuredCvLanguage = { + name?: string; + level?: string; + notes?: string; +}; + +export type StructuredCvOtherSection = { + title?: string; + items: string[]; +}; + +export type StructuredCvProfile = { + version: string; + contact: StructuredCvContact; + summary: string[]; + jobs: StructuredCvJob[]; + education: StructuredCvEducation[]; + skills: string[]; + languages: StructuredCvLanguage[]; + interests: string[]; + otherSections: StructuredCvOtherSection[]; + sections: ParsedCvSection[]; +}; + +export function splitLines(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function joinLines(values: string[]) { + return values.join("\n"); +} + +export function emptyStructuredCv(): StructuredCvProfile { + return { + version: "1", + contact: {}, + summary: [], + jobs: [], + education: [], + skills: [], + languages: [], + interests: [], + otherSections: [], + sections: [], + }; +} + +function normalizeString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeList(value: unknown) { + if (!Array.isArray(value)) return [] as string[]; + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); +} + +function normalizeParsedSections(value: unknown): ParsedCvSection[] { + if (!Array.isArray(value)) return []; + return value + .map((section) => { + const content = typeof (section as any)?.content === "string" ? (section as any).content.trim() : ""; + const name = typeof (section as any)?.name === "string" && (section as any).name.trim() ? (section as any).name.trim() : "General"; + const computedWordCount = content ? content.split(/\s+/).length : 0; + const wordCount = Number.isFinite(Number((section as any)?.wordCount)) ? Number((section as any).wordCount) : computedWordCount; + return { name, content, wordCount }; + }) + .filter((section) => section.content); +} + +function linesFromSection(sections: ParsedCvSection[], names: string[]) { + const match = sections.find((section) => names.includes(section.name.toLowerCase())); + return match ? splitLines(match.content) : []; +} + +function buildLegacyStructuredCv(sections: ParsedCvSection[]): StructuredCvProfile { + const summary = linesFromSection(sections, ["professional summary", "summary"]); + const skills = linesFromSection(sections, ["skills", "core skills", "technical skills"]) + .flatMap((line) => (line.includes(",") ? line.split(",") : [line])) + .map((item) => item.trim()) + .filter(Boolean); + const interests = linesFromSection(sections, ["interests"]); + const languages = linesFromSection(sections, ["languages"]).map((line) => { + const [name, ...rest] = line.split(":"); + return { name: name?.trim(), level: rest.join(":").trim() || undefined, notes: undefined }; + }); + const contactLines = linesFromSection(sections, ["contact"]); + const contact: StructuredCvContact = { + fullName: contactLines.find((line) => /^[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,4}$/.test(line)), + email: contactLines.find((line) => line.includes("@")), + phone: contactLines.find((line) => /\+?\d[\d\s().-]{6,}\d/.test(line)), + linkedIn: contactLines.find((line) => line.toLowerCase().includes("linkedin")), + website: contactLines.find((line) => !line.includes("@") && /\./.test(line)), + }; + + return { + ...emptyStructuredCv(), + contact, + summary, + skills, + languages, + interests, + sections, + }; +} + +export function normalizeStructuredCv(value: unknown): StructuredCvProfile { + if (Array.isArray(value)) { + return buildLegacyStructuredCv(normalizeParsedSections(value)); + } + + const source = (value && typeof value === "object" ? value : {}) as any; + const sections = normalizeParsedSections(source.sections); + const normalized: StructuredCvProfile = { + version: normalizeString(source.version) ?? "1", + contact: { + fullName: normalizeString(source.contact?.fullName), + headline: normalizeString(source.contact?.headline), + email: normalizeString(source.contact?.email), + phone: normalizeString(source.contact?.phone), + location: normalizeString(source.contact?.location), + website: normalizeString(source.contact?.website), + linkedIn: normalizeString(source.contact?.linkedIn), + }, + summary: normalizeList(source.summary), + jobs: Array.isArray(source.jobs) + ? source.jobs.map((job: any) => ({ + title: normalizeString(job?.title), + company: normalizeString(job?.company), + location: normalizeString(job?.location), + start: normalizeString(job?.start), + end: normalizeString(job?.end), + isCurrent: Boolean(job?.isCurrent), + bullets: normalizeList(job?.bullets), + skills: normalizeList(job?.skills), + })) + : [], + education: Array.isArray(source.education) + ? source.education.map((education: any) => ({ + qualification: normalizeString(education?.qualification), + institution: normalizeString(education?.institution), + location: normalizeString(education?.location), + start: normalizeString(education?.start), + end: normalizeString(education?.end), + details: normalizeList(education?.details), + })) + : [], + skills: normalizeList(source.skills), + languages: Array.isArray(source.languages) + ? source.languages.map((language: any) => ({ + name: normalizeString(language?.name), + level: normalizeString(language?.level), + notes: normalizeString(language?.notes), + })) + : [], + interests: normalizeList(source.interests), + otherSections: Array.isArray(source.otherSections) + ? source.otherSections.map((section: any) => ({ + title: normalizeString(section?.title), + items: normalizeList(section?.items), + })) + : [], + sections, + }; + + if (!normalized.sections.length) { + return { + ...normalized, + sections: buildLegacyStructuredCv(normalized.sections).sections, + }; + } + + return normalized; +} + +export function parseStructuredCvJson(value?: string): StructuredCvProfile { + if (!value?.trim()) return emptyStructuredCv(); + try { + return normalizeStructuredCv(JSON.parse(value)); + } catch { + return emptyStructuredCv(); + } +}