Add structured CV editor to profile page

This commit is contained in:
2026-03-28 15:08:43 +01:00
parent 8f8a34ad9c
commit 5f14490ead
4 changed files with 474 additions and 57 deletions
+223
View File
@@ -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();
}
}