mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
feat(schema): structured resume dates with a read-time upgrade and dual write
Dated entries and roles carry dates (start, end, present, and raw when the text couldn't be read exactly). The parser moves to the schema package and reports how each date was written; parseResumeData fills dates, infers the date format from how dates were typed and rewrites the legacy period/date text from them, so older clients and API readers keep working. API writes sync against the stored data, so an edit to the text alone is read back into dates. getById and getBySlug return upgraded data, ATS date rules and sorting read dates, JSON Resume and LinkedIn imports map their dates directly, entry titles may be empty (drafts aren't printed), and the MCP schema resource is generated live in place of the stale schema.json.
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { syncResumeDates, upgradeResumeDates } from "@reactive-resume/schema/resume/dates";
|
||||
import { parseResumeDataForWrite } from "@reactive-resume/schema/resume/write";
|
||||
|
||||
function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData {
|
||||
@@ -21,8 +22,17 @@ function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVE
|
||||
}
|
||||
}
|
||||
|
||||
export const parseWritableResumeData = (data: unknown) =>
|
||||
parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
|
||||
/**
|
||||
* Validates data before it's saved, then keeps structured dates and their legacy text in step. `previous` is
|
||||
* the stored data being replaced, which tells an edit to the text alone (from a client that doesn't know
|
||||
* structured dates) from an edit to the dates.
|
||||
*/
|
||||
export const parseWritableResumeData = (data: unknown, previous?: ResumeData) => {
|
||||
const parsed = parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
|
||||
upgradeResumeDates(parsed);
|
||||
syncResumeDates(parsed, previous);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseStoredResumeData = (data: unknown) =>
|
||||
parseApiResumeData(data, "INTERNAL_SERVER_ERROR", "Stored resume data does not match the canonical schema.");
|
||||
|
||||
@@ -140,9 +140,10 @@ async function applyResumePatchTx(
|
||||
input.operations.forEach(assertValidPatchPointers);
|
||||
|
||||
let patchedData: ResumeData;
|
||||
const storedData = parseStoredResumeData(existing.data);
|
||||
|
||||
try {
|
||||
patchedData = applyResumePatches(parseStoredResumeData(existing.data), input.operations);
|
||||
patchedData = applyResumePatches(storedData, input.operations);
|
||||
} catch (error) {
|
||||
if (error instanceof ResumePatchError) {
|
||||
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
|
||||
@@ -158,7 +159,7 @@ async function applyResumePatchTx(
|
||||
});
|
||||
}
|
||||
|
||||
patchedData = parseWritableResumeData(patchedData);
|
||||
patchedData = parseWritableResumeData(patchedData, storedData);
|
||||
// The version guard is the ms-precision JS check above, under the SELECT ... FOR UPDATE lock.
|
||||
// Never compare expectedUpdatedAt in SQL: rows stamped by Postgres now() (defaultNow() on
|
||||
// insert) carry microseconds, while JS Dates are ms-truncated — SQL equality then matches
|
||||
@@ -509,7 +510,8 @@ export const resumeService = {
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
return resume;
|
||||
// Clients get the current data shape (structured dates and their text in step), not the stored one.
|
||||
return { ...resume, data: parseStoredResumeData(resume.data) };
|
||||
},
|
||||
|
||||
getBySlug: async (input: {
|
||||
@@ -562,7 +564,8 @@ export const resumeService = {
|
||||
}
|
||||
}
|
||||
|
||||
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
|
||||
const current = { ...resume, data: parseStoredResumeData(resume.data) };
|
||||
return toSharedResumeResponse(redactResumeForViewer(current, isOwner(current, viewer)), resume.hasPassword);
|
||||
},
|
||||
|
||||
create: async (input: {
|
||||
@@ -633,7 +636,9 @@ export const resumeService = {
|
||||
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
const normalizedData = input.data ? parseWritableResumeData(input.data) : undefined;
|
||||
const normalizedData = input.data
|
||||
? parseWritableResumeData(input.data, parseStoredResumeData(existing.data))
|
||||
: undefined;
|
||||
const updateData: Partial<typeof schema.resume.$inferSelect> = {
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
import { formatDate, formatPeriod, formatSingleDate, toRangeDates, toSingleDates, toYearMonth } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM as 'Month Year'", () => {
|
||||
@@ -87,3 +87,17 @@ describe("formatSingleDate", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured dates", () => {
|
||||
it("keeps the year and month of ISO dates", () => {
|
||||
expect(toYearMonth("2024-01-15")).toBe("2024-01");
|
||||
expect(toYearMonth("2024")).toBe("2024");
|
||||
expect(toYearMonth("soon")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks a start without an end as ongoing", () => {
|
||||
expect(toRangeDates("2020-03", undefined)).toEqual({ start: "2020-03", end: null, present: true });
|
||||
expect(toRangeDates("2020-03", "2021")).toEqual({ start: "2020-03", end: "2021", present: false });
|
||||
expect(toSingleDates("2019-11-02")).toEqual({ start: "2019-11", end: null, present: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ResumeDates, YearMonth } from "@reactive-resume/schema/resume/dates";
|
||||
|
||||
// ponytail: Intl replaces the 12-line MONTH_NAMES array; pinned to en-US so output is stable
|
||||
const fmt = new Intl.DateTimeFormat("en-US", { month: "long" });
|
||||
|
||||
@@ -49,3 +51,20 @@ export function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
return formatDate(date, true);
|
||||
}
|
||||
|
||||
/** The year and month of an ISO 8601 date ("2024-01-15" → "2024-01"); null when it isn't one. */
|
||||
export function toYearMonth(date?: string): YearMonth | null {
|
||||
const match = /^(\d{4})(?:-(0[1-9]|1[0-2]))?/.exec(date?.trim() ?? "");
|
||||
if (!match?.[1]) return null;
|
||||
return match[2] ? `${match[1]}-${match[2]}` : match[1];
|
||||
}
|
||||
|
||||
/** Structured dates for a range of ISO dates; a start with no end is ongoing, as `formatPeriod` prints it. */
|
||||
export function toRangeDates(startDate?: string, endDate?: string): ResumeDates {
|
||||
const start = toYearMonth(startDate);
|
||||
const end = toYearMonth(endDate);
|
||||
return { start, end, present: Boolean(start) && !endDate };
|
||||
}
|
||||
|
||||
/** Structured dates for a single ISO date. The day, if any, doesn't print. */
|
||||
export const toSingleDates = (date?: string): ResumeDates => ({ start: toYearMonth(date), end: null, present: false });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getNetworkIcon } from "@reactive-resume/resume/icons";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { formatPeriod, formatSingleDate } from "./date";
|
||||
import { formatPeriod, formatSingleDate, toRangeDates, toSingleDates } from "./date";
|
||||
import { rethrowAsImportError } from "./error";
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
import { parseLevel } from "./level";
|
||||
@@ -221,6 +221,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
position: work.position || "",
|
||||
location: work.location || "",
|
||||
period: formatPeriod(work.startDate, work.endDate),
|
||||
dates: toRangeDates(work.startDate, work.endDate),
|
||||
website: createItemWebsite(work.url),
|
||||
roles: [],
|
||||
description: toHtmlDescription(work.summary, work.highlights),
|
||||
@@ -243,6 +244,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
grade: edu.score || "",
|
||||
location: "",
|
||||
period: formatPeriod(edu.startDate, edu.endDate),
|
||||
dates: toRangeDates(edu.startDate, edu.endDate),
|
||||
website: createItemWebsite(edu.url),
|
||||
description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "",
|
||||
})),
|
||||
@@ -260,6 +262,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
hidden: false,
|
||||
name: project.name || "",
|
||||
period: formatPeriod(project.startDate, project.endDate),
|
||||
dates: toRangeDates(project.startDate, project.endDate),
|
||||
website: createItemWebsite(project.url),
|
||||
description: toHtmlDescription(project.description, project.highlights),
|
||||
})),
|
||||
@@ -330,6 +333,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
title: award.title || "",
|
||||
awarder: award.awarder || "",
|
||||
date: formatSingleDate(award.date),
|
||||
dates: toSingleDates(award.date),
|
||||
website: createItemWebsite(),
|
||||
description: award.summary ? `<p>${award.summary}</p>` : "",
|
||||
})),
|
||||
@@ -348,6 +352,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
title: cert.name || "",
|
||||
issuer: cert.issuer || "",
|
||||
date: formatSingleDate(cert.date),
|
||||
dates: toSingleDates(cert.date),
|
||||
website: createItemWebsite(cert.url),
|
||||
description: "",
|
||||
})),
|
||||
@@ -366,6 +371,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
title: pub.name || "",
|
||||
publisher: pub.publisher || "",
|
||||
date: formatSingleDate(pub.releaseDate),
|
||||
dates: toSingleDates(pub.releaseDate),
|
||||
website: createItemWebsite(pub.url),
|
||||
description: pub.summary ? `<p>${pub.summary}</p>` : "",
|
||||
})),
|
||||
@@ -384,6 +390,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
organization: vol.organization || "",
|
||||
location: "",
|
||||
period: formatPeriod(vol.startDate, vol.endDate),
|
||||
dates: toRangeDates(vol.startDate, vol.endDate),
|
||||
website: createItemWebsite(vol.url),
|
||||
description: toHtmlDescription(vol.summary, vol.highlights),
|
||||
})),
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("parseLinkedInExport", () => {
|
||||
expect(item.company).toBe("Acme");
|
||||
expect(item.position).toBe("Engineer");
|
||||
expect(item.period).toBe("January 2020 - December 2022");
|
||||
expect(item.dates).toEqual({ start: "2020-01", end: "2022-12", present: false });
|
||||
expect(item.description).toBe("<p>Built stuff</p>");
|
||||
});
|
||||
|
||||
@@ -56,6 +57,7 @@ describe("parseLinkedInExport", () => {
|
||||
const result = parseLinkedInExport(zip);
|
||||
expect(result.sections.experience.items).toHaveLength(1);
|
||||
expect(result.sections.experience.items[0]!.period).toBe("January 2020 - Present");
|
||||
expect(result.sections.experience.items[0]!.dates).toEqual({ start: "2020-01", end: null, present: true });
|
||||
});
|
||||
|
||||
it("skips positions without a company name", () => {
|
||||
@@ -123,7 +125,10 @@ describe("parseLinkedInExport", () => {
|
||||
"Positions.csv": "Company Name,Started On,Finished On\nAcme,Jan 2020,2021-06-30\n",
|
||||
});
|
||||
|
||||
expect(parseLinkedInExport(zip).sections.experience.items[0]!.period).toBe("January 2020 - 2021-06-30");
|
||||
const [item] = parseLinkedInExport(zip).sections.experience.items;
|
||||
expect(item!.period).toBe("January 2020 - 2021-06-30");
|
||||
// An end cell it couldn't read leaves the text to be read, and flagged, when saved.
|
||||
expect(item!.dates).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads headers behind a UTF-8 byte order mark", () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ResumeDates } from "@reactive-resume/schema/resume/dates";
|
||||
import { unzipSync } from "fflate";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { formatDate } from "./date";
|
||||
import { formatDate, toRangeDates, toSingleDates } from "./date";
|
||||
import { rethrowAsImportError } from "./error";
|
||||
import { toHtml } from "./html";
|
||||
import { parseLevel } from "./level";
|
||||
@@ -46,11 +47,18 @@ const MONTHS: Record<string, string> = {
|
||||
|
||||
// LinkedIn's "Started On" / "Finished On" cells are "Mon YYYY" (e.g. "Jan 2020") or a bare year.
|
||||
// Anything else is kept verbatim rather than dropped.
|
||||
function formatLinkedInDate(value = ""): string {
|
||||
// LinkedIn writes "Jan 2020" (or a bare year); anything else stays as written and asks for a review.
|
||||
function toLinkedInIsoDate(value = ""): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d{4}$/.test(trimmed)) return trimmed;
|
||||
const monthYear = /^([A-Za-z]{3})[a-z]*\s+(\d{4})$/.exec(trimmed);
|
||||
const month = MONTHS[monthYear?.[1]?.toLowerCase() ?? ""];
|
||||
return monthYear && month ? formatDate(`${monthYear[2]}-${month}`) : trimmed;
|
||||
return monthYear && month ? `${monthYear[2]}-${month}` : null;
|
||||
}
|
||||
|
||||
function formatLinkedInDate(value = ""): string {
|
||||
const iso = toLinkedInIsoDate(value);
|
||||
return iso ? formatDate(iso) : value.trim();
|
||||
}
|
||||
|
||||
// Only an empty end cell means the entry is ongoing; an unrecognised one must not read as "Present".
|
||||
@@ -61,6 +69,19 @@ function formatLinkedInPeriod(start?: string, end?: string): string {
|
||||
return `${from} - ${to || "Present"}`;
|
||||
}
|
||||
|
||||
// Dates only when every cell was read; otherwise the text is read (and flagged if need be) when saved.
|
||||
function linkedInPeriodDates(start?: string, end?: string): ResumeDates | undefined {
|
||||
const from = toLinkedInIsoDate(start);
|
||||
const to = toLinkedInIsoDate(end);
|
||||
if (!from || (end?.trim() && !to)) return undefined;
|
||||
return toRangeDates(from, to ?? undefined);
|
||||
}
|
||||
|
||||
function linkedInSingleDates(value?: string): ResumeDates | undefined {
|
||||
const iso = toLinkedInIsoDate(value);
|
||||
return iso ? toSingleDates(iso) : undefined;
|
||||
}
|
||||
|
||||
const textToHtml = (text = "") => toHtml(text.split(/\r\n?|\n/));
|
||||
|
||||
const languageLevel = (proficiency = "") =>
|
||||
@@ -203,6 +224,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
|
||||
position: position.Title || "",
|
||||
location: position.Location || "",
|
||||
period: formatLinkedInPeriod(position["Started On"], position["Finished On"]),
|
||||
dates: linkedInPeriodDates(position["Started On"], position["Finished On"]),
|
||||
website: emptyWebsite,
|
||||
roles: [],
|
||||
description: textToHtml(position.Description),
|
||||
@@ -223,6 +245,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
|
||||
grade: "",
|
||||
location: "",
|
||||
period: formatLinkedInPeriod(edu["Start Date"], edu["End Date"]),
|
||||
dates: linkedInPeriodDates(edu["Start Date"], edu["End Date"]),
|
||||
website: emptyWebsite,
|
||||
description: textToHtml(edu.Notes),
|
||||
})),
|
||||
@@ -270,6 +293,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
|
||||
title: cert.Name ?? "",
|
||||
issuer: cert.Authority || "",
|
||||
date: formatLinkedInDate(cert["Started On"]),
|
||||
dates: linkedInSingleDates(cert["Started On"]),
|
||||
website: linkWebsite(cert.Url),
|
||||
description: "",
|
||||
})),
|
||||
|
||||
@@ -61,12 +61,12 @@ describe("parseResumeText", () => {
|
||||
company: "Analytical Engines",
|
||||
position: "Senior Engineer",
|
||||
location: "Berlin",
|
||||
period: "Jan 2020 - Present",
|
||||
period: "Jan 2020 – Present",
|
||||
});
|
||||
expect(data.sections.experience.items[1]).toMatchObject({
|
||||
company: "Babbage Ltd",
|
||||
position: "Engineer",
|
||||
period: "Mar 2016 - Dec 2019",
|
||||
period: "Mar 2016 – Dec 2019",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("parseResumeText", () => {
|
||||
expect(data.sections.education.items[0]).toMatchObject({
|
||||
school: "University of London",
|
||||
degree: "BSc Mathematics",
|
||||
period: "2012 - 2016",
|
||||
period: "2012 – 2016",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("parseResumeText review findings", () => {
|
||||
expect(data.sections.experience.items[0]).toMatchObject({
|
||||
company: "Analytical Engines",
|
||||
position: "Senior Engineer",
|
||||
period: "Jan 2020 - Present",
|
||||
period: "Jan 2020 – Present",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("parseResumeText multi-line entry preambles", () => {
|
||||
expect(data.sections.experience.items[0]).toMatchObject({
|
||||
company: "ACME CORPORATION",
|
||||
position: "Senior Engineer",
|
||||
period: "Jan 2020 - Present",
|
||||
period: "Jan 2020 – Present",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("parseResumeText multi-line entry preambles", () => {
|
||||
expect(data.sections.education.items[0]).toMatchObject({
|
||||
school: "UNIVERSITY OF LONDON",
|
||||
degree: "BSc Mathematics",
|
||||
period: "2012 - 2016",
|
||||
period: "2012 – 2016",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("parseResumeText four-line entry preambles", () => {
|
||||
company: "ACME CORPORATION",
|
||||
position: "Senior Engineer",
|
||||
location: "Berlin, Germany",
|
||||
period: "Jan 2020 - Present",
|
||||
period: "Jan 2020 – Present",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ describe("parseResumeText four-line entry preambles", () => {
|
||||
school: "UNIVERSITY OF LONDON",
|
||||
degree: "BSc Mathematics",
|
||||
location: "London, UK",
|
||||
period: "2012 - 2016",
|
||||
period: "2012 – 2016",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -314,7 +314,7 @@ describe("parseResumeText keeps content the layout hides", () => {
|
||||
);
|
||||
|
||||
expect(() => resumeDataSchema.parse(data)).not.toThrow();
|
||||
expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 - Present" });
|
||||
expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 – Present" });
|
||||
expect(data.sections.education.items[0]).toMatchObject({ school: "University of London" });
|
||||
expect(data.sections.certifications.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { parsePeriod, parseSingleDate } from "@reactive-resume/resume/ats";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { parsePeriod, parseSingleDate } from "@reactive-resume/schema/resume/period";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { BULLET_PATTERN, toHtml } from "./html";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import type router from "@reactive-resume/api/routers";
|
||||
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import schemaJSON from "@reactive-resume/schema/schema.json";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
|
||||
|
||||
export function registerResources(server: McpServer, client: RouterClient<typeof router>) {
|
||||
@@ -63,7 +63,8 @@ export function registerResources(server: McpServer, client: RouterClient<typeof
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: "application/json" as const,
|
||||
text: JSON.stringify(schemaJSON, null, 2),
|
||||
// Generated from the Zod schema, like /schema.json, so it never drifts from what writes accept.
|
||||
text: JSON.stringify(createResumeDataJsonSchema(), null, 2),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -280,7 +280,11 @@ export const TOOL_META = {
|
||||
" /metadata/template — Change the template (e.g. 'azurill', 'bronzor', 'onyx')",
|
||||
" /metadata/design/colors/primary — Change the primary color (rgba string)",
|
||||
" /sections/interests/hidden — Hide/show a section",
|
||||
" /sections/experience/items/0/dates — Set dates: { start, end, present } with years or",
|
||||
" year-months ('2022' or '2022-03')",
|
||||
"",
|
||||
"Dates: write `dates`; the text in `period` (or `date` for awards, certifications and",
|
||||
"publications) is rewritten from it in the resume's locale. Editing only the text still works.",
|
||||
"Important: HTML content fields (description, summary.content) must use valid HTML.",
|
||||
"New items must include a valid UUID as `id` and `hidden: false`.",
|
||||
`Locked resumes cannot be patched; use \`${T.unlockResume}\` first.`,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { DateToken, ExtractedDocument } from "../types";
|
||||
import { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "../../ats/period";
|
||||
import {
|
||||
isFutureEndpoint,
|
||||
isReversedPeriod,
|
||||
parsePeriod,
|
||||
parseSingleDate,
|
||||
} from "@reactive-resume/schema/resume/period";
|
||||
|
||||
const MONTH_WORD = String.raw`[A-Za-z]{3,9}\.?`;
|
||||
const ENDPOINT = String.raw`(?:${MONTH_WORD}\s*'?\d{2,4}|\d{1,2}[/.\-]\d{2,4}|\d{4})`;
|
||||
|
||||
@@ -47,8 +47,6 @@ export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {})
|
||||
}
|
||||
|
||||
export type { AtsRuleCode } from "./catalog";
|
||||
export type { ParsedPeriod, PeriodEndpoint } from "./period";
|
||||
export type { AtsFinding, AtsFindingParams, AtsReport, AtsSeverity } from "./types";
|
||||
export type { SectionPlacement, WalkedItem, WalkedSection } from "./walk";
|
||||
export { ATS_RULE_CODES } from "./catalog";
|
||||
export { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period";
|
||||
|
||||
@@ -156,6 +156,16 @@ describe("date rules", () => {
|
||||
expect(codesOf(data)).not.toContain("UNPARSEABLE_PERIOD");
|
||||
});
|
||||
|
||||
it("reads structured dates rather than their printed text", () => {
|
||||
const data = makeResume((resume) => {
|
||||
resume.metadata.page.locale = "ja-JP";
|
||||
resume.sections.experience.items = [
|
||||
experienceItem({ period: "2022年3月 – 現在", dates: { start: "2022-03", end: null, present: true } }),
|
||||
];
|
||||
});
|
||||
expect(codesOf(data)).not.toContain("UNPARSEABLE_PERIOD");
|
||||
});
|
||||
|
||||
it("flags a period that runs backwards", () => {
|
||||
const data = makeResume((resume) => {
|
||||
resume.sections.experience.items = [experienceItem({ period: "Mar 2022 - Jan 2020" })];
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { CustomSectionType, ResumeData } from "@reactive-resume/schema/resu
|
||||
import type { AtsRuleCode } from "./catalog";
|
||||
import type { AtsFinding, AtsFindingParams } from "./types";
|
||||
import type { WalkedSection } from "./walk";
|
||||
import { resumeDatesSchema, resumeDatesToPeriod } from "@reactive-resume/schema/resume/dates";
|
||||
import {
|
||||
isFutureEndpoint,
|
||||
isReversedPeriod,
|
||||
parsePeriod,
|
||||
parseSingleDate,
|
||||
} from "@reactive-resume/schema/resume/period";
|
||||
import { atsRuleSeverity } from "./catalog";
|
||||
import { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period";
|
||||
import { SECTION_TITLE_ALIASES } from "./section-aliases";
|
||||
import { isRenderedSection } from "./walk";
|
||||
|
||||
@@ -99,13 +105,26 @@ const urlRules: AtsRule = (context) => {
|
||||
return findings;
|
||||
};
|
||||
|
||||
function periodFindings(raw: unknown, pointer: string, type: CustomSectionType, context: RuleContext): AtsFinding[] {
|
||||
// Structured dates are read already; text that never parsed keeps its original in `raw`.
|
||||
const readItemDates = (dates: unknown) => {
|
||||
const parsed = resumeDatesSchema.safeParse(dates);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
};
|
||||
|
||||
function periodFindings(
|
||||
raw: unknown,
|
||||
dates: unknown,
|
||||
pointer: string,
|
||||
type: CustomSectionType,
|
||||
context: RuleContext,
|
||||
): AtsFinding[] {
|
||||
if (typeof raw !== "string") return [];
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value) return PERIOD_REQUIRED_TYPES.has(type) ? [finding("EMPTY_PERIOD", pointer)] : [];
|
||||
|
||||
const parsed = parsePeriod(value, context.locale);
|
||||
const structured = readItemDates(dates);
|
||||
const parsed = structured ? resumeDatesToPeriod(structured) : parsePeriod(value, context.locale);
|
||||
if (!parsed) return [finding("UNPARSEABLE_PERIOD", pointer, { value })];
|
||||
|
||||
const findings: AtsFinding[] = [];
|
||||
@@ -119,13 +138,15 @@ function periodFindings(raw: unknown, pointer: string, type: CustomSectionType,
|
||||
return findings;
|
||||
}
|
||||
|
||||
function singleDateFindings(raw: unknown, pointer: string, context: RuleContext): AtsFinding[] {
|
||||
function singleDateFindings(raw: unknown, dates: unknown, pointer: string, context: RuleContext): AtsFinding[] {
|
||||
if (typeof raw !== "string") return [];
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value) return [];
|
||||
|
||||
return parseSingleDate(value, context.locale) ? [] : [finding("UNPARSEABLE_DATE", pointer, { value })];
|
||||
const structured = readItemDates(dates);
|
||||
const readable = structured ? Boolean(structured.start) : Boolean(parseSingleDate(value, context.locale));
|
||||
return readable ? [] : [finding("UNPARSEABLE_DATE", pointer, { value })];
|
||||
}
|
||||
|
||||
const dateRules: AtsRule = (context) => {
|
||||
@@ -135,15 +156,17 @@ const dateRules: AtsRule = (context) => {
|
||||
if (isCoverLetter(section) || !isRenderedSection(section)) continue;
|
||||
|
||||
for (const item of section.items) {
|
||||
findings.push(...periodFindings(item.value.period, `${item.pointer}/period`, section.type, context));
|
||||
findings.push(...singleDateFindings(item.value.date, `${item.pointer}/date`, context));
|
||||
findings.push(
|
||||
...periodFindings(item.value.period, item.value.dates, `${item.pointer}/period`, section.type, context),
|
||||
);
|
||||
findings.push(...singleDateFindings(item.value.date, item.value.dates, `${item.pointer}/date`, context));
|
||||
|
||||
const roles = item.value.roles;
|
||||
if (!Array.isArray(roles)) continue;
|
||||
|
||||
roles.forEach((role, index) => {
|
||||
const value = (role as Record<string, unknown>).period;
|
||||
findings.push(...periodFindings(value, `${item.pointer}/roles/${index}/period`, section.type, context));
|
||||
const { period, dates } = role as Record<string, unknown>;
|
||||
findings.push(...periodFindings(period, dates, `${item.pointer}/roles/${index}/period`, section.type, context));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { PeriodEndpoint } from "./ats/period";
|
||||
import { isReversedPeriod, parsePeriod } from "./ats/period";
|
||||
import type { ResumeDates } from "@reactive-resume/schema/resume/dates";
|
||||
import type { PeriodEndpoint } from "@reactive-resume/schema/resume/period";
|
||||
import { resumeDatesToPeriod } from "@reactive-resume/schema/resume/dates";
|
||||
import { isReversedPeriod, parsePeriod } from "@reactive-resume/schema/resume/period";
|
||||
|
||||
type SectionItemWithPeriod = {
|
||||
id: string;
|
||||
period: string;
|
||||
dates?: ResumeDates | undefined;
|
||||
};
|
||||
|
||||
export type SortSectionItemsByPeriodResult<T> = {
|
||||
@@ -30,7 +33,7 @@ function compareEndpointDescending(left: PeriodEndpoint | undefined, right: Peri
|
||||
}
|
||||
|
||||
function rankItem<T extends SectionItemWithPeriod>(item: T, index: number, locale: string): RankedItem<T> {
|
||||
const period = parsePeriod(item.period, locale);
|
||||
const period = item.dates ? resumeDatesToPeriod(item.dates) : parsePeriod(item.period, locale);
|
||||
if (!period?.start || (period.end && isReversedPeriod(period.start, period.end))) {
|
||||
return { item, index, group: "unresolved" };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"./icons": "./src/icons.ts",
|
||||
"./resume/stylesheet": "./src/resume/stylesheet.ts",
|
||||
"./resume/*": "./src/resume/*.ts",
|
||||
"./schema.json": "./schema.json",
|
||||
"./templates": "./src/templates.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -357,7 +357,7 @@ describe("customFieldSchema", () => {
|
||||
});
|
||||
|
||||
describe("experienceItemSchema", () => {
|
||||
it("requires company name (min 1)", () => {
|
||||
it("accepts a draft without a company (drafts aren't printed)", () => {
|
||||
const result = experienceItemSchema.safeParse({
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
@@ -369,7 +369,7 @@ describe("experienceItemSchema", () => {
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates a complete experience item", () => {
|
||||
@@ -404,8 +404,8 @@ describe("experienceItemSchema", () => {
|
||||
});
|
||||
|
||||
describe("skillItemSchema", () => {
|
||||
it("requires name (min 1)", () => {
|
||||
const invalid = {
|
||||
it("accepts a draft without a name (drafts aren't printed)", () => {
|
||||
const draft = {
|
||||
id: "x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
@@ -415,7 +415,7 @@ describe("skillItemSchema", () => {
|
||||
level: 4,
|
||||
keywords: [],
|
||||
};
|
||||
expect(skillItemSchema.safeParse(invalid).success).toBe(false);
|
||||
expect(skillItemSchema.safeParse(draft).success).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps invalid level via .catch(0)", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { templateSchema } from "../templates";
|
||||
import { dateFormatSchema, resumeDatesSchema, syncResumeDates, upgradeResumeDates } from "./dates";
|
||||
import { semanticStylesheetSchema } from "./stylesheet";
|
||||
|
||||
const iconSchema = z
|
||||
@@ -132,28 +133,43 @@ export const summaryItemSchema = baseItemSchema.extend({
|
||||
export type SummaryItem = z.infer<typeof summaryItemSchema>;
|
||||
|
||||
export const awardItemSchema = baseItemSchema.extend({
|
||||
title: z.string().min(1).describe("The title of the award."),
|
||||
title: z.string().describe("The title of the award. Empty while the entry is a draft, which isn't printed."),
|
||||
awarder: z.string().describe("The awarder of the award."),
|
||||
date: z.string().describe("The date when the award was received."),
|
||||
date: z
|
||||
.string()
|
||||
.describe("The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`."),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The website of the award, if any."),
|
||||
description: z.string().describe("The description of the award. This should be a HTML-formatted string."),
|
||||
});
|
||||
|
||||
export const certificationItemSchema = baseItemSchema.extend({
|
||||
title: z.string().min(1).describe("The title of the certification."),
|
||||
title: z.string().describe("The title of the certification. Empty while the entry is a draft, which isn't printed."),
|
||||
issuer: z.string().describe("The issuer of the certification."),
|
||||
date: z.string().describe("The date when the certification was received."),
|
||||
date: z
|
||||
.string()
|
||||
.describe(
|
||||
"The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The website of the certification, if any."),
|
||||
description: z.string().describe("The description of the certification. This should be a HTML-formatted string."),
|
||||
});
|
||||
|
||||
export const educationItemSchema = baseItemSchema.extend({
|
||||
school: z.string().min(1).describe("The name of the school or institution."),
|
||||
school: z
|
||||
.string()
|
||||
.describe("The name of the school or institution. Empty while the entry is a draft, which isn't printed."),
|
||||
degree: z.string().describe("The degree or qualification obtained."),
|
||||
area: z.string().describe("The area of study or specialization."),
|
||||
grade: z.string().describe("The grade or score achieved."),
|
||||
location: z.string().describe("The location of the school or institution."),
|
||||
period: z.string().describe("The period of time the education was obtained over."),
|
||||
period: z
|
||||
.string()
|
||||
.describe(
|
||||
"The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The website of the school or institution, if any."),
|
||||
description: z.string().describe("The description of the education. This should be a HTML-formatted string."),
|
||||
});
|
||||
@@ -161,14 +177,19 @@ export const educationItemSchema = baseItemSchema.extend({
|
||||
const roleItemSchema = z.object({
|
||||
id: z.string().describe("The unique identifier for the role. Usually generated as a UUID."),
|
||||
position: z.string().describe("The position or job title for this role."),
|
||||
period: z.string().describe("The period of time this role was held."),
|
||||
period: z
|
||||
.string()
|
||||
.describe("The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`."),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
description: z.string().describe("The description of this specific role. This should be a HTML-formatted string."),
|
||||
});
|
||||
|
||||
export type RoleItem = z.infer<typeof roleItemSchema>;
|
||||
|
||||
export const experienceItemSchema = baseItemSchema.extend({
|
||||
company: z.string().min(1).describe("The name of the company or organization."),
|
||||
company: z
|
||||
.string()
|
||||
.describe("The name of the company or organization. Empty while the entry is a draft, which isn't printed."),
|
||||
position: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -178,8 +199,9 @@ export const experienceItemSchema = baseItemSchema.extend({
|
||||
period: z
|
||||
.string()
|
||||
.describe(
|
||||
"The overall period of time at the company. When multiple roles are used, this should reflect the total tenure.",
|
||||
"The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The website of the company or organization, if any."),
|
||||
description: z.string().describe("The description of the experience. This should be a HTML-formatted string."),
|
||||
roles: z
|
||||
@@ -191,7 +213,7 @@ export const experienceItemSchema = baseItemSchema.extend({
|
||||
export const interestItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
name: z.string().min(1).describe("The name of the interest/hobby."),
|
||||
name: z.string().describe("The name of the interest/hobby. Empty while the entry is a draft, which isn't printed."),
|
||||
keywords: z
|
||||
.array(z.string())
|
||||
.catch([])
|
||||
@@ -199,7 +221,9 @@ export const interestItemSchema = baseItemSchema.extend({
|
||||
});
|
||||
|
||||
export const languageItemSchema = baseItemSchema.extend({
|
||||
language: z.string().min(1).describe("The name of the language the author knows."),
|
||||
language: z
|
||||
.string()
|
||||
.describe("The name of the language the author knows. Empty while the entry is a draft, which isn't printed."),
|
||||
fluency: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -218,28 +242,44 @@ export const languageItemSchema = baseItemSchema.extend({
|
||||
export const profileItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
network: z.string().min(1).describe("The name of the network or platform."),
|
||||
network: z
|
||||
.string()
|
||||
.describe("The name of the network or platform. Empty while the entry is a draft, which isn't printed."),
|
||||
username: z.string().describe("The username of the author on the network or platform."),
|
||||
website: itemWebsiteSchema.describe("The link to the profile of the author on the network or platform, if any."),
|
||||
});
|
||||
|
||||
export const projectItemSchema = baseItemSchema.extend({
|
||||
name: z.string().min(1).describe("The name of the project."),
|
||||
period: z.string().describe("The period of time the project was worked on."),
|
||||
name: z.string().describe("The name of the project. Empty while the entry is a draft, which isn't printed."),
|
||||
period: z
|
||||
.string()
|
||||
.describe(
|
||||
"The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The link to the project, if any."),
|
||||
description: z.string().describe("The description of the project. This should be a HTML-formatted string."),
|
||||
});
|
||||
|
||||
export const publicationItemSchema = baseItemSchema.extend({
|
||||
title: z.string().min(1).describe("The title of the publication."),
|
||||
title: z.string().describe("The title of the publication. Empty while the entry is a draft, which isn't printed."),
|
||||
publisher: z.string().describe("The publisher of the publication."),
|
||||
date: z.string().describe("The date when the publication was published."),
|
||||
date: z
|
||||
.string()
|
||||
.describe(
|
||||
"The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The link to the publication, if any."),
|
||||
description: z.string().describe("The description of the publication. This should be a HTML-formatted string."),
|
||||
});
|
||||
|
||||
export const referenceItemSchema = baseItemSchema.extend({
|
||||
name: z.string().min(1).describe("The name of the reference, or a note such as 'Available upon request'."),
|
||||
name: z
|
||||
.string()
|
||||
.describe(
|
||||
"The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed.",
|
||||
),
|
||||
position: z.string().describe("The position or job title of the reference."),
|
||||
website: itemWebsiteSchema.describe("The website or LinkedIn profile of the reference, if any."),
|
||||
phone: z.string().describe("The phone number of the reference."),
|
||||
@@ -253,7 +293,7 @@ export const referenceItemSchema = baseItemSchema.extend({
|
||||
export const skillItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
name: z.string().min(1).describe("The name of the skill."),
|
||||
name: z.string().describe("The name of the skill. Empty while the entry is a draft, which isn't printed."),
|
||||
proficiency: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -274,9 +314,16 @@ export const skillItemSchema = baseItemSchema.extend({
|
||||
});
|
||||
|
||||
export const volunteerItemSchema = baseItemSchema.extend({
|
||||
organization: z.string().min(1).describe("The name of the organization or company."),
|
||||
organization: z
|
||||
.string()
|
||||
.describe("The name of the organization or company. Empty while the entry is a draft, which isn't printed."),
|
||||
location: z.string().describe("The location of the organization or company."),
|
||||
period: z.string().describe("The period of time the author was volunteered at the organization or company."),
|
||||
period: z
|
||||
.string()
|
||||
.describe(
|
||||
"The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`.",
|
||||
),
|
||||
dates: resumeDatesSchema.optional(),
|
||||
website: itemWebsiteSchema.describe("The link to the organization or company, if any."),
|
||||
description: z
|
||||
.string()
|
||||
@@ -494,6 +541,12 @@ export const pageSchema = z.object({
|
||||
.string()
|
||||
.describe("The locale of the page. Used for displaying pre-translated section headings, if not overridden.")
|
||||
.catch("en-US"),
|
||||
dateFormat: dateFormatSchema
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.describe(
|
||||
"How dates print: 'short' (Mar 2022), 'long' (March 2022), 'numeric' (03/2022) or 'iso' (2022-03). When missing, it's read from how the dates were typed.",
|
||||
),
|
||||
hideLinkUnderline: z.boolean().describe("Whether to hide the underlines of the links.").catch(false),
|
||||
hideIcons: z.boolean().describe("Whether to hide the item-level icons (skills, profiles, interests).").catch(false),
|
||||
hideSectionIcons: z
|
||||
@@ -709,6 +762,8 @@ export const parseResumeData = (data: unknown): ResumeData => {
|
||||
parsed.summary.showHeading ??= true;
|
||||
for (const section of Object.values(parsed.sections)) section.showHeading ??= true;
|
||||
for (const section of parsed.customSections) section.showHeading ??= true;
|
||||
upgradeResumeDates(parsed);
|
||||
syncResumeDates(parsed);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { ResumeData } from "./data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseResumeData } from "./data";
|
||||
import {
|
||||
formatEntryDates,
|
||||
formatResumeDates,
|
||||
formatYearMonth,
|
||||
getPresentLabel,
|
||||
inferDateFormat,
|
||||
readLegacyDates,
|
||||
resumeDatesSchema,
|
||||
syncResumeDates,
|
||||
} from "./dates";
|
||||
import { defaultResumeData } from "./default";
|
||||
|
||||
const range = (text: string, locale = "en-US") => readLegacyDates(text, { locale, single: false });
|
||||
const single = (text: string, locale = "en-US") => readLegacyDates(text, { locale, single: true });
|
||||
|
||||
describe("readLegacyDates", () => {
|
||||
it.each([
|
||||
["March 2022 - Present", { start: "2022-03", end: null, present: true }],
|
||||
["Mar 2020 – Dec 2021", { start: "2020-03", end: "2021-12", present: false }],
|
||||
["2014 - 2018", { start: "2014", end: "2018", present: false }],
|
||||
["03/2020 - 06/2021", { start: "2020-03", end: "2021-06", present: false }],
|
||||
["2020-03 to 2021-06", { start: "2020-03", end: "2021-06", present: false }],
|
||||
["2024 - 2020", { start: "2024", end: "2020", present: false }],
|
||||
["2019", { start: "2019", end: null, present: false }],
|
||||
])("reads %j exactly, with no review flag", (text, expected) => {
|
||||
expect(range(text)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("reads month names and 'Present' in the resume's own locale", () => {
|
||||
expect(range("janvier 2020 - aujourd'hui", "fr-FR")).toEqual({ start: "2020-01", end: null, present: true });
|
||||
expect(range("März 2021 – heute", "de-DE")).toEqual({ start: "2021-03", end: null, present: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Summer 2016 - 2018", { start: "2016-06", end: "2018", present: false }],
|
||||
["15/03/2020 - 2021", { start: "2020-03", end: "2021", present: false }],
|
||||
["March 3, 2020 - Present", { start: "2020-03", end: null, present: true }],
|
||||
])("keeps approximate text %j for a review", (text, expected) => {
|
||||
expect(range(text)).toEqual({ ...expected, raw: text });
|
||||
});
|
||||
|
||||
it("keeps unreadable text as written, with no dates", () => {
|
||||
expect(range("Sometime in college")).toEqual({
|
||||
start: null,
|
||||
end: null,
|
||||
present: false,
|
||||
raw: "Sometime in college",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing from empty text and doesn't flag it", () => {
|
||||
expect(range(" ")).toEqual({ start: null, end: null, present: false });
|
||||
});
|
||||
|
||||
it("reads single dates for awards, certifications and publications", () => {
|
||||
expect(single("June 2021")).toEqual({ start: "2021-06", end: null, present: false });
|
||||
expect(single("2021-06-14")).toEqual({ start: "2021-06", end: null, present: false, raw: "2021-06-14" });
|
||||
expect(single("Spring term")).toEqual({ start: null, end: null, present: false, raw: "Spring term" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeDatesSchema", () => {
|
||||
it("accepts years and year-months only", () => {
|
||||
expect(resumeDatesSchema.parse({ start: "2022-03", end: "2023", present: false })).toEqual({
|
||||
start: "2022-03",
|
||||
end: "2023",
|
||||
present: false,
|
||||
});
|
||||
expect(resumeDatesSchema.parse({ start: "2022-13", end: "22", present: "no" })).toEqual({
|
||||
start: null,
|
||||
end: null,
|
||||
present: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferDateFormat", () => {
|
||||
it("follows how most dates were typed", () => {
|
||||
expect(inferDateFormat(["March 2020 - Present", "January 2018 - February 2020"], "en-US")).toBe("long");
|
||||
expect(inferDateFormat(["Mar 2020 - Present", "Sept 2019 - Dec 2019"], "en-US")).toBe("short");
|
||||
expect(inferDateFormat(["03/2020 - 06/2021"], "en-US")).toBe("numeric");
|
||||
expect(inferDateFormat(["2020-03 - 2021-06"], "en-US")).toBe("iso");
|
||||
});
|
||||
|
||||
it("falls back to short month names when nothing tells", () => {
|
||||
expect(inferDateFormat(["2014 - 2018", "May 2020", ""], "en-US")).toBe("short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPresentLabel", () => {
|
||||
it("uses the translation when there is one, else the language's usual word", () => {
|
||||
expect(getPresentLabel("en-US")).toBe("Present");
|
||||
expect(getPresentLabel("de-DE")).toBe("Heute");
|
||||
expect(getPresentLabel("xx-XX")).toBe("Present");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upgradeResumeDates", () => {
|
||||
const withExperience = (period: string) => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
// Saved before structured dates: no date format yet.
|
||||
delete data.metadata.page.dateFormat;
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "job",
|
||||
hidden: false,
|
||||
company: "Lumen",
|
||||
position: "Designer",
|
||||
location: "",
|
||||
period,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [{ id: "role", position: "Lead", period: "Jan 2023 - Present", description: "" }],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
};
|
||||
|
||||
it("fills dates on entries and their roles, and the format from how they were typed", () => {
|
||||
const data = parseResumeData(withExperience("January 2020 - March 2022"));
|
||||
const [job] = data.sections.experience.items;
|
||||
|
||||
expect(job?.dates).toEqual({ start: "2020-01", end: "2022-03", present: false });
|
||||
expect(job?.roles[0]?.dates).toEqual({ start: "2023-01", end: null, present: true });
|
||||
expect(data.metadata.page.dateFormat).toBe("long");
|
||||
});
|
||||
|
||||
it("is idempotent and never overwrites existing dates", () => {
|
||||
const once = parseResumeData(withExperience("2020 - 2022"));
|
||||
const [job] = once.sections.experience.items;
|
||||
if (!job) throw new Error("Missing entry.");
|
||||
job.dates = { start: "2019-05", end: null, present: true };
|
||||
|
||||
const twice = parseResumeData(JSON.parse(JSON.stringify(once)));
|
||||
const thrice = parseResumeData(JSON.parse(JSON.stringify(twice)));
|
||||
|
||||
expect(twice.sections.experience.items[0]).toMatchObject({
|
||||
dates: { start: "2019-05", end: null, present: true },
|
||||
period: "May 2019 – Present",
|
||||
});
|
||||
expect(thrice).toEqual(twice);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatYearMonth", () => {
|
||||
it.each([
|
||||
["short", "en-US", "Mar 2022"],
|
||||
["long", "en-US", "March 2022"],
|
||||
["numeric", "en-US", "03/2022"],
|
||||
["iso", "en-US", "2022-03"],
|
||||
["long", "de-DE", "März 2022"],
|
||||
["short", "fr-FR", "mars 2022"],
|
||||
] as const)("prints 2022-03 as %s in %s", (format, locale, expected) => {
|
||||
expect(formatYearMonth("2022-03", { locale, format })).toBe(expected);
|
||||
});
|
||||
|
||||
it("prints year-only dates as the year in every format", () => {
|
||||
expect(formatYearMonth("2014", { locale: "en-US", format: "long" })).toBe("2014");
|
||||
expect(formatYearMonth("2014", { locale: "en-US", format: "numeric" })).toBe("2014");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResumeDates", () => {
|
||||
const options = { locale: "en-US", format: "short" } as const;
|
||||
|
||||
it("prints ranges, ongoing entries and single dates", () => {
|
||||
expect(formatResumeDates({ start: "2022-03", end: null, present: true }, options)).toBe("Mar 2022 – Present");
|
||||
expect(formatResumeDates({ start: "2014", end: "2018", present: false }, options)).toBe("2014 – 2018");
|
||||
expect(formatResumeDates({ start: "2021-06", end: null, present: false }, options)).toBe("Jun 2021");
|
||||
expect(formatResumeDates({ start: null, end: null, present: false }, options)).toBe("");
|
||||
});
|
||||
|
||||
it("prints 'Present' in the resume's locale", () => {
|
||||
expect(formatResumeDates({ start: "2021", end: null, present: true }, { locale: "de-DE" })).toBe("2021 – Heute");
|
||||
});
|
||||
|
||||
it("prints text it couldn't read exactly as written, until it's reviewed", () => {
|
||||
const dates = { start: "2016-06", end: "2018", present: false, raw: "Summer 2016 - 2018" };
|
||||
expect(formatResumeDates(dates, options)).toBe("Summer 2016 - 2018");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEntryDates", () => {
|
||||
it("falls back to the legacy text for data without dates", () => {
|
||||
expect(formatEntryDates(undefined, "whenever", { locale: "en-US" })).toBe("whenever");
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncResumeDates", () => {
|
||||
const resume = (period: string, extra: Partial<ResumeData["sections"]["experience"]["items"][number]> = {}) => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.page.dateFormat = "short";
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "job",
|
||||
hidden: false,
|
||||
company: "Lumen",
|
||||
position: "Designer",
|
||||
location: "",
|
||||
period,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [],
|
||||
...extra,
|
||||
},
|
||||
];
|
||||
data.sections.awards.items = [
|
||||
{
|
||||
id: "award",
|
||||
hidden: false,
|
||||
title: "Best in show",
|
||||
awarder: "",
|
||||
date: "June 2021",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
return data;
|
||||
};
|
||||
|
||||
const job = (data: ResumeData) => data.sections.experience.items[0];
|
||||
|
||||
it("gives entries without dates their dates, and rewrites the text from them", () => {
|
||||
const data = resume("March 2022 - Present");
|
||||
syncResumeDates(data);
|
||||
|
||||
expect(job(data)?.dates).toEqual({ start: "2022-03", end: null, present: true });
|
||||
expect(job(data)?.period).toBe("Mar 2022 – Present");
|
||||
expect(data.sections.awards.items[0]).toMatchObject({ date: "Jun 2021", dates: { start: "2021-06" } });
|
||||
});
|
||||
|
||||
it("rewrites the text when the dates change (dates are the source of truth)", () => {
|
||||
const previous = parseResumeData(resume("2020 - 2022"));
|
||||
syncResumeDates(previous);
|
||||
const next = structuredClone(previous);
|
||||
const entry = job(next);
|
||||
if (!entry) throw new Error("Missing entry.");
|
||||
entry.dates = { start: "2019-04", end: null, present: true };
|
||||
|
||||
syncResumeDates(next, previous);
|
||||
|
||||
expect(job(next)?.period).toBe("Apr 2019 – Present");
|
||||
});
|
||||
|
||||
it("reads the text again when only the text changed, as clients unaware of dates do", () => {
|
||||
const previous = parseResumeData(resume("2020 - 2022"));
|
||||
syncResumeDates(previous);
|
||||
const next = structuredClone(previous);
|
||||
const entry = job(next);
|
||||
if (!entry) throw new Error("Missing entry.");
|
||||
entry.period = "Jan 2018 - Present";
|
||||
|
||||
syncResumeDates(next, previous);
|
||||
|
||||
expect(job(next)?.dates).toEqual({ start: "2018-01", end: null, present: true });
|
||||
expect(job(next)?.period).toBe("Jan 2018 – Present");
|
||||
});
|
||||
|
||||
it("reformats every entry when the locale or format changes, without reading the text again", () => {
|
||||
const previous = parseResumeData(resume("March 2022 - Present"));
|
||||
syncResumeDates(previous);
|
||||
const next = structuredClone(previous);
|
||||
next.metadata.page.locale = "ja-JP";
|
||||
next.metadata.page.dateFormat = "long";
|
||||
|
||||
syncResumeDates(next, previous);
|
||||
|
||||
expect(job(next)?.dates).toEqual({ start: "2022-03", end: null, present: true });
|
||||
expect(job(next)?.period).toBe(
|
||||
`2022年3月 – ${formatResumeDates({ start: null, end: null, present: true }, { locale: "ja-JP" })}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves text it couldn't read untouched, and is idempotent", () => {
|
||||
const data = resume("Summer 2016 - 2018");
|
||||
syncResumeDates(data);
|
||||
const once = structuredClone(data);
|
||||
syncResumeDates(data, once);
|
||||
|
||||
expect(job(data)?.period).toBe("Summer 2016 - 2018");
|
||||
expect(job(data)?.dates?.raw).toBe("Summer 2016 - 2018");
|
||||
expect(data).toEqual(once);
|
||||
});
|
||||
|
||||
it("keeps roles in step too", () => {
|
||||
const data = resume("2020 - 2023", {
|
||||
roles: [{ id: "role", position: "Lead", period: "2022 - Present", description: "" }],
|
||||
});
|
||||
syncResumeDates(data);
|
||||
|
||||
expect(job(data)?.roles[0]).toMatchObject({ period: "2022 – Present", dates: { start: "2022", present: true } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { ResumeData } from "./data";
|
||||
import type { EndpointReading, ParsedPeriod, PeriodEndpoint } from "./period";
|
||||
import z from "zod";
|
||||
import { ONGOING_TOKENS_BY_LANGUAGE, readPeriod, readSingleDate } from "./period";
|
||||
import presentLabels from "./present-labels.json";
|
||||
|
||||
/** A year ("2022") or a year and month ("2022-03"). A month is optional, so year-only dates are exact. */
|
||||
export const yearMonthSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}(?:-(?:0[1-9]|1[0-2]))?$/)
|
||||
.describe('A year ("2022") or a year and month ("2022-03").');
|
||||
|
||||
export type YearMonth = z.infer<typeof yearMonthSchema>;
|
||||
|
||||
export const resumeDatesSchema = z
|
||||
.object({
|
||||
start: yearMonthSchema
|
||||
.nullable()
|
||||
.catch(null)
|
||||
.describe("When it started, or the date itself for single-date entries (awards, certifications, publications)."),
|
||||
end: yearMonthSchema.nullable().catch(null).describe("When it ended. Null while ongoing or for single dates."),
|
||||
present: z.boolean().catch(false).describe("Whether it's ongoing, printed as 'Present'."),
|
||||
raw: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them.",
|
||||
);
|
||||
|
||||
export type ResumeDates = z.infer<typeof resumeDatesSchema>;
|
||||
|
||||
/** How dates print: Mar 2022, March 2022, 03/2022 or 2022-03. */
|
||||
export const dateFormatSchema = z.enum(["short", "long", "numeric", "iso"]);
|
||||
|
||||
export type DateFormat = z.infer<typeof dateFormatSchema>;
|
||||
|
||||
export const DEFAULT_DATE_FORMAT: DateFormat = "short";
|
||||
|
||||
export const EMPTY_RESUME_DATES: ResumeDates = { start: null, end: null, present: false };
|
||||
|
||||
/** Dates of the same entry are the same when their fields are; `raw` counts, since it changes what prints. */
|
||||
export const areResumeDatesEqual = (a: ResumeDates | undefined, b: ResumeDates | undefined) =>
|
||||
a?.start === b?.start && a?.end === b?.end && a?.present === b?.present && a?.raw === b?.raw;
|
||||
|
||||
const labels: Record<string, string> = presentLabels;
|
||||
|
||||
/**
|
||||
* "Present" in a resume's locale, as dates print it. Locales without a translation yet fall back to the
|
||||
* language's usual word from the parser's list ("Heute", "Presente"), then to English.
|
||||
*/
|
||||
export function getPresentLabel(locale: string): string {
|
||||
const language = locale.split("-")[0]?.toLowerCase() ?? "";
|
||||
const translated = labels[locale];
|
||||
if (translated && (translated !== "Present" || language === "en")) return translated;
|
||||
|
||||
const word = ONGOING_TOKENS_BY_LANGUAGE[language]?.[0];
|
||||
if (!word) return "Present";
|
||||
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
||||
}
|
||||
|
||||
const toEndpoint = (value: YearMonth): PeriodEndpoint => {
|
||||
const [year, month] = value.split("-").map(Number);
|
||||
return month ? { year: year ?? 0, month } : { year: year ?? 0 };
|
||||
};
|
||||
|
||||
/** Dates as the period shape sorting and checks use; null when nothing was read (no start). */
|
||||
export const resumeDatesToPeriod = (dates: ResumeDates): ParsedPeriod | null =>
|
||||
dates.start
|
||||
? {
|
||||
start: toEndpoint(dates.start),
|
||||
...(dates.end ? { end: toEndpoint(dates.end) } : {}),
|
||||
ongoing: dates.present,
|
||||
}
|
||||
: null;
|
||||
|
||||
export const toYearMonth = ({ year, month }: PeriodEndpoint): YearMonth =>
|
||||
month === undefined ? String(year) : `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
// Seasons and days don't fit a year-month exactly, so readings that use them keep the original text.
|
||||
const isExact = (reading: EndpointReading | undefined) =>
|
||||
!reading || (reading.style !== "season" && reading.style !== "day");
|
||||
|
||||
/**
|
||||
* Reads a legacy date text into structured dates. Exact readings (years, months by name or number, and
|
||||
* "Present") drop the text; approximate or unreadable ones keep it in `raw`, which asks for a review and
|
||||
* prints as written until then.
|
||||
*/
|
||||
export function readLegacyDates(text: string, options: { locale: string; single: boolean }): ResumeDates {
|
||||
const value = text.trim();
|
||||
if (!value) return { ...EMPTY_RESUME_DATES };
|
||||
|
||||
if (options.single) {
|
||||
const reading = readSingleDate(value, options.locale);
|
||||
if (!reading) return { ...EMPTY_RESUME_DATES, raw: value };
|
||||
const dates: ResumeDates = { start: toYearMonth(reading.endpoint), end: null, present: false };
|
||||
return isExact(reading) ? dates : { ...dates, raw: value };
|
||||
}
|
||||
|
||||
const reading = readPeriod(value, options.locale, [getPresentLabel(options.locale)]);
|
||||
if (!reading?.start) return { ...EMPTY_RESUME_DATES, raw: value };
|
||||
|
||||
const dates: ResumeDates = {
|
||||
start: toYearMonth(reading.start.endpoint),
|
||||
end: reading.end ? toYearMonth(reading.end.endpoint) : null,
|
||||
present: reading.ongoing,
|
||||
};
|
||||
return isExact(reading.start) && isExact(reading.end) ? dates : { ...dates, raw: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses the date format a resume was written in from its legacy texts, so upgraded resumes keep printing
|
||||
* the way they were typed: month names long or short, numeric months, or ISO. Year-only dates don't vote.
|
||||
*/
|
||||
export function inferDateFormat(texts: readonly string[], locale: string): DateFormat {
|
||||
const votes: Record<DateFormat, number> = { short: 0, long: 0, numeric: 0, iso: 0 };
|
||||
|
||||
for (const text of texts) {
|
||||
const reading = readPeriod(text, locale) ?? { start: readSingleDate(text, locale) ?? undefined };
|
||||
for (const endpoint of [reading.start, "end" in reading ? reading.end : undefined]) {
|
||||
const style = endpoint?.style;
|
||||
if (style === "short" || style === "long" || style === "numeric" || style === "iso") votes[style] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const [winner, count] = Object.entries(votes).sort((a, b) => b[1] - a[1])[0] as [DateFormat, number];
|
||||
return count > 0 ? winner : DEFAULT_DATE_FORMAT;
|
||||
}
|
||||
|
||||
/** An entry that carries dates, with the legacy text field that mirrors them. */
|
||||
export type DatedEntry = { dates?: ResumeDates | undefined } & ({ period: string } | { date: string });
|
||||
|
||||
type DatedEntryVisitor = (entry: DatedEntry, field: "period" | "date") => void;
|
||||
|
||||
const RANGE_TYPES = new Set(["experience", "education", "projects", "volunteer"]);
|
||||
const SINGLE_TYPES = new Set(["awards", "certifications", "publications"]);
|
||||
|
||||
function visitItems(type: string, items: readonly unknown[], visit: DatedEntryVisitor) {
|
||||
const field = RANGE_TYPES.has(type) ? "period" : SINGLE_TYPES.has(type) ? "date" : null;
|
||||
if (!field) return;
|
||||
|
||||
for (const item of items as DatedEntry[]) {
|
||||
visit(item, field);
|
||||
if (type !== "experience") continue;
|
||||
for (const role of (item as { roles?: DatedEntry[] }).roles ?? []) visit(role, "period");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits every dated entry: experience (and its roles), education, projects and volunteer by period; awards,
|
||||
* certifications and publications by date; custom sections of those types too. Mutating visitors are fine,
|
||||
* including inside an immer draft.
|
||||
*/
|
||||
export function forEachDatedEntry(data: ResumeData, visit: DatedEntryVisitor) {
|
||||
for (const [type, section] of Object.entries(data.sections)) visitItems(type, section.items, visit);
|
||||
for (const section of data.customSections) visitItems(section.type, section.items, visit);
|
||||
}
|
||||
|
||||
export const getLegacyDateText = (entry: DatedEntry, field: "period" | "date") =>
|
||||
field === "period" ? (entry as { period: string }).period : (entry as { date: string }).date;
|
||||
|
||||
/**
|
||||
* Read-time upgrade for resumes saved before structured dates: fills `dates` from each entry's legacy text,
|
||||
* and the date format from how those texts were typed. Entries that already have dates keep them.
|
||||
*/
|
||||
export function upgradeResumeDates(data: ResumeData) {
|
||||
const { page } = data.metadata;
|
||||
|
||||
if (!page.dateFormat) {
|
||||
const texts: string[] = [];
|
||||
forEachDatedEntry(data, (entry, field) => {
|
||||
const text = getLegacyDateText(entry, field);
|
||||
if (!entry.dates && text) texts.push(text);
|
||||
});
|
||||
page.dateFormat = inferDateFormat(texts, page.locale);
|
||||
}
|
||||
|
||||
forEachDatedEntry(data, (entry, field) => {
|
||||
entry.dates ??= readLegacyDates(getLegacyDateText(entry, field), { locale: page.locale, single: field === "date" });
|
||||
});
|
||||
}
|
||||
|
||||
export type DateFormatOptions = {
|
||||
locale: string;
|
||||
format?: DateFormat | undefined;
|
||||
/** Defaults to "Present" in the locale. */
|
||||
presentLabel?: string | undefined;
|
||||
};
|
||||
|
||||
const monthFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function formatMonth(year: number, month: number, locale: string, style: "short" | "long") {
|
||||
const key = `${locale}:${style}`;
|
||||
let formatter = monthFormatters.get(key);
|
||||
if (!formatter) {
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat(locale, { year: "numeric", month: style, timeZone: "UTC" });
|
||||
} catch {
|
||||
formatter = new Intl.DateTimeFormat("en-US", { year: "numeric", month: style, timeZone: "UTC" });
|
||||
}
|
||||
monthFormatters.set(key, formatter);
|
||||
}
|
||||
return formatter.format(Date.UTC(year, month - 1, 1));
|
||||
}
|
||||
|
||||
/** One date in the chosen format: Mar 2022, March 2022, 03/2022 or 2022-03. Year-only dates print the year. */
|
||||
export function formatYearMonth(value: YearMonth, { locale, format = DEFAULT_DATE_FORMAT }: DateFormatOptions) {
|
||||
const [yearText, monthText] = value.split("-");
|
||||
if (!monthText) return yearText ?? value;
|
||||
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
if (format === "iso") return value;
|
||||
if (format === "numeric") return `${monthText}/${yearText}`;
|
||||
return formatMonth(year, month, locale, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dates as they print: "Mar 2022 – Present", "2014 – 2018" or one date. Dates that still carry `raw` (text that
|
||||
* couldn't be read exactly) print that text as written until someone reviews them.
|
||||
*/
|
||||
export function formatResumeDates(dates: ResumeDates, options: DateFormatOptions): string {
|
||||
if (dates.raw !== undefined) return dates.raw;
|
||||
|
||||
const start = dates.start ? formatYearMonth(dates.start, options) : "";
|
||||
const end = dates.present
|
||||
? (options.presentLabel ?? getPresentLabel(options.locale))
|
||||
: dates.end
|
||||
? formatYearMonth(dates.end, options)
|
||||
: "";
|
||||
|
||||
return start && end ? `${start} – ${end}` : start || end;
|
||||
}
|
||||
|
||||
type PageDateOptions = { locale: string; dateFormat?: DateFormat | undefined };
|
||||
|
||||
/** Prints an entry's dates, or its legacy text when it has none (data that skipped the read-time upgrade). */
|
||||
export const formatEntryDates = (dates: ResumeDates | undefined, text: string, page: PageDateOptions) =>
|
||||
dates ? formatResumeDates(dates, { locale: page.locale, format: page.dateFormat }) : text;
|
||||
|
||||
type Snapshot = { text: string; dates: ResumeDates | undefined };
|
||||
|
||||
const entryKey = (entry: DatedEntry) => (entry as { id?: string }).id;
|
||||
|
||||
/**
|
||||
* Keeps structured dates and the legacy text (`period` or `date`) in step before resume data is saved, so
|
||||
* older app versions and API clients that read the text keep working (the dual write).
|
||||
*
|
||||
* Dates are the source of truth: the text is rewritten from them in the resume's locale and date format.
|
||||
* The one exception is an edit that changed only the text since `previous` (a client that doesn't know
|
||||
* structured dates); then the text is read into new dates. Entries without dates get them from their text.
|
||||
*
|
||||
* Mutates `data` in place and writes only what changed, so it's safe inside an immer draft.
|
||||
*/
|
||||
export function syncResumeDates(data: ResumeData, previous?: ResumeData) {
|
||||
const { locale, dateFormat } = data.metadata.page;
|
||||
const options: DateFormatOptions = { locale, format: dateFormat, presentLabel: getPresentLabel(locale) };
|
||||
|
||||
const before = new Map<string, Snapshot>();
|
||||
if (previous) {
|
||||
forEachDatedEntry(previous, (entry, field) => {
|
||||
const key = entryKey(entry);
|
||||
if (key) before.set(key, { text: getLegacyDateText(entry, field), dates: entry.dates });
|
||||
});
|
||||
}
|
||||
|
||||
forEachDatedEntry(data, (entry, field) => {
|
||||
const text = getLegacyDateText(entry, field);
|
||||
const read = () => readLegacyDates(text, { locale, single: field === "date" });
|
||||
|
||||
if (!entry.dates) {
|
||||
entry.dates = read();
|
||||
} else if (text !== formatResumeDates(entry.dates, options)) {
|
||||
const key = entryKey(entry);
|
||||
const snapshot = key ? before.get(key) : undefined;
|
||||
const onlyTextChanged = snapshot && snapshot.text !== text && areResumeDatesEqual(snapshot.dates, entry.dates);
|
||||
if (onlyTextChanged) entry.dates = read();
|
||||
}
|
||||
|
||||
const formatted = formatResumeDates(entry.dates, options);
|
||||
if (formatted === text) return;
|
||||
if (field === "period") (entry as { period: string }).period = formatted;
|
||||
else (entry as { date: string }).date = formatted;
|
||||
});
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export const defaultResumeData: ResumeData = {
|
||||
marginY: 12,
|
||||
format: "a4",
|
||||
locale: "en-US",
|
||||
dateFormat: "short",
|
||||
hideLinkUnderline: false,
|
||||
hideIcons: false,
|
||||
hideSectionIcons: true,
|
||||
|
||||
@@ -6,7 +6,24 @@ export type ParsedPeriod = {
|
||||
ongoing: boolean;
|
||||
};
|
||||
|
||||
type EndpointResult = PeriodEndpoint | "ongoing";
|
||||
/**
|
||||
* How an endpoint was written. "long" and "short" are month names ("March", "Mar"); "name" is a month whose
|
||||
* long and short names are the same ("May"). "season" and "day" lose or add precision, so they aren't exact.
|
||||
*/
|
||||
export type EndpointStyle = "year" | "iso" | "numeric" | "long" | "short" | "name" | "season" | "day";
|
||||
|
||||
export type EndpointReading = { endpoint: PeriodEndpoint; style: EndpointStyle };
|
||||
|
||||
/** A period with how each endpoint was written, so callers can tell exact readings from approximate ones. */
|
||||
export type PeriodReading = {
|
||||
start?: EndpointReading;
|
||||
end?: EndpointReading;
|
||||
ongoing: boolean;
|
||||
};
|
||||
|
||||
type EndpointResult = EndpointReading | "ongoing";
|
||||
|
||||
type MonthName = { month: number; style: "long" | "short" | "name" };
|
||||
|
||||
const MIN_YEAR = 1900;
|
||||
const MAX_YEAR = 2100;
|
||||
@@ -77,40 +94,43 @@ const DASH_CHARS = new Set(["-", "–", "—", "~"]);
|
||||
|
||||
const SPACED_SEPARATOR = /\s(?:[-–—~]+|to|through|until)\s/;
|
||||
|
||||
const monthLookupCache = new Map<string, ReadonlyMap<string, number>>();
|
||||
const monthLookupCache = new Map<string, ReadonlyMap<string, MonthName>>();
|
||||
|
||||
const normalizeToken = (value: string) => value.trim().toLowerCase().replace(/\.$/, "");
|
||||
|
||||
const normalizeWhitespace = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
|
||||
function buildMonthLookup(locale: string): ReadonlyMap<string, number> {
|
||||
const lookup = new Map<string, number>();
|
||||
function buildMonthLookup(locale: string): ReadonlyMap<string, MonthName> {
|
||||
const lookup = new Map<string, MonthName>();
|
||||
|
||||
for (const tag of new Set(["en-US", locale])) {
|
||||
for (const month of ["long", "short"] as const) {
|
||||
for (const style of ["long", "short"] as const) {
|
||||
let format: Intl.DateTimeFormat;
|
||||
|
||||
try {
|
||||
format = new Intl.DateTimeFormat(tag, { month, timeZone: "UTC" });
|
||||
format = new Intl.DateTimeFormat(tag, { month: style, timeZone: "UTC" });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = 1; index <= 12; index++) {
|
||||
const name = normalizeToken(format.format(Date.UTC(2000, index - 1, 1)));
|
||||
if (name) lookup.set(name, index);
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const name = normalizeToken(format.format(Date.UTC(2000, month - 1, 1)));
|
||||
if (!name) continue;
|
||||
const existing = lookup.get(name);
|
||||
// "May" is both the long and the short name.
|
||||
lookup.set(name, existing && existing.style !== style ? { month, style: "name" } : { month, style });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [alias, month] of Object.entries(EXTRA_MONTH_ALIASES)) {
|
||||
if (!lookup.has(alias)) lookup.set(alias, month);
|
||||
if (!lookup.has(alias)) lookup.set(alias, { month, style: "short" });
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function getMonthLookup(locale: string): ReadonlyMap<string, number> {
|
||||
function getMonthLookup(locale: string): ReadonlyMap<string, MonthName> {
|
||||
const cached = monthLookupCache.get(locale);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -126,39 +146,48 @@ function toEndpoint(year: number, month?: number): PeriodEndpoint | null {
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
function parseEndpoint(raw: string, months: ReadonlyMap<string, number>): EndpointResult | null {
|
||||
function read(endpoint: PeriodEndpoint | null, style: EndpointStyle): EndpointReading | null {
|
||||
return endpoint ? { endpoint, style } : null;
|
||||
}
|
||||
|
||||
type Tokens = { months: ReadonlyMap<string, MonthName>; present: ReadonlySet<string> };
|
||||
|
||||
function parseEndpoint(raw: string, { months, present }: Tokens): EndpointResult | null {
|
||||
const value = normalizeWhitespace(raw);
|
||||
if (!value) return null;
|
||||
if (PRESENT_TOKENS.has(value.replace(/\p{P}+$/u, ""))) return "ongoing";
|
||||
if (present.has(value.replace(/\p{P}+$/u, ""))) return "ongoing";
|
||||
|
||||
const yearOnly = /^(\d{4})$/.exec(value);
|
||||
if (yearOnly?.[1]) return toEndpoint(Number(yearOnly[1]));
|
||||
if (yearOnly?.[1]) return read(toEndpoint(Number(yearOnly[1])), "year");
|
||||
|
||||
const iso = /^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/.exec(value);
|
||||
if (iso?.[1] && iso[2]) return toEndpoint(Number(iso[1]), Number(iso[2]));
|
||||
if (iso?.[1] && iso[2]) return read(toEndpoint(Number(iso[1]), Number(iso[2])), iso[3] ? "day" : "iso");
|
||||
|
||||
const monthYear = /^(\d{1,2})[/.](\d{4})$/.exec(value);
|
||||
if (monthYear?.[1] && monthYear[2]) return toEndpoint(Number(monthYear[2]), Number(monthYear[1]));
|
||||
if (monthYear?.[1] && monthYear[2]) return read(toEndpoint(Number(monthYear[2]), Number(monthYear[1])), "numeric");
|
||||
|
||||
const dayMonthYear = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(value);
|
||||
if (dayMonthYear?.[1] && dayMonthYear[2] && dayMonthYear[3]) {
|
||||
const year = Number(dayMonthYear[3]);
|
||||
const first = Number(dayMonthYear[1]);
|
||||
const second = Number(dayMonthYear[2]);
|
||||
return toEndpoint(year, first) ?? toEndpoint(year, second);
|
||||
return read(toEndpoint(year, first) ?? toEndpoint(year, second), "day");
|
||||
}
|
||||
|
||||
const named = /^(\p{L}+\.?)\s+(?:\d{1,2},?\s+)?(\d{4})$/u.exec(value);
|
||||
if (named?.[1] && named[2]) {
|
||||
const named = /^(\p{L}+\.?)\s+(\d{1,2},?\s+)?(\d{4})$/u.exec(value);
|
||||
if (named?.[1] && named[3]) {
|
||||
const token = normalizeToken(named[1]);
|
||||
const month = months.get(token) ?? SEASON_MONTHS[token];
|
||||
return month === undefined ? null : toEndpoint(Number(named[2]), month);
|
||||
const year = Number(named[3]);
|
||||
const month = months.get(token);
|
||||
if (month) return read(toEndpoint(year, month.month), named[2] ? "day" : month.style);
|
||||
const season = SEASON_MONTHS[token];
|
||||
return season === undefined ? null : read(toEndpoint(year, season), "season");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toPeriod(start: EndpointResult | undefined, end: EndpointResult | undefined): ParsedPeriod {
|
||||
function toReading(start: EndpointResult | undefined, end: EndpointResult | undefined): PeriodReading {
|
||||
return {
|
||||
...(start && start !== "ongoing" ? { start } : {}),
|
||||
...(end && end !== "ongoing" ? { end } : {}),
|
||||
@@ -166,6 +195,18 @@ function toPeriod(start: EndpointResult | undefined, end: EndpointResult | undef
|
||||
};
|
||||
}
|
||||
|
||||
const toPeriod = (reading: PeriodReading): ParsedPeriod => ({
|
||||
...(reading.start ? { start: reading.start.endpoint } : {}),
|
||||
...(reading.end ? { end: reading.end.endpoint } : {}),
|
||||
ongoing: reading.ongoing,
|
||||
});
|
||||
|
||||
function getTokens(locale: string, presentWords: readonly string[] = []): Tokens {
|
||||
const months = getMonthLookup(locale);
|
||||
if (presentWords.length === 0) return { months, present: PRESENT_TOKENS };
|
||||
return { months, present: new Set([...PRESENT_TOKENS, ...presentWords.map(normalizeWhitespace)]) };
|
||||
}
|
||||
|
||||
function splitOnce(value: string, separator: RegExp): [string, string] | null {
|
||||
const match = separator.exec(value);
|
||||
if (!match) return null;
|
||||
@@ -188,45 +229,59 @@ function* dashSplits(value: string): Generator<[string, string]> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseSplit(parts: [string, string], months: ReadonlyMap<string, number>): ParsedPeriod | null {
|
||||
const start = parseEndpoint(parts[0], months);
|
||||
function parseSplit(parts: [string, string], tokens: Tokens): PeriodReading | null {
|
||||
const start = parseEndpoint(parts[0], tokens);
|
||||
if (!start || start === "ongoing") return null;
|
||||
|
||||
const end = parseEndpoint(parts[1], months);
|
||||
return end ? toPeriod(start, end) : null;
|
||||
const end = parseEndpoint(parts[1], tokens);
|
||||
return end ? toReading(start, end) : null;
|
||||
}
|
||||
|
||||
export function parsePeriod(value: string, locale = "en-US"): ParsedPeriod | null {
|
||||
/**
|
||||
* Reads a period such as "Mar 2020 – Present" and reports how each endpoint was written. `presentWords` adds
|
||||
* words for "Present" beyond the built-in list, such as the label a resume's own locale prints.
|
||||
*/
|
||||
export function readPeriod(value: string, locale = "en-US", presentWords?: readonly string[]): PeriodReading | null {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const months = getMonthLookup(locale);
|
||||
const tokens = getTokens(locale, presentWords);
|
||||
|
||||
const single = parseEndpoint(normalized, months);
|
||||
if (single) return single === "ongoing" ? null : toPeriod(single, undefined);
|
||||
const single = parseEndpoint(normalized, tokens);
|
||||
if (single) return single === "ongoing" ? null : toReading(single, undefined);
|
||||
|
||||
const spaced = splitOnce(normalized, SPACED_SEPARATOR);
|
||||
if (spaced) {
|
||||
const period = parseSplit(spaced, months);
|
||||
const period = parseSplit(spaced, tokens);
|
||||
if (period) return period;
|
||||
}
|
||||
|
||||
for (const candidate of dashSplits(normalized)) {
|
||||
const period = parseSplit(candidate, months);
|
||||
const period = parseSplit(candidate, tokens);
|
||||
if (period) return period;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSingleDate(value: string, locale = "en-US"): PeriodEndpoint | null {
|
||||
/** Reads a single date such as "Mar 2020" and reports how it was written. */
|
||||
export function readSingleDate(value: string, locale = "en-US"): EndpointReading | null {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const result = parseEndpoint(normalized, getMonthLookup(locale));
|
||||
const result = parseEndpoint(normalized, getTokens(locale));
|
||||
return result && result !== "ongoing" ? result : null;
|
||||
}
|
||||
|
||||
export function parsePeriod(value: string, locale = "en-US"): ParsedPeriod | null {
|
||||
const reading = readPeriod(value, locale);
|
||||
return reading ? toPeriod(reading) : null;
|
||||
}
|
||||
|
||||
export function parseSingleDate(value: string, locale = "en-US"): PeriodEndpoint | null {
|
||||
return readSingleDate(value, locale)?.endpoint ?? null;
|
||||
}
|
||||
|
||||
export function isReversedPeriod(start: PeriodEndpoint, end: PeriodEndpoint): boolean {
|
||||
return start.year * 12 + (start.month ?? 1) > end.year * 12 + (end.month ?? 12);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"af-ZA": "Present",
|
||||
"am-ET": "Present",
|
||||
"ar-SA": "Present",
|
||||
"az-AZ": "Present",
|
||||
"bg-BG": "Present",
|
||||
"bn-BD": "Present",
|
||||
"ca-ES": "Present",
|
||||
"cs-CZ": "Present",
|
||||
"da-DK": "Present",
|
||||
"de-DE": "Present",
|
||||
"el-GR": "Present",
|
||||
"en-GB": "Present",
|
||||
"en-US": "Present",
|
||||
"es-ES": "Present",
|
||||
"fa-IR": "Present",
|
||||
"fi-FI": "Present",
|
||||
"fr-FR": "Present",
|
||||
"he-IL": "Present",
|
||||
"hi-IN": "Present",
|
||||
"hu-HU": "Present",
|
||||
"id-ID": "Present",
|
||||
"it-IT": "Present",
|
||||
"ja-JP": "Present",
|
||||
"km-KH": "Present",
|
||||
"kn-IN": "Present",
|
||||
"ko-KR": "Present",
|
||||
"lt-LT": "Present",
|
||||
"lv-LV": "Present",
|
||||
"ml-IN": "Present",
|
||||
"mr-IN": "Present",
|
||||
"ms-MY": "Present",
|
||||
"ne-NP": "Present",
|
||||
"nl-NL": "Present",
|
||||
"no-NO": "Present",
|
||||
"or-IN": "Present",
|
||||
"pl-PL": "Present",
|
||||
"pt-BR": "Present",
|
||||
"pt-PT": "Present",
|
||||
"ro-RO": "Present",
|
||||
"ru-RU": "Present",
|
||||
"sk-SK": "Present",
|
||||
"sl-SI": "Present",
|
||||
"sq-AL": "Present",
|
||||
"sr-SP": "Present",
|
||||
"sv-SE": "Present",
|
||||
"ta-IN": "Present",
|
||||
"te-IN": "Present",
|
||||
"th-TH": "Present",
|
||||
"tr-TR": "Present",
|
||||
"uk-UA": "Present",
|
||||
"uz-UZ": "Present",
|
||||
"vi-VN": "Present",
|
||||
"zh-CN": "Present",
|
||||
"zh-TW": "Present",
|
||||
"zu-ZA": "Present"
|
||||
}
|
||||
Reference in New Issue
Block a user