chore: Remove old snoozeDateParser in favor of multi-lang natural language parser
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Parses natural language text into a future date.
|
||||
*
|
||||
* Flow: clean the input → try each matcher in order → return the first future date.
|
||||
* The MATCHERS order matters — see the comment above the array.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
startOfDay,
|
||||
getDay,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
getUnixTime,
|
||||
isValid,
|
||||
startOfWeek,
|
||||
addWeeks,
|
||||
isAfter,
|
||||
isBefore,
|
||||
endOfMonth,
|
||||
} from 'date-fns';
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
NEXT_WEEKDAY_FN,
|
||||
TIME_OF_DAY_MAP,
|
||||
TOD_HOUR_RANGE,
|
||||
HALF_UNIT_DURATIONS,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
parseNumber,
|
||||
parseTimeString,
|
||||
applyTimeToDate,
|
||||
applyTimeOrDefault,
|
||||
strictDate,
|
||||
futureOrNextYear,
|
||||
ensureFutureOrNextDay,
|
||||
inferHoursFromTOD,
|
||||
addFractionalSafe,
|
||||
} from './tokenMaps';
|
||||
|
||||
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
|
||||
|
||||
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
|
||||
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
|
||||
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
|
||||
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
|
||||
|
||||
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
|
||||
const UNIT_RE = `(${UNIT_NAMES})`;
|
||||
const TIME_SUFFIX_RE =
|
||||
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
|
||||
|
||||
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
|
||||
);
|
||||
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`
|
||||
);
|
||||
const NEXT_UNIT_RE = new RegExp(
|
||||
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
|
||||
const NEXT_WEEKDAY_TOD_RE = new RegExp(
|
||||
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const NEXT_WEEKDAY_RE = new RegExp(
|
||||
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
|
||||
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
|
||||
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const SAME_TIME_WEEKDAY_RE = new RegExp(
|
||||
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const WEEKDAY_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const TIME_ONLY_MERIDIEM_RE =
|
||||
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
|
||||
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
|
||||
const TOD_WITH_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
|
||||
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const TOD_PLAIN_RE = new RegExp(
|
||||
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
|
||||
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
|
||||
);
|
||||
const ABSOLUTE_DATE_RE = new RegExp(
|
||||
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
|
||||
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
|
||||
const DAY_AFTER_TOMORROW_RE = new RegExp(
|
||||
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
|
||||
const COMPOUND_DURATION_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
|
||||
);
|
||||
const DURATION_AT_TIME_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
|
||||
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
|
||||
|
||||
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
|
||||
const ISO_DATE_RE = new RegExp(
|
||||
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const SLASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DOT_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const AMBIGUOUS_DATE_RES = [SLASH_DATE_RE, DASH_DATE_RE, DOT_DATE_RE];
|
||||
|
||||
// ─── Pattern Matchers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Read amount and unit from a regex match, then add to now. */
|
||||
const parseDuration = (match, now) => {
|
||||
if (!match) return null;
|
||||
const amount = parseNumber(match[1]);
|
||||
const unit = UNIT_MAP[match[2]];
|
||||
if (amount == null || !unit) return null;
|
||||
return addFractionalSafe(now, unit, amount);
|
||||
};
|
||||
|
||||
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
|
||||
const matchDuration = (text, now) => {
|
||||
const half = text.match(HALF_UNIT_RE);
|
||||
if (half) {
|
||||
return HALF_UNIT_DURATIONS[half[1]]
|
||||
? add(now, HALF_UNIT_DURATIONS[half[1]])
|
||||
: null;
|
||||
}
|
||||
|
||||
const compound = text.match(COMPOUND_DURATION_RE);
|
||||
if (compound) {
|
||||
const a1 = parseNumber(compound[1]);
|
||||
const u1 = UNIT_MAP[compound[2]];
|
||||
const a2 = parseNumber(compound[3]);
|
||||
const u2 = UNIT_MAP[compound[4]];
|
||||
if (a1 == null || !u1 || a2 == null || !u2) {
|
||||
return null;
|
||||
}
|
||||
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
|
||||
}
|
||||
|
||||
const atTime = text.match(DURATION_AT_TIME_RE);
|
||||
if (atTime) {
|
||||
const amount = parseNumber(atTime[1]);
|
||||
const unit = UNIT_MAP[atTime[2]];
|
||||
const time = parseTimeString(atTime[3]);
|
||||
if (amount == null || !unit || !time) {
|
||||
return null;
|
||||
}
|
||||
return applyTimeToDate(
|
||||
add(now, { [unit]: amount }),
|
||||
time.hours,
|
||||
time.minutes
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
|
||||
/** Set time on a day offset. If the result is already past, move to the next day. */
|
||||
const applyTimeWithRollover = (offset, hours, minutes, now) => {
|
||||
const base = add(startOfDay(now), { days: offset });
|
||||
const date = applyTimeToDate(base, hours, minutes);
|
||||
if (isAfter(date, now)) return date;
|
||||
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
|
||||
};
|
||||
|
||||
/** Handle "today", "tonight", "tomorrow" with optional time. */
|
||||
const matchRelativeDay = (text, now) => {
|
||||
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
|
||||
if (dayOnlyMatch) {
|
||||
const key = dayOnlyMatch[1];
|
||||
const offset = RELATIVE_DAY_MAP[key];
|
||||
if (key === 'tonight' || key === 'tonite') {
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
if (offset === 1) {
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
|
||||
}
|
||||
return add(now, { hours: 1 });
|
||||
}
|
||||
|
||||
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
|
||||
if (dayTodTimeMatch) {
|
||||
const timeParts = dayTodTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(
|
||||
dayTodTimeMatch[2],
|
||||
timeParts[0],
|
||||
timeParts[1]
|
||||
);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
|
||||
if (dayTodMatch) {
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodMatch[1]],
|
||||
hours,
|
||||
minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const time = parseTimeString(dayAtTimeMatch[2]);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayAtTimeMatch[1]],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
|
||||
if (sameTimeMatch) {
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]];
|
||||
if (offset <= 0) return null;
|
||||
return applyTimeToDate(
|
||||
add(startOfDay(now), { days: offset }),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the given weekday in next week (not this week). */
|
||||
const nextWeekdayInNextWeek = (dayIndex, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const date = fn(now);
|
||||
const sameWeek =
|
||||
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
|
||||
startOfWeek(date, { weekStartsOn: 1 }).getTime();
|
||||
return sameWeek ? fn(date) : date;
|
||||
};
|
||||
|
||||
/** Handle "next friday", "next week", "next month", "next january", etc. */
|
||||
const matchNextPattern = (text, now) => {
|
||||
const nextUnitMatch = text.match(NEXT_UNIT_RE);
|
||||
if (nextUnitMatch) {
|
||||
const unit = nextUnitMatch[1];
|
||||
if (unit === 'hour') return add(now, { hours: 1 });
|
||||
if (unit === 'minute') return add(now, { minutes: 1 });
|
||||
if (unit === 'week') {
|
||||
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
|
||||
const nextMonthMatch = text.match(NEXT_MONTH_RE);
|
||||
if (nextMonthMatch) {
|
||||
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
|
||||
let year = now.getFullYear();
|
||||
if (monthIdx <= now.getMonth()) year += 1;
|
||||
const base = new Date(year, monthIdx, 1);
|
||||
return applyTimeOrDefault(base, nextMonthMatch[2]);
|
||||
}
|
||||
|
||||
// "next monday morning", "next friday midnight" — weekday + time-of-day
|
||||
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
|
||||
if (nextTodMatch) {
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
|
||||
if (!date) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
|
||||
return applyTimeToDate(date, hours, minutes);
|
||||
}
|
||||
|
||||
// "monday of next week", "next week monday", "next friday" — all with optional time
|
||||
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
|
||||
if (weekdayMatch) {
|
||||
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, weekdayMatch[4]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the next occurrence of a weekday, with optional time. */
|
||||
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
let adjusted = timeStr;
|
||||
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
|
||||
const h = parseInt(timeStr, 10);
|
||||
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
|
||||
}
|
||||
|
||||
if (getDay(now) === dayIndex) {
|
||||
const todayDate = applyTimeOrDefault(now, adjusted);
|
||||
if (todayDate && isAfter(todayDate, now)) return todayDate;
|
||||
}
|
||||
|
||||
return applyTimeOrDefault(fn(now), adjusted);
|
||||
};
|
||||
|
||||
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
|
||||
const matchWeekday = (text, now) => {
|
||||
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
|
||||
if (sameTimeWeekday) {
|
||||
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const target = fn(now);
|
||||
return applyTimeToDate(target, now.getHours(), now.getMinutes());
|
||||
}
|
||||
|
||||
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
|
||||
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
|
||||
if (todTimeMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const timeParts = todTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
|
||||
if (!time) return null;
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, time.hours, time.minutes);
|
||||
return isAfter(date, now)
|
||||
? date
|
||||
: applyTimeToDate(fn(now), time.hours, time.minutes);
|
||||
}
|
||||
|
||||
// "monday morning", "friday midnight", "wednesday evening", etc.
|
||||
const todMatch = text.match(WEEKDAY_TOD_RE);
|
||||
if (todMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, hours, minutes);
|
||||
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
|
||||
}
|
||||
|
||||
const match = text.match(WEEKDAY_TIME_RE);
|
||||
if (!match) return null;
|
||||
|
||||
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
|
||||
};
|
||||
|
||||
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
|
||||
const matchTimeOnly = (text, now) => {
|
||||
const match =
|
||||
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const time = parseTimeString(match[1]);
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
|
||||
const matchTimeOfDay = (text, now) => {
|
||||
const todWithTime = text.match(TOD_WITH_TIME_RE);
|
||||
if (todWithTime) {
|
||||
const rawTime = todWithTime[2].trim();
|
||||
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
|
||||
let time;
|
||||
if (hasMeridiem) {
|
||||
time = parseTimeString(rawTime);
|
||||
const range = TOD_HOUR_RANGE[todWithTime[1]];
|
||||
if (!time) return null;
|
||||
if (range) {
|
||||
const h = time.hours === 0 ? 24 : time.hours;
|
||||
if (h < range[0] || h >= range[1]) return null;
|
||||
}
|
||||
} else {
|
||||
const parts = rawTime.split(':');
|
||||
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
|
||||
}
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const match = text.match(TOD_PLAIN_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const key = text
|
||||
.replace(/^(?:later|in)\s+/, '')
|
||||
.replace(/^(?:this|the)\s+/, '')
|
||||
.trim();
|
||||
const tod = TIME_OF_DAY_MAP[key];
|
||||
if (!tod) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours, tod.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Turn month + day + optional year into a future date. */
|
||||
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
|
||||
let year = now.getFullYear();
|
||||
if (yearStr && /next\s+year/i.test(yearStr)) {
|
||||
year += 1;
|
||||
} else if (yearStr) {
|
||||
year = parseInt(yearStr, 10);
|
||||
}
|
||||
if (yearStr) {
|
||||
const base = strictDate(year, month, day);
|
||||
if (!base) return null;
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
return date && isAfter(date, now) ? date : null;
|
||||
}
|
||||
return futureOrNextYear(year, month, day, timeStr, now);
|
||||
};
|
||||
|
||||
/** Handle "jan 15", "15 march", "december 2025". */
|
||||
const matchNamedDate = (text, now) => {
|
||||
const abs = text.match(ABSOLUTE_DATE_RE);
|
||||
if (abs) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[abs[1]],
|
||||
parseInt(abs[2], 10),
|
||||
abs[3],
|
||||
abs[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
|
||||
if (rev) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[rev[2]],
|
||||
parseInt(rev[1], 10),
|
||||
rev[3],
|
||||
rev[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const my = text.match(MONTH_YEAR_RE);
|
||||
if (my) {
|
||||
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
|
||||
if (!isValid(date)) return null;
|
||||
const result = applyTimeToDate(date, 9, 0);
|
||||
return isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Build a date from year/month/day numbers, with optional time. */
|
||||
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
|
||||
const date = strictDate(year, month, day);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, timeStr);
|
||||
};
|
||||
|
||||
// When both values are ≤ 12 (ambiguous), defaults to M/D (US format).
|
||||
const disambiguateDayMonth = (a, b) => {
|
||||
if (a > 12) return { day: a, month: b - 1 };
|
||||
if (b > 12) return { month: a - 1, day: b };
|
||||
return { month: a - 1, day: b };
|
||||
};
|
||||
|
||||
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
|
||||
const matchFormalDate = (text, now) => {
|
||||
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
|
||||
|
||||
const isoMatch = text.match(ISO_DATE_RE);
|
||||
if (isoMatch) {
|
||||
return ensureFuture(
|
||||
buildDateWithOptionalTime(
|
||||
parseInt(isoMatch[1], 10),
|
||||
parseInt(isoMatch[2], 10) - 1,
|
||||
parseInt(isoMatch[3], 10),
|
||||
isoMatch[4]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let result = null;
|
||||
AMBIGUOUS_DATE_RES.some(re => {
|
||||
const m = text.match(re);
|
||||
if (!m) return false;
|
||||
const { month, day } = disambiguateDayMonth(
|
||||
parseInt(m[1], 10),
|
||||
parseInt(m[2], 10)
|
||||
);
|
||||
result = ensureFuture(
|
||||
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
|
||||
);
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
|
||||
const matchSpecial = (text, now) => {
|
||||
const dat = text.match(DAY_AFTER_TOMORROW_RE);
|
||||
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
|
||||
|
||||
const eof = text.match(END_OF_RE);
|
||||
if (eof) {
|
||||
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
|
||||
if (eof[1] === 'week') {
|
||||
const fri = applyTimeToDate(now, 17, 0);
|
||||
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
|
||||
return applyTimeToDate(nextFriday(now), 17, 0);
|
||||
}
|
||||
if (eof[1] === 'month') {
|
||||
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
|
||||
if (isAfter(eom, now)) return eom;
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
|
||||
|
||||
const weekendMatch = text.match(
|
||||
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
|
||||
);
|
||||
if (weekendMatch) {
|
||||
const isNext = weekendMatch[1] === 'next weekend';
|
||||
const timeStr = weekendMatch[2];
|
||||
|
||||
if (isNext) {
|
||||
const sat = nextSaturday(now);
|
||||
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
|
||||
return applyTimeOrDefault(d, timeStr);
|
||||
}
|
||||
|
||||
if (isSaturday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
if (now.getHours() < 18) return add(now, { hours: 2 });
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
|
||||
}
|
||||
if (isSunday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
return add(now, { hours: 2 });
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
}
|
||||
return applyTimeOrDefault(nextSaturday(now), timeStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ─── Main Parser ────────────────────────────────────────────────────────────
|
||||
|
||||
// Order matters — first match wins. Common patterns go first.
|
||||
// Do not reorder without running the spec.
|
||||
const MATCHERS = [
|
||||
matchDuration, // "in 2 hours", "half day", "3h30m"
|
||||
matchSpecial, // "end of week", "later today", "this weekend"
|
||||
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
|
||||
matchNextPattern, // "next friday", "next week", "next month"
|
||||
matchTimeOfDay, // "morning", "evening 6pm", "eod"
|
||||
matchWeekday, // "friday", "monday 3pm", "wed morning"
|
||||
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
|
||||
matchNamedDate, // "jan 15", "march 20 next year"
|
||||
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse free-form text into a future date.
|
||||
* Returns { date, unix } or null. Only returns dates after referenceDate.
|
||||
*
|
||||
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @returns {{ date: Date, unix: number } | null}
|
||||
*/
|
||||
export const parseDateFromText = (text, referenceDate = new Date()) => {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
const normalized = stripNoise(sanitize(text));
|
||||
if (!normalized) return null;
|
||||
|
||||
const maxDate = add(referenceDate, { years: 999 });
|
||||
|
||||
const isValidFuture = d =>
|
||||
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
|
||||
|
||||
let result = null;
|
||||
MATCHERS.some(matcher => {
|
||||
const d = matcher(normalized, referenceDate);
|
||||
if (isValidFuture(d)) {
|
||||
result = { date: d, unix: getUnixTime(d) };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
Reference in New Issue
Block a user