Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions __tests__/resumeImportDates.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// @vitest-environment node
import { parseImportDate } from "@/actions/resumeImport/shared";

const ym = (d: Date | null) => d && [d.getFullYear(), d.getMonth() + 1];

describe("parseImportDate multilingual", () => {
it.each([
["Jan 2020", [2020, 1]],
["janvier 2020", [2020, 1]],
["Janvier 2020", [2020, 1]],
["févr. 2021", [2021, 2]],
["août 2019", [2019, 8]],
["décembre 2018", [2018, 12]],
["Januar 2020", [2020, 1]],
["März 2020", [2020, 3]],
["enero de 2020", [2020, 1]],
["ene. 2020", [2020, 1]],
["marzo 2022", [2022, 3]],
["gennaio 2020", [2020, 1]],
["janeiro de 2020", [2020, 1]],
["январь 2020", [2020, 1]],
["2020年3月", [2020, 3]],
["2020-03", [2020, 3]],
["03/2020", [2020, 3]],
])("parses %s", (input, expected) => {
expect(ym(parseImportDate(input))).toEqual(expected);
});

it.each(["Present", "Présent", "actuel", "en cours", "Heute", "Actualidad", "現在", "по настоящее время", "-"])(
"treats %s as ongoing",
(input) => {
expect(parseImportDate(input)).toBeNull();
},
);

it("parses a bare year and ignores empty input", () => {
expect(ym(parseImportDate("2019"))).toEqual([2019, 1]);
expect(parseImportDate("")).toBeNull();
expect(parseImportDate("not a date")).toBeNull();
});
});
82 changes: 82 additions & 0 deletions src/actions/resumeImport/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Locale-independent date helpers for resume import. Month names come from
// Intl rather than a hand-written French/German/... list, so any locale the
// runtime's ICU knows works without code changes.

const MONTH_LOCALES = [
"en", "fr", "es", "de", "pt", "it", "nl", "sv", "da", "nb", "fi", "pl",
"cs", "sk", "ro", "hu", "tr", "ru", "uk", "el", "ca", "id", "vi",
];

// Words that mean "still ongoing" in end-date position. Matched after
// lowercasing and stripping diacritics and trailing punctuation.
const PRESENT_WORDS = new Set([
"present", "current", "now", "ongoing", "today", "-",
"presente", "actuel", "actuelle", "actuellement", "aujourd'hui", "en cours",
"heute", "aktuell", "gegenwart", "laufend", "jetzt",
"actualidad", "actualmente", "actual", "hoy", "atualmente", "atual", "hoje",
"attuale", "attualmente", "oggi", "in corso",
"heden", "nu", "huidig", "nuvarande", "nutid", "nåværende", "nykyinen",
"obecnie", "dzis", "teraz", "soucasnost", "prezent", "actualmente",
"halen", "su anda", "devam ediyor", "в настоящее время", "настоящее время",
"по настоящее время", "наст. время", "сейчас", "теперь",
"現在", "至今", "今", "현재", "재직중", "الحالي", "حتى الآن",
]);

const PARTICLES = new Set(["de", "del", "of", "d", "di", "van", "von", "em", "en", "a", "in"]);

function fold(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[.,]+$/g, "")
.trim();
}

let monthMap: Map<string, number> | null = null;

function getMonthMap(): Map<string, number> {
if (monthMap) return monthMap;
const seen = new Map<string, number>();
const ambiguous = new Set<string>();
for (const loc of MONTH_LOCALES) {
for (const style of ["long", "short"] as const) {
let fmt: Intl.DateTimeFormat;
try {
fmt = new Intl.DateTimeFormat(loc, { month: style, timeZone: "UTC" });
} catch {
continue;
}
for (let m = 0; m < 12; m++) {
const name = fold(fmt.format(new Date(Date.UTC(2020, m, 1)))).replace(/\./g, "");
if (!/^\p{L}{3,}$/u.test(name)) continue;
const prev = seen.get(name);
if (prev !== undefined && prev !== m) ambiguous.add(name);
else seen.set(name, m);
}
}
}
for (const a of ambiguous) seen.delete(a);
monthMap = seen;
return seen;
}

