158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
/**
|
||
* Date range parsing.
|
||
*
|
||
* Resume dates are where a surprising amount of ATS damage happens, because
|
||
* the formats that look tidiest to a human ("2020–22", "Jan '20") are the ones
|
||
* no parser handles. Where a format fails we say so explicitly rather than
|
||
* silently dropping the range.
|
||
*/
|
||
|
||
import type { DateRange } from "./types.ts";
|
||
|
||
const MONTHS: Record<string, number> = {
|
||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||
};
|
||
|
||
const SEASONS: Record<string, number> = {
|
||
spring: 3, summer: 6, fall: 9, autumn: 9, winter: 12,
|
||
};
|
||
|
||
const MONTH = String.raw`(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)`;
|
||
const SEASON = String.raw`(?:spring|summer|fall|autumn|winter)`;
|
||
/** Hyphen, en dash, em dash, tilde or the word "to". */
|
||
const SEP = String.raw`\s*(?:[-–—~]|\bto\b)+\s*`;
|
||
const CURRENT = String.raw`(?:present|current|now|ongoing|today)`;
|
||
|
||
export const CURRENT_INDICATORS = /\b(present|current|now|ongoing|today)\b/i;
|
||
|
||
const PATTERNS: { re: RegExp; kind: string }[] = [
|
||
{ re: new RegExp(`${MONTH}\\s*\\.?\\s*\\d{4}${SEP}(?:${MONTH}\\s*\\.?\\s*\\d{4}|${CURRENT})`, "i"), kind: "monthYear" },
|
||
{ re: new RegExp(`\\d{1,2}\\/\\d{4}${SEP}(?:\\d{1,2}\\/\\d{4}|${CURRENT})`, "i"), kind: "numericMonthYear" },
|
||
{ re: new RegExp(`${SEASON}\\s*\\d{4}${SEP}(?:${SEASON}\\s*\\d{4}|${CURRENT})`, "i"), kind: "season" },
|
||
{ re: new RegExp(`\\b(?:19|20)\\d{2}${SEP}(?:(?:19|20)\\d{2}|${CURRENT})\\b`, "i"), kind: "yearOnly" },
|
||
];
|
||
|
||
/** Formats that look reasonable but that no parser examined handles. */
|
||
const KNOWN_BAD: { re: RegExp; problem: string }[] = [
|
||
{
|
||
re: /\b(?:19|20)\d{2}\s*[-–—]\s*\d{2}\b(?!\d)/,
|
||
problem:
|
||
"A two-digit end year (“2020–22”) matches no date pattern, so the range is dropped entirely.",
|
||
},
|
||
{
|
||
re: /['’]\d{2}\b/,
|
||
problem:
|
||
"Apostrophe years (“Jan ’20”) are not supported by any parser examined; write the year in full.",
|
||
},
|
||
];
|
||
|
||
function pad(month: number): string {
|
||
return String(month).padStart(2, "0");
|
||
}
|
||
|
||
/** Normalise one endpoint to YYYY-MM or YYYY. */
|
||
function normaliseEndpoint(raw: string): string | null {
|
||
const text = raw.trim().toLowerCase();
|
||
if (CURRENT_INDICATORS.test(text)) return null;
|
||
|
||
const monthYear = text.match(new RegExp(`(${MONTH})\\s*\\.?\\s*((?:19|20)\\d{2})`, "i"));
|
||
if (monthYear) {
|
||
const key = monthYear[1].slice(0, 3).toLowerCase();
|
||
const month = MONTHS[key];
|
||
return month ? `${monthYear[2]}-${pad(month)}` : monthYear[2];
|
||
}
|
||
|
||
const seasonYear = text.match(new RegExp(`(${SEASON})\\s*((?:19|20)\\d{2})`, "i"));
|
||
if (seasonYear) {
|
||
const month = SEASONS[seasonYear[1].toLowerCase()];
|
||
return `${seasonYear[2]}-${pad(month)}`;
|
||
}
|
||
|
||
const numeric = text.match(/(\d{1,2})\/((?:19|20)\d{2})/);
|
||
if (numeric) {
|
||
const month = Number(numeric[1]);
|
||
return month >= 1 && month <= 12
|
||
? `${numeric[2]}-${pad(month)}`
|
||
: numeric[2];
|
||
}
|
||
|
||
const yearOnly = text.match(/\b((?:19|20)\d{2})\b/);
|
||
if (yearOnly) return yearOnly[1];
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Find and parse the first date range in a line of text. */
|
||
export function parseDateRange(text: string): DateRange | null {
|
||
for (const bad of KNOWN_BAD) {
|
||
if (bad.re.test(text)) {
|
||
const raw = text.match(bad.re)?.[0] ?? text.trim();
|
||
return {
|
||
raw,
|
||
start: null,
|
||
end: null,
|
||
isCurrent: false,
|
||
parsed: false,
|
||
problem: bad.problem,
|
||
};
|
||
}
|
||
}
|
||
|
||
for (const { re } of PATTERNS) {
|
||
const match = text.match(re);
|
||
if (!match) continue;
|
||
|
||
const raw = match[0];
|
||
const halves = raw.split(new RegExp(SEP, "i"));
|
||
const startRaw = halves[0] ?? "";
|
||
const endRaw = halves.slice(1).join(" ");
|
||
const isCurrent = CURRENT_INDICATORS.test(endRaw);
|
||
|
||
const range: DateRange = {
|
||
raw: raw.trim(),
|
||
start: normaliseEndpoint(startRaw),
|
||
end: isCurrent ? null : normaliseEndpoint(endRaw),
|
||
isCurrent,
|
||
parsed: true,
|
||
};
|
||
|
||
if (!range.start) {
|
||
range.parsed = false;
|
||
range.problem = "The start of this range could not be interpreted.";
|
||
} else if (/^\d{4}$/.test(range.start)) {
|
||
range.problem =
|
||
"Only a year was given, so month precision is lost and tenure is rounded.";
|
||
}
|
||
return range;
|
||
}
|
||
|
||
// A lone date with no range, e.g. a graduation year.
|
||
const single = normaliseEndpoint(text);
|
||
if (single) {
|
||
return {
|
||
raw: text.match(/\b(?:19|20)\d{2}\b/)?.[0] ?? text.trim(),
|
||
start: single,
|
||
end: single,
|
||
isCurrent: false,
|
||
parsed: true,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export function hasAnyDate(text: string): boolean {
|
||
return /\b(?:19|20)\d{2}\b/.test(text) || CURRENT_INDICATORS.test(text);
|
||
}
|
||
|
||
/**
|
||
* Detect a date range broken across two lines, which Textkernel flags as 418
|
||
* and RChilli as 4109. Both treat it as fatal.
|
||
*/
|
||
export function isVerticalDateRange(line: string, next: string | undefined): boolean {
|
||
if (!next) return false;
|
||
const endsOpen = /\b(?:19|20)\d{2}\s*[-–—~]\s*$/.test(line.trim());
|
||
const nextIsDate = /^\s*(?:(?:19|20)\d{2}|present|current)\b/i.test(next.trim());
|
||
return endsOpen && nextIsDate;
|
||
}
|