@kermank/nldp
Version:
A modular date/time parser for converting natural language into dates and times
390 lines • 19.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ordinalDaysRule = void 0;
exports.parseOrdinalDay = parseOrdinalDay;
const Logger_1 = require("../utils/Logger");
const luxon_1 = require("luxon");
const ORDINALS = {
first: 1, second: 2, third: 3, fourth: 4, fifth: 5,
last: -1, penultimate: -2, ultimate: -1,
'second to last': -2, 'third to last': -3
};
const MONTHS_ARRAY = [
'january', 'jan',
'february', 'feb',
'march', 'mar',
'april', 'apr',
'may',
'june', 'jun',
'july', 'jul',
'august', 'aug',
'september', 'sep',
'october', 'oct',
'november', 'nov',
'december', 'dec'
];
const createOrdinalDayComponent = (date, span, originalText, preferences, rangeEnd) => {
var _a;
// Only use reference year if the date's year is the current year
const currentYear = luxon_1.DateTime.now().year;
const dateYear = date.year;
const referenceYear = ((_a = preferences.referenceDate) === null || _a === void 0 ? void 0 : _a.year) || currentYear;
// If the date already has a specific year (not current year), keep it
const finalYear = dateYear !== currentYear ? dateYear : referenceYear;
const dateWithCorrectYear = date.set({ year: finalYear });
const isRange = rangeEnd !== undefined;
return {
type: isRange ? 'range' : 'date',
span,
value: isRange ? { start: dateWithCorrectYear, end: rangeEnd } : dateWithCorrectYear,
confidence: 1,
metadata: {
originalText,
dateType: 'ordinal',
rangeType: isRange ? 'ordinalWeek' : undefined
}
};
};
// Default range for "after" expressions (1 month)
const DEFAULT_AFTER_RANGE_MONTHS = 1;
const findNextOccurrence = (day, month, referenceDate, after) => {
// First, create the target date in the current year
let targetDate = referenceDate.set({ day });
if (month !== null) {
targetDate = targetDate.set({ month });
// If the date is in the past and a specific month was given, move to next year
if (targetDate < referenceDate) {
targetDate = targetDate.plus({ years: 1 });
}
}
else {
// For month-less dates, if target is in past, move to next month
if (targetDate < referenceDate) {
targetDate = targetDate.plus({ months: 1 });
}
}
// Now create the range based on whether it's a before/after expression
if (after) {
// For "after" expressions, range is [target_date -> target_date + 1 month]
return {
start: targetDate,
end: targetDate.plus({ months: DEFAULT_AFTER_RANGE_MONTHS })
};
}
else {
// For "before" expressions, range is [reference_date -> target_date]
return {
start: referenceDate,
end: targetDate
};
}
};
// Helper function to find nth weekday of month
function findNthWeekdayOfMonth(year, month, weekday, n) {
const firstDayOfMonth = luxon_1.DateTime.utc(year, month, 1);
const lastDayOfMonth = firstDayOfMonth.endOf('month');
Logger_1.Logger.debug('Finding nth weekday', {
year,
month,
weekday,
n,
firstDayOfMonth: firstDayOfMonth.toISO(),
lastDayOfMonth: lastDayOfMonth.toISO()
});
// Get all occurrences of the weekday in the month
const occurrences = [];
let currentDate = firstDayOfMonth;
while (currentDate <= lastDayOfMonth) {
if (currentDate.weekday === weekday) {
occurrences.push(currentDate);
}
currentDate = currentDate.plus({ days: 1 });
}
Logger_1.Logger.debug('Found weekday occurrences', {
count: occurrences.length,
dates: occurrences.map(d => d.toISO())
});
if (occurrences.length === 0)
return null;
// For positive indices, count from start (1-based)
// For negative indices, count from end (-1 is last, -2 is second to last, etc.)
const index = n > 0 ? n - 1 : occurrences.length + n;
if (index < 0 || index >= occurrences.length) {
Logger_1.Logger.debug('Invalid index', { index, n, length: occurrences.length });
return null;
}
Logger_1.Logger.debug('Selected occurrence', {
n,
index,
date: occurrences[index].toISO()
});
return occurrences[index];
}
exports.ordinalDaysRule = {
name: 'ordinal-days',
patterns: [
{
// Pattern for "1st of March", "first of March", etc.
regex: /^(?:the\s+)?(?:(\d+)(?:st|nd|rd|th)|first|second|third|fourth|fifth|last|second\s+to\s+last|third\s+to\s+last)\s+(?:day\s+)?(?:of\s+)?(?:the\s+)?(?:month\s+(?:of\s+)?)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)(?:\s+(\d{4}))?$/i,
parse: (matches, preferences) => {
var _a;
Logger_1.Logger.debug('Parsing ordinal day', { matches });
const [fullMatch, ordinalOrNumber, month, year] = matches;
let dayNum;
if (ordinalOrNumber) {
dayNum = parseInt(ordinalOrNumber, 10);
if (isNaN(dayNum))
return null;
}
else {
const ordinal = matches[0].split(' ')[0].toLowerCase();
dayNum = ORDINALS[ordinal];
if (!dayNum)
return null;
}
const monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (monthNum < 1 || monthNum > 12)
return null;
const referenceYear = year ? parseInt(year, 10) : ((_a = preferences.referenceDate) === null || _a === void 0 ? void 0 : _a.year) || luxon_1.DateTime.now().year;
// Create the date in the specified timezone
const result = preferences.timeZone
? luxon_1.DateTime.fromObject({ year: referenceYear, month: monthNum, day: Math.abs(dayNum) }, { zone: preferences.timeZone })
: luxon_1.DateTime.utc(referenceYear, monthNum, Math.abs(dayNum));
if (!result.isValid) {
Logger_1.Logger.debug('Invalid date created', { year: referenceYear, month: monthNum, day: dayNum });
return null;
}
return createOrdinalDayComponent(result, { start: 0, end: fullMatch.length }, fullMatch, preferences);
}
},
{
// Pattern for "March 1st", "March first", etc.
regex: /^(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+(?:the\s+)?(?:(\d+)(?:st|nd|rd|th)?|first|second|third|fourth|fifth|last|second\s+to\s+last|third\s+to\s+last)(?:\s+(\d{4}))?$/i,
parse: (matches, preferences) => {
var _a;
Logger_1.Logger.debug('Parsing month first ordinal day', { matches });
const [fullMatch, month, dayNumber, year] = matches;
let dayNum;
if (dayNumber) {
dayNum = parseInt(dayNumber, 10);
if (isNaN(dayNum))
return null;
}
else {
const ordinal = matches[0].split(' ').slice(-1)[0].toLowerCase();
dayNum = ORDINALS[ordinal];
if (!dayNum)
return null;
}
const monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (monthNum < 1 || monthNum > 12)
return null;
const referenceYear = year ? parseInt(year, 10) : ((_a = preferences.referenceDate) === null || _a === void 0 ? void 0 : _a.year) || luxon_1.DateTime.now().year;
// Create the date in the specified timezone
const result = preferences.timeZone
? luxon_1.DateTime.fromObject({ year: referenceYear, month: monthNum, day: Math.abs(dayNum) }, { zone: preferences.timeZone })
: luxon_1.DateTime.utc(referenceYear, monthNum, Math.abs(dayNum));
if (!result.isValid) {
Logger_1.Logger.debug('Invalid date created', { year: referenceYear, month: monthNum, day: dayNum });
return null;
}
return createOrdinalDayComponent(result, { start: 0, end: fullMatch.length }, fullMatch, preferences);
}
},
{
// Pattern for relative ordinal expressions with more variations (before/until)
regex: /^(?:(?:anytime\s+)?(?:from\s+(?:now|today)\s+)?(?:until|before|till|up\s+to))\s+(?:the\s+)?(?:(\d+)(?:st|nd|rd|th)|first|second|third|fourth|fifth)(?:\s+(?:of\s+)?(?:the\s+)?(?:month\s+(?:of\s+)?)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec))?$/i,
parse: (matches, preferences) => {
Logger_1.Logger.debug('Parsing relative ordinal day (before)', { matches });
const [fullMatch, ordinalOrNumber, month] = matches;
let dayNum;
if (ordinalOrNumber) {
dayNum = parseInt(ordinalOrNumber, 10);
if (isNaN(dayNum))
return null;
}
else {
const ordinal = matches[0].split(' ')[1].toLowerCase();
dayNum = ORDINALS[ordinal];
if (!dayNum)
return null;
}
let monthNum = null;
if (month) {
monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (monthNum < 1 || monthNum > 12)
return null;
}
const referenceDate = preferences.referenceDate || luxon_1.DateTime.now();
const { start, end } = findNextOccurrence(dayNum, monthNum, referenceDate, false);
if (!start.isValid || !end.isValid) {
Logger_1.Logger.debug('Invalid date created', { dayNum, monthNum });
return null;
}
return createOrdinalDayComponent(start, { start: 0, end: fullMatch.length }, fullMatch, preferences, end);
}
},
{
// Pattern for "after/starting from" expressions
regex: /^(?:(?:anytime\s+)?(?:after|starting\s+from|from|beginning\s+from|starting|beginning)\s+(?:on|at|from)?|after)\s+(?:the\s+)?(?:(\d+)(?:st|nd|rd|th)|first|second|third|fourth|fifth)(?:\s+(?:of\s+)?(?:the\s+)?(?:month\s+(?:of\s+)?)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec))?$/i,
parse: (matches, preferences) => {
Logger_1.Logger.debug('Parsing relative ordinal day (after)', { matches });
const [fullMatch, ordinalOrNumber, month] = matches;
let dayNum;
if (ordinalOrNumber) {
dayNum = parseInt(ordinalOrNumber, 10);
if (isNaN(dayNum))
return null;
}
else {
// Get the word after "after" or "from" or "starting"
const words = matches[0].toLowerCase().split(/\s+/);
const ordinalIndex = words.findIndex(w => w === 'the') + 1;
const ordinal = words[ordinalIndex];
dayNum = ORDINALS[ordinal];
if (!dayNum)
return null;
}
let monthNum = null;
if (month) {
monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (monthNum < 1 || monthNum > 12)
return null;
}
const referenceDate = preferences.referenceDate || luxon_1.DateTime.now();
const { start, end } = findNextOccurrence(dayNum, monthNum, referenceDate, true);
if (!start.isValid || !end.isValid) {
Logger_1.Logger.debug('Invalid date created', { dayNum, monthNum });
return null;
}
return createOrdinalDayComponent(start, { start: 0, end: fullMatch.length }, fullMatch, preferences, end);
}
},
{
// Pattern for "3rd Monday of April", "last Friday of March", etc.
regex: /^(?:the\s+)?(?:(\d+)(?:st|nd|rd|th)|first|second|third|fourth|fifth|last|(?:second|third)\s+to\s+last)\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday|sun|mon|tue|wed|thu|fri|sat)\s+(?:of\s+)?(?:the\s+)?(?:month\s+(?:of\s+)?)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)(?:\s+(\d{4}))?$/i,
parse: (matches, preferences) => {
var _a;
Logger_1.Logger.debug('Parsing ordinal weekday', { matches });
const [fullMatch, ordinalOrNumber, weekday, month, year] = matches;
// Convert weekday to number (1-7, Monday=1)
const weekdayNum = {
'sunday': 7, 'sun': 7,
'monday': 1, 'mon': 1,
'tuesday': 2, 'tue': 2,
'wednesday': 3, 'wed': 3,
'thursday': 4, 'thu': 4,
'friday': 5, 'fri': 5,
'saturday': 6, 'sat': 6
}[weekday.toLowerCase()];
if (!weekdayNum)
return null;
// Convert ordinal to number
let n;
if (ordinalOrNumber) {
// First try to parse as a number (e.g. "3rd")
n = parseInt(ordinalOrNumber, 10);
if (isNaN(n)) {
// If not a number, try as a word (e.g. "third")
n = ORDINALS[ordinalOrNumber.toLowerCase()];
}
}
else {
// Try to get the full ordinal phrase which could be compound (e.g. "second to last")
const ordinalPhrase = fullMatch.trim().split(/\s+(?:of|the)\s+/)[0].toLowerCase();
// Check for compound ordinals first
if (ordinalPhrase.includes('to last')) {
const parts = ordinalPhrase.split(/\s+to\s+last/);
const ordinal = ORDINALS[parts[0]];
if (ordinal) {
n = -ordinal;
}
}
else if (ordinalPhrase === 'last') {
n = -1;
}
else {
// Try as a simple ordinal word (e.g. "third")
const firstWord = ordinalPhrase.split(/\s+/)[0];
n = ORDINALS[firstWord];
}
}
if (!n)
return null;
Logger_1.Logger.debug('Extracted ordinal', { ordinalPhrase: fullMatch.trim().split(/\s+(?:of|the)\s+/)[0], n });
const monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (monthNum < 1 || monthNum > 12)
return null;
const referenceYear = year ? parseInt(year, 10) : ((_a = preferences.referenceDate) === null || _a === void 0 ? void 0 : _a.year) || luxon_1.DateTime.now().year;
// Find the nth weekday of the month
const result = findNthWeekdayOfMonth(referenceYear, monthNum, weekdayNum, n);
if (!result || !result.isValid) {
Logger_1.Logger.debug('Invalid date created', { year: referenceYear, month: monthNum, weekday: weekdayNum, n });
return null;
}
return createOrdinalDayComponent(result, { start: 0, end: fullMatch.length }, fullMatch, preferences);
}
}
],
interpret: (intermediate, prefs) => {
var _a, _b;
if (!intermediate.captures)
return null;
const { day, month } = intermediate.captures;
if (!day || !month)
return null;
const dayNum = parseInt(day, 10);
if (isNaN(dayNum))
return null;
// Map abbreviated month names to full names using MONTHS object
const monthNum = MONTHS_ARRAY.indexOf(month.toLowerCase()) + 1;
if (!monthNum)
return null;
// Use the reference date's year for validation
const referenceYear = ((_a = prefs.referenceDate) === null || _a === void 0 ? void 0 : _a.toUTC().year) || new Date().getUTCFullYear();
const targetYear = referenceYear;
// For February, check if it's a leap year when day is 29
if (monthNum === 2 && dayNum === 29) {
const isLeapYear = (targetYear % 4 === 0 && targetYear % 100 !== 0) || (targetYear % 400 === 0);
if (!isLeapYear) {
Logger_1.Logger.debug('Invalid date: February 29 in non-leap year', { targetYear });
return null;
}
}
// Validate the day number is valid for the given month and year
const lastDayOfMonth = new Date(Date.UTC(targetYear, monthNum, 0)).getUTCDate();
if (dayNum < 1 || dayNum > lastDayOfMonth) {
Logger_1.Logger.debug('Invalid day for month', {
day: dayNum,
month: monthNum,
year: targetYear,
lastDayOfMonth
});
return null;
}
Logger_1.Logger.debug('Interpreting nth of month', {
month: monthNum,
day: dayNum,
result: new Date(Date.UTC(targetYear, monthNum - 1, dayNum)).toISOString()
});
// Create the date
const result = luxon_1.DateTime.utc(targetYear, monthNum - 1, dayNum);
return createOrdinalDayComponent(result, { start: 0, end: ((_b = intermediate.text) === null || _b === void 0 ? void 0 : _b.length) || 0 }, intermediate.text || '', prefs);
}
};
function parseOrdinalDay(matches, preferences) {
var _a;
const [fullMatch, ordinal, month] = matches;
const ordinalNum = parseInt(ordinal);
const monthNum = Math.floor(MONTHS_ARRAY.indexOf(month.toLowerCase()) / 2) + 1;
if (ordinalNum < 1 || ordinalNum > 31 || monthNum < 1) {
return null;
}
const year = ((_a = preferences.referenceDate) === null || _a === void 0 ? void 0 : _a.year) || luxon_1.DateTime.now().year;
const start = luxon_1.DateTime.utc(year, monthNum, ordinalNum);
if (!start.isValid) {
return null;
}
return createOrdinalDayComponent(start, { start: 0, end: fullMatch.length }, fullMatch, preferences);
}
//# sourceMappingURL=ordinal-days.js.map