Add ats-tool

This commit is contained in:
badblocks 2026-08-07 17:18:34 -07:00
parent 89cfe7bdec
commit 523f4f6214
Signed by: badblocks
SSH key fingerprint: SHA256:hEcM6BP4hKm9F7WsomNuXSBpPn2BSDnFzPSl3y1NerQ
20 changed files with 3704 additions and 12 deletions

158
src/lib/ats/dates.ts Normal file
View file

@ -0,0 +1,158 @@
/**
* Date range parsing.
*
* Resume dates are where a surprising amount of ATS damage happens, because
* the formats that look tidiest to a human ("202022", "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 (“202022”) 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;
}