UNPKG

@kermank/nldp

Version:

A modular date/time parser for converting natural language into dates and times

390 lines 19.4 kB
"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