/** * 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 = { 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 = { 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; }