Add structured CV editor to profile page
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user