File size: 11,718 Bytes
bf48b89 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | import type { Dayjs, ManipulateType, OptionType } from 'dayjs';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import duration from 'dayjs/plugin/duration.js';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore.js';
import weekday from 'dayjs/plugin/weekday.js';
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(isSameOrBefore);
dayjs.extend(weekday);
/**
* Defines a pattern for semantic date keywords.
*/
interface KeywordDefinition {
/**
* Regular expression to match the keyword (e.g., "Yesterday", "Monday", "周一").
*/
regExp: RegExp;
/**
* Factory function to calculate the base date (start of day) relative to the current time.
*/
calc: () => Dayjs;
}
/**
* Defines a pattern for extracting time units from a string.
*/
interface UnitPattern {
/**
* The unit of time manipulation (e.g., 'days', 'hours').
*/
unit: ManipulateType;
/**
* Regular expression to match value and unit in the input string.
*/
regExp: RegExp;
}
// === Pre-compiled Regular Expressions ===
const REGEX_JUST_NOW = /^(?:just\s?now|刚刚|剛剛)$/i;
const REGEX_AGO = /(.*)(?:ago|[之以]?前)$/i;
const REGEX_IN = /^(?:in\s*)(.*)|(.*)(?:\s*later|\s*after|[之以]?[后後])$/i;
const REGEX_STICKY_AMPM = /(\d+)\s*(a|p)m$/i;
const REGEX_IS_PM = /(?:下午|晚上|晚|pm|p\.m\.)/i;
const REGEX_IS_AM = /(?:上午|凌晨|早|晨|am|a\.m\.)/i;
const UNIT_PATTERNS: UnitPattern[] = [
{ unit: 'years', regExp: /(\d+)\s*(?:年|y(?:ea)?rs?)/i },
{ unit: 'months', regExp: /(\d+)\s*(?:[个個]?月|months?)/i },
{ unit: 'weeks', regExp: /(\d+)\s*(?:周|[个個]?星期|weeks?)/i },
{ unit: 'days', regExp: /(\d+)\s*(?:天|日|d(?:ay)?s?)/i },
{ unit: 'hours', regExp: /(\d+)\s*(?:[个個]?(?:小?时|時|点|點)|h(?:(?:ou)?r)?s?)/i },
{ unit: 'minutes', regExp: /(\d+)\s*(?:分[鐘钟]?|m(?:in(?:ute)?)?s?)/i },
{ unit: 'seconds', regExp: /(\d+)\s*(?:秒[鐘钟]?|s(?:ec(?:ond)?)?s?)/i },
];
const CN_NUM_MAP: Record<string, string> = {
一: '1',
二: '2',
两: '2',
三: '3',
四: '4',
五: '5',
六: '6',
七: '7',
八: '8',
九: '9',
十: '10',
};
/**
* Calculates the date of the most recent occurrence of a specific weekday.
*
* If the target weekday is the same as today or occurs later in the current week,
* it returns the date from the previous week.
*
* @param targetDay - The day index: 1 (Monday) to 7 (Sunday).
* @returns A Dayjs object representing the start of that day.
*/
const getLastWeekday = (targetDay: number): Dayjs => {
const today = dayjs();
const currentDayIndex = today.day(); // 0 (Sun) - 6 (Sat)
// Normalize input (7=Sun) to Day.js standard (0=Sun)
const targetDayIndex = targetDay === 7 ? 0 : targetDay;
// Calculate difference
let daysToAdd = targetDayIndex - currentDayIndex;
// If the target day is today or in the future (within the standard week cycle),
// backtrack 7 days to find the previous occurrence.
if (daysToAdd >= 0) {
daysToAdd -= 7;
}
return today.add(daysToAdd, 'day').startOf('day');
};
// Semantic Keywords configuration
const KEYWORDS: KeywordDefinition[] = [
{
regExp: /^(?:今[天日早晨晚]|to?day?)/i,
calc: () => dayjs().startOf('day'),
},
{
regExp: /^(?:昨[天日早晨晚]|y(?:ester)?day?)/i,
calc: () => dayjs().subtract(1, 'days').startOf('day'),
},
{
regExp: /^(?:前天|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)/i,
calc: () => dayjs().subtract(2, 'days').startOf('day'),
},
{
regExp: /^(?:明[天日早晨晚]|t(?:omorrow)?)/i,
calc: () => dayjs().add(1, 'days').startOf('day'),
},
{
regExp: /^(?:[后後][天日]|(?:the)?d(?:ay)?a(?:fter)?t(?:omrrow)?)/i,
calc: () => dayjs().add(2, 'days').startOf('day'),
},
// Weekdays (English + Chinese)
{ regExp: /^(?:mon(?:day)?|(?:周|星期)[1一])/i, calc: () => getLastWeekday(1) },
{ regExp: /^(?:tue(?:sday)?|(?:周|星期)[2二两])/i, calc: () => getLastWeekday(2) },
{ regExp: /^(?:wed(?:nesday)?|(?:周|星期)[3三])/i, calc: () => getLastWeekday(3) },
{ regExp: /^(?:thu(?:rsday)?|(?:周|星期)[4四])/i, calc: () => getLastWeekday(4) },
{ regExp: /^(?:fri(?:day)?|(?:周|星期)[5五])/i, calc: () => getLastWeekday(5) },
{ regExp: /^(?:sat(?:urday)?|(?:周|星期)[6六])/i, calc: () => getLastWeekday(6) },
{ regExp: /^(?:sun(?:day)?|(?:周|星期)[7日天])/i, calc: () => getLastWeekday(7) },
];
/**
* Normalizes the input string to a format suitable for parsing.
*
* Operations performed:
* - Lowercasing and trimming.
* - Converting English articles/quantifiers ('a', 'an', 'x') to numbers.
* - Converting Chinese numerals and quantifiers to Arabic numbers.
* - Removing punctuation (commas).
*
* @param date - The raw date string to normalize.
* @returns The normalized string.
*/
const normalize = (date: string): string => {
let str = date.toLowerCase().trim();
if (REGEX_JUST_NOW.test(str)) {
return 'just now';
}
// 1. Quantifiers: 'a'/'an' -> '1'
str = str.replaceAll(/(^|\s)an?(\s)/g, '$11$2');
// 2. Vague 'x' (English needs boundary)
str = str.replaceAll(/(^|\s)x(\s|$)/g, '$13$2');
// 3. Vague Chinese '几/数' (No boundary needed)
str = str.replaceAll(/几|幾|数/g, '3');
// 4. Chinese numerals
str = str.replaceAll(/[一二两三四五六七八九十]/g, (match) => CN_NUM_MAP[match] || match);
// 5. Remove commas
str = str.replaceAll(',', '');
return str;
};
/**
* Parses a string to extract the total duration based on predefined unit patterns.
*
* @param str - The string containing time units (e.g., "1 hour 30 mins").
* @returns A Dayjs Duration object representing the total time.
*/
const parseDuration = (str: string): plugin.Duration => {
let totalDuration = dayjs.duration(0);
// Remove spaces for regex unit matching
const cleanStr = str.replaceAll(/\s+/g, '');
for (const { unit, regExp } of UNIT_PATTERNS) {
const match = regExp.exec(cleanStr);
if (match) {
const val = Number.parseInt(match[1], 10);
if (!Number.isNaN(val)) {
totalDuration = totalDuration.add(val, unit);
}
}
}
return totalDuration;
};
/**
* A wrapper around `dayjs()` to parse standard date formats.
*
* @param date - The date input (string, number, or Date object).
* @param options - Optional Day.js configuration (e.g., format string).
* @returns A native JavaScript Date object.
*/
export const parseDate = (date: string | number | Date, ...options: OptionType[]): Date => dayjs(date, ...options).toDate();
/**
* Processes a date string composed of a semantic keyword and an optional time component.
*
* @param baseTime - The calculated base date (start of day).
* @param timePart - The string segment containing the time info (e.g., "3pm", "10:00").
* @param originalContext - The original normalized string for context detection (AM/PM modifiers).
* @returns The resolved Date object.
*/
const processSemanticKeyword = (baseTime: Dayjs, timePart: string, originalContext: string): Date => {
if (!timePart) {
return baseTime.toDate();
}
const isPM = REGEX_IS_PM.test(originalContext);
const isAM = REGEX_IS_AM.test(originalContext);
// Normalize formats like "3pm" to "3 pm" to separate digits from text
const fixTimePart = timePart.replace(REGEX_STICKY_AMPM, '$1 $2m');
// Attempt 1: Parse as a duration (e.g., "8点" -> 8 hours from start of day)
const extraDuration = parseDuration(fixTimePart);
let addedMillis = extraDuration.asMilliseconds();
// Attempt 2: Handle cases where duration regex fails (e.g., "3 pm" doesn't match standard units)
const stickyMatch = REGEX_STICKY_AMPM.exec(timePart);
if (addedMillis === 0 && stickyMatch) {
addedMillis = Number.parseInt(stickyMatch[1], 10) * 60 * 60 * 1000;
}
if (addedMillis > 0) {
const hours = dayjs.duration(addedMillis).asHours();
// Adjust for 12-hour clock context
if (isPM && hours < 12) {
addedMillis += 12 * 60 * 60 * 1000;
} else if (isAM && hours === 12) {
addedMillis -= 12 * 60 * 60 * 1000;
}
return baseTime.add(addedMillis, 'ms').toDate();
}
// Attempt 3: Parse as a standard time string using Day.js formats
const composedDateStr = `${baseTime.format('YYYY-MM-DD')} ${fixTimePart}`;
const tried = dayjs(composedDateStr, ['YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm', 'YYYY-MM-DD H:m', 'YYYY-MM-DD h:m a', 'YYYY-MM-DD h a']);
if (tried.isValid()) {
let result = tried;
// Manual adjustment if the parser missed 12-hour context (e.g., "下午" stripped earlier)
if (isPM && result.hour() < 12) {
result = result.add(12, 'hours');
}
return result.toDate();
}
return baseTime.toDate();
};
/**
* Parses a relative or semantic date string into a JavaScript Date object.
*
* This function handles a variety of natural language date formats, including:
*
* 1. **Immediate Time**:
* - `Just now`, `刚刚`
*
* 2. **Relative Durations**:
* - Past: `10 minutes ago`, `an hour ago`, `x days ago`, `几分钟前`
* - Future: `in 10 minutes`, `2 hours later`, `10分钟后`
*
* 3. **Semantic Keywords**:
* - `Today`, `Yesterday`, `Tomorrow`, `TDA`, `今天`, `昨天`, `明天`
* - `Monday`, `Tuesday`... (Always resolves to the previous occurrence if ambiguous)
* - `周一`, `星期三`, `前天`
*
* 4. **Combined Semantic Expressions** (Date + Time + Context):
* - Explicit Time: `Yesterday 10:00`, `Monday 3pm`
* - Contextual Modifiers: `Today 3pm`, `昨晚8点` (Yesterday Evening), `明早8点` (Tomorrow Morning)
* - Mixed Formats: `周五下午3点`, `Today 3 p.m.`
*
* 5. **Fallback**:
* - Any format not matched above is passed to Day.js with the provided options.
*
* @param date - The relative or absolute date string to parse.
* @param options - Optional configuration passed to Day.js for fallback parsing.
* @returns A parsed JavaScript Date object.
*/
export const parseRelativeDate = (date: string, ...options: OptionType[]): Date => {
if (!date) {
return new Date();
}
const normalized = normalize(date);
// Strategy 1: Immediate Time
if (normalized === 'just now') {
return dayjs().subtract(3, 'seconds').toDate();
}
// Strategy 2: Relative Duration
const agoMatch = REGEX_AGO.exec(normalized);
if (agoMatch) {
const duration = parseDuration(agoMatch[1]);
if (duration.asMilliseconds() > 0) {
return dayjs().subtract(duration).toDate();
}
}
const inMatch = REGEX_IN.exec(normalized);
if (inMatch) {
const duration = parseDuration(inMatch[1] || inMatch[2]);
if (duration.asMilliseconds() > 0) {
return dayjs().add(duration).toDate();
}
}
// Strategy 3: Semantic Keywords extraction and processing
const cleanStr = normalized.replaceAll(/\s+/g, '');
for (const word of KEYWORDS) {
const match = word.regExp.exec(cleanStr);
if (match) {
const baseTime = word.calc();
const timePart = cleanStr.replace(word.regExp, '');
return processSemanticKeyword(baseTime, timePart, normalized);
}
}
// Strategy 4: Fallback to standard Day.js parsing
return parseDate(date, ...options);
};
|