date-shortcut-parser
Version:
A lightweight and powerful TypeScript library for parsing human-readable date shortcuts into `Date` objects.
333 lines (332 loc) • 9.74 kB
JavaScript
//#region src/index.ts
const locales = {
en: {
year: [
"y",
"yr",
"year",
"years"
],
month: [
"m",
"mo",
"month",
"months"
],
week: [
"w",
"wk",
"week",
"weeks"
],
day: [
"d",
"day",
"days"
],
today: [
"t",
"today",
"now"
],
weekday: [
"wd",
"weekday",
"weekdays"
],
datePatterns: [{
regex: /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?/,
format: "mm/dd/yyyy"
}],
am: ["am"],
pm: ["pm"]
},
de: {
year: [
"j",
"jahr",
"jahre"
],
month: [
"m",
"monat",
"monate"
],
week: [
"w",
"woche",
"wochen"
],
day: [
"t",
"tag",
"tage",
"d"
],
today: [
"h",
"heute",
"jetzt"
],
weekday: [
"wt",
"werktag",
"werktage"
],
datePatterns: [{
regex: /^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?/,
format: "dd.mm.yyyy"
}],
am: [],
pm: []
},
fr: {
year: [
"a",
"an",
"année",
"années"
],
month: ["m", "mois"],
week: [
"s",
"sem",
"semaine",
"semaines"
],
day: [
"j",
"jour",
"jours"
],
today: [
"a",
"aujourdhui",
"maintenant"
],
weekday: [
"jo",
"jourouvrable",
"joursouvrables"
],
datePatterns: [{
regex: /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?/,
format: "dd/mm/yyyy"
}],
am: [],
pm: []
},
tr: {
year: ["y", "yıl"],
month: ["a", "ay"],
week: ["h", "hafta"],
day: ["g", "gün"],
today: [
"b",
"bugün",
"şimdi"
],
weekday: [
"ig",
"işgünü",
"işgünleri"
],
datePatterns: [{
regex: /^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?/,
format: "dd.mm.yyyy"
}],
am: [],
pm: []
}
};
var DateShortcutParser = class {
options;
locale;
defaultTime = null;
constructor(options = {}) {
this.options = {
fromDate: options.fromDate || new Date(),
locale: options.locale || "en",
defaultTime: options.defaultTime
};
this.locale = this.resolveLocale(this.options.locale);
if (this.options.defaultTime) this.defaultTime = this._parseTime(this.options.defaultTime);
}
resolveLocale(locale) {
if (typeof locale === "string") {
const predefinedLocale = locales[locale];
if (!predefinedLocale) throw new Error(`DateShortcutParser: Predefined locale "${locale}" not found.`);
return predefinedLocale;
}
return locale;
}
_parseTime(timeString) {
const parts = timeString.split(":").map(Number);
if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) throw new Error(`DateShortcutParser: Invalid defaultTime format "${timeString}".`);
const [hour = 0, minute = 0, second = 0] = parts;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) throw new Error(`DateShortcutParser: Invalid time values in defaultTime "${timeString}".`);
return {
hour,
minute,
second
};
}
parse(shortcut) {
let dateShortcut = shortcut.trim().toLowerCase();
let timeInfo = null;
const am = this.locale.am || [];
const pm = this.locale.pm || [];
const ampm = [...am, ...pm];
const ampmPattern = ampm.length > 0 ? `\\s*(${ampm.join("|")})?` : "";
const timeRegex = new RegExp(`(?:\\s+|^)(\\d{1,2}(?::\\d{2})?(?::\\d{2})?)${ampmPattern}$`, "i");
const timeMatch = dateShortcut.match(timeRegex);
if (timeMatch) {
const timeString = timeMatch[1];
const ampmPart = timeMatch[2]?.toLowerCase();
dateShortcut = dateShortcut.substring(0, timeMatch.index).trim();
const timeComponents = timeString.split(":").map(Number);
let hour = timeComponents[0];
const minute = timeComponents[1] || 0;
const second = timeComponents[2] || 0;
if (isNaN(hour) || minute < 0 || minute > 59 || second < 0 || second > 59) throw new Error(`DateShortcutParser: Invalid time format in shortcut "${shortcut}".`);
if (ampmPart && (hour < 1 || hour > 12)) throw new Error(`DateShortcutParser: Invalid hour "${hour}" for AM/PM format.`);
if (ampmPart) {
const isPm = pm.includes(ampmPart);
if (isPm && hour >= 1 && hour <= 11) hour += 12;
else if (!isPm && hour === 12) hour = 0;
}
if (hour < 0 || hour > 23) throw new Error(`DateShortcutParser: Invalid hour "${timeComponents[0]}" in shortcut.`);
timeInfo = {
hour,
minute,
second
};
}
let currentDate = new Date(this.options.fromDate.getTime());
let remainingShortcut = dateShortcut;
if (!remainingShortcut && !timeMatch) throw new Error("DateShortcutParser: Shortcut string cannot be empty.");
if (remainingShortcut) {
currentDate.setUTCHours(0, 0, 0, 0);
const sortedTodayWords = [...this.locale.today].sort((a, b) => b.length - a.length);
for (const todayWord of sortedTodayWords) if (remainingShortcut.startsWith(todayWord)) {
remainingShortcut = remainingShortcut.substring(todayWord.length);
break;
}
for (const pattern of this.locale.datePatterns) {
const match = remainingShortcut.match(pattern.regex);
if (match) {
const [, dayStr, monthStr, yearStr] = match;
const format = pattern.format.toLowerCase();
const day = parseInt(format.startsWith("dd") ? dayStr : monthStr, 10);
const month = parseInt(format.startsWith("dd") ? monthStr : dayStr, 10) - 1;
let year = yearStr ? parseInt(yearStr, 10) : this.options.fromDate.getUTCFullYear();
if (yearStr && yearStr.length <= 2) year += 2e3;
currentDate = new Date(Date.UTC(year, month, day));
remainingShortcut = remainingShortcut.substring(match[0].length);
break;
}
}
const adjustToWorkday = remainingShortcut.trim().endsWith(".");
if (adjustToWorkday) remainingShortcut = remainingShortcut.trim().slice(0, -1);
const rawParts = remainingShortcut.replace(/([+-])/g, " $1").trim().split(/\s+/).filter(Boolean);
const parts = [];
for (let i = 0; i < rawParts.length; i++) if ((rawParts[i] === "+" || rawParts[i] === "-") && i + 1 < rawParts.length) {
parts.push(rawParts[i] + rawParts[i + 1]);
i++;
} else parts.push(rawParts[i]);
if (parts.length > 0) for (const part of parts) {
const partRegex = /^([+-])?(\d*)?([a-z]+)$/;
const match = part.match(partRegex);
if (!match) throw new Error(`DateShortcutParser: Invalid part format "${part}" in shortcut.`);
const [, sign, valueStr, unit] = match;
const value = valueStr ? parseInt(valueStr, 10) : 1;
const multiplier = sign === "-" ? -1 : 1;
const amount = value * multiplier;
const { datePatterns, am: am$1, pm: pm$1,...unitDefinitions } = this.locale;
let unitFound = false;
for (const unitType in unitDefinitions) if (Object.prototype.hasOwnProperty.call(unitDefinitions, unitType)) {
const unitArray = unitDefinitions[unitType];
if (Array.isArray(unitArray) && unitArray.includes(unit)) {
unitFound = true;
switch (unitType) {
case "year":
currentDate.setUTCFullYear(currentDate.getUTCFullYear() + amount);
break;
case "month":
const originalDay = currentDate.getUTCDate();
currentDate.setUTCDate(1);
currentDate.setUTCMonth(currentDate.getUTCMonth() + amount);
const daysInTargetMonth = new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() + 1, 0)).getUTCDate();
currentDate.setUTCDate(Math.min(originalDay, daysInTargetMonth));
break;
case "week":
currentDate.setUTCDate(currentDate.getUTCDate() + amount * 7);
break;
case "day":
currentDate.setUTCDate(currentDate.getUTCDate() + amount);
break;
case "weekday":
currentDate = this.findNthWeekday(currentDate, value, sign === "-");
break;
case "today": break;
}
}
}
if (!unitFound) throw new Error(`DateShortcutParser: Unknown unit "${unit}" in shortcut.`);
}
if (adjustToWorkday) currentDate = this.findClosestWorkday(currentDate);
}
if (timeInfo) currentDate.setUTCHours(timeInfo.hour, timeInfo.minute, timeInfo.second, 0);
else if (this.defaultTime) currentDate.setUTCHours(this.defaultTime.hour, this.defaultTime.minute, this.defaultTime.second, 0);
return currentDate;
}
getOrdinalSuffix(n) {
const lastDigit = n % 10;
const lastTwoDigits = n % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return "th";
switch (lastDigit) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
}
findNthWeekday(date, n, fromEnd) {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
let dayCounter = 0;
if (fromEnd) {
const lastDayOfMonth = new Date(Date.UTC(year, month + 1, 0));
const tempDate = new Date(lastDayOfMonth.getTime());
for (let day = lastDayOfMonth.getUTCDate(); day >= 1; day--) {
tempDate.setUTCDate(day);
const dayOfWeek = tempDate.getUTCDay();
if (dayOfWeek > 0 && dayOfWeek < 6) {
dayCounter++;
if (dayCounter === n) return tempDate;
}
}
} else {
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const tempDate = new Date(Date.UTC(year, month, 1));
for (let day = 1; day <= daysInMonth; day++) {
tempDate.setUTCDate(day);
const dayOfWeek = tempDate.getUTCDay();
if (dayOfWeek > 0 && dayOfWeek < 6) {
dayCounter++;
if (dayCounter === n) return tempDate;
}
}
}
throw new Error(`DateShortcutParser: Could not find the ${n}${this.getOrdinalSuffix(n)} weekday for the specified month.`);
}
findClosestWorkday(date) {
const dayOfWeek = date.getUTCDay();
const adjustedDate = new Date(date.getTime());
if (dayOfWeek === 6) adjustedDate.setUTCDate(date.getUTCDate() - 1);
else if (dayOfWeek === 0) adjustedDate.setUTCDate(date.getUTCDate() + 1);
return adjustedDate;
}
};
//#endregion
export { DateShortcutParser };