export function isPresentMarker(s: string): boolean {
const t = fold(s);
return PRESENT_WORDS.has(t) || PRESENT_WORDS.has(s.trim().toLowerCase());
}

// "janvier 2020", "Januar 2020", "enero de 2020", "ene. 2020", "2020年1月"
export function parseNamedMonthYear(s: string): Date | null {
const cjk = /^(\d{4})\s*[年.]\s*(\d{1,2})\s*月?$/.exec(s.trim());
if (cjk) return new Date(+cjk[1], +cjk[2] - 1, 1);

const year = /(?<!\d)(\d{4})(?!\d)/.exec(s);
if (!year) return null;
const words = fold(s.replace(year[1], " "))
.split(/[^\p{L}]+/u)
.filter((w) => w && !PARTICLES.has(w));
if (words.length !== 1) return null;
const month = getMonthMap().get(words[0]);
return month === undefined ? null : new Date(+year[1], month, 1);
}
19 changes: 11 additions & 8 deletions src/actions/resumeImport/shared.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import prisma from "@/lib/db";
import { SectionType } from "@/models/profile.model";
import { isPresentMarker, parseNamedMonthYear } from "./dates";

// Not a "use server" module: shared across src/actions/resumeImport/ modules,
// so it must stay importable without becoming a callable server-action endpoint.

export function parseImportDate(s: string | undefined | null): Date | null {
if (!s) return null;
const t = s.trim().toLowerCase();
if (["present", "current", "now", "ongoing", "-"].includes(t)) return null;
if (isPresentMarker(s)) return null;

// "Jan 2020" / "January 2020"
const monthYear = /^([a-z]+)\s+(\d{4})$/i.exec(s.trim());
if (monthYear) {
const d = new Date(`${monthYear[1]} 1, ${monthYear[2]}`);
if (!isNaN(d.getTime())) return d;
}
// "Jan 2020" / "janvier 2020" / "Januar 2020" — any locale Intl knows
const named = parseNamedMonthYear(s);
if (named) return named;

// "2020"
if (/^\d{4}$/.test(s.trim())) {
Expand All @@ -27,6 +24,12 @@ export function parseImportDate(s: string | undefined | null): Date | null {
return new Date(parseInt(mmyyyy[2]), parseInt(mmyyyy[1]) - 1, 1);
}

// YYYY-MM or YYYY/MM
const yyyymm = /^(\d{4})[/-](\d{1,2})$/.exec(s.trim());
if (yyyymm) {
return new Date(parseInt(yyyymm[1]), parseInt(yyyymm[2]) - 1, 1);
}

const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
Expand Down
6 changes: 5 additions & 1 deletion src/lib/ai/prompts/resume-import/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,15 @@ DATE RULES (applies to both experience and education, every entry):
If a date is genuinely not stated in the document, use an empty string "".
- Return the exact string from the document (e.g. "Jan 2020", "2019", "Present").
- If an entry is ongoing (no end date given, or words like "Present"/"Current"),
set endDate to "Present".
set endDate to "Present". This applies in any language: "Présent", "Actuel",
"Heute", "Actualidad", "現在" and similar all become "Present".

DESCRIPTION FIELD RULES (applies to both experience and education):
- The description field is REQUIRED. Populate it whenever the entry has bullet
points or details; use "" only if none exist.
- The document may be in any language. Copy every text value in its original
language and never translate it. Only the JSON keys and the "Present" marker
are fixed English.
- Copy bullet points VERBATIM, in order, without summarizing or dropping any.
- Preserve bullet symbols (•) at the start of each line. Join lines with newlines.
- Do not use HTML or markdown.
Expand Down