# Pull Request Template ## Description This PR introduces a custom, lightweight natural-language date parser (dependency-free except for date-fns) to power snooze actions via the command bar (e.g., “Remind me tomorrow at 6am”). It also adds support for multi-language searches. <details> <summary>Supported Formats</summary> ## Snooze Date Parser — Supported Input Formats ## 1. Durations Specify an amount of time from now. ### Basic - `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten year` - `in 2 hours` · `in 30 minutes` · `in a week` · `in a month` - `5 minutes from now` · `a week from now` · `two weeks from now` ### Half / fractional - `half hour` · `half day` · `half week` · `half month` - `in half a day` · `in half an hour` · `in half a week` - `one and a half hours` · `in one and a half hours` - `1.5 hours` · `2.5 days` ### Compound - `1 hour and 30 minutes` · `2 hours and 15 minutes` - `2 days at 3pm` · `1 week at 9am` ### Shorthand (no spaces) - `2h` · `30m` · `1h30m` · `2h15m` - `1h30minutes` · `2hr15min` · `1hour30min` ### Informal quantities - `couple hours` · `a couple of days` · `in a couple hours` - `a few minutes` · `in a few hours` · `in a few days` - `fortnight` · `in a fortnight` _(= 2 weeks)_ ### Trailing "later" - `2 days later` · `a week later` · `month later` ### Prefix words (`after` / `within`) - `after 2 hours` · `after 3 days` · `after ten year` - `within a week` · `within 2 hours` ### Recognised word-numbers `a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` · `sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3) --- ## 2. Relative Days - `today` · `tonight` · `tomorrow` - `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` · `tomorrow night` - `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm` - `tomorrow same time` · `same time tomorrow` - `day after tomorrow` · `the day after tomorrow` · `day after tomorrow at 2pm` - `later today` · `later this afternoon` · `later this evening` --- ## 3. Weekdays - `monday` · `friday` · `wed` · `thu` - `friday at 3pm` · `monday 9am` · `wednesday 14:30` - `monday morning` · `friday afternoon` · `wednesday evening` - `monday morning 6` · `friday evening 7` - `this friday` · `upcoming monday` · `coming friday` - `same time friday` · `same time wednesday` --- ## 4. "Next" Patterns - `next hour` · `next week` · `next month` · `next year` - `next week at 2pm` · `next month at 9am` - `next monday` · `next friday` · `next friday at 3pm` - `next monday morning` · `next friday evening` - `monday of next week` · `next week monday` - `next january` · `next december` - `next business day` · `next working day` --- ## 5. Time of Day - `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight` - `this morning` · `this afternoon` · `this evening` - `early morning` · `late evening` · `late night` - `morning at 8am` · `evening 6pm` · `afternoon 2pm` - `eod` · `end of day` · `end of the day` --- ## 6. Standalone Time - **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am` - **24-hour:** `14:30` · `at 14:30` --- ## 7. Named Dates (Month + Day) - `jan 15` · `january 15` · `march 20` · `dec 25` - `jan 1st` · `march 3rd` · `april 2nd` · `december 31st` - `15 march` · `25 dec` _(reversed order)_ - `jan 15 2025` · `dec 25 2025` · `march 20 next year` - `jan 15 at 2pm` · `march 5 at 2pm` - `december 2025` · `january 2024` _(month + year only)_ --- ## 8. Month + Ordinal Patterns Target a specific week or day within a month. ### Week of month - `april first week` · `july 2nd week` · `feb 3rd week` - `first week of april` · `2nd week of july` ### Day of month - `april first day` · `march second day` · `march 5th day` - `third day of march` · `5th day of jan at 2pm` ### Supported ordinals - **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for weeks) - **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh` `eighth` `ninth` `tenth` --- ## 9. Formal / Numeric Dates - **ISO:** `2025-01-15` - **Slash (M/D/Y):** `01/15/2025` - **Dash (D-M-Y):** `15-01-2025` - **Dot (D.M.Y):** `15.01.2025` - Any of the above **+ time:** `2025-01-15 at 3pm` --- ## 10. Special Phrases - `this weekend` · `weekend` · `next weekend` - `end of week` · `end of month` - `end of next week` · `end of next month` - `beginning of next week` · `start of next week` - `beginning of next month` --- ## 11. Noise / Filler Stripping The parser silently removes conversational prefixes so all of these work exactly the same as the bare expression: ``` snooze for 2 hours → 2 hours remind me tomorrow → tomorrow please snooze until friday → friday can you set a reminder for next week → next week schedule this for jan 15 → jan 15 postpone to next monday → next monday defer for 2 days → 2 days delay it by 1 hour → 1 hour ``` ### Recognised filler verbs / prefixes `snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder` · `schedule` · `postpone` · `defer` · `delay` · `push` ### Recognised prepositions (stripped) `on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after` · `within` ### Typo corrections `tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next` --- ## 12. Multi-Language Support The parser supports localised input via translations in `snooze.json`. ### Translatable token categories - **Units:** minute, hour, day, week, month, year _(singular + plural)_ - **Relative days:** tomorrow, day after tomorrow, next week / month, this / next weekend - **Time of day:** morning, afternoon, evening, night, noon, midnight - **Word numbers:** one through ten, twelve, fifteen, twenty, thirty - **Ordinals:** first through fifth - **Structural words:** at, in, of, after, week, day, from now, next year - **Meridiem:** am, pm ### Auto-detected from locale Weekday names and month names are resolved automatically via `Intl.DateTimeFormat` for the user's locale — no manual translation needed. </details> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screenshots** <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
409 lines
11 KiB
JavaScript
409 lines
11 KiB
JavaScript
/**
|
|
* Handles non-English input and generates the final suggestion list.
|
|
* Translates localized words to English before parsing, then converts
|
|
* suggestion labels back to the user's language for display.
|
|
*/
|
|
|
|
import {
|
|
WEEKDAY_MAP,
|
|
MONTH_MAP,
|
|
UNIT_MAP,
|
|
WORD_NUMBER_MAP,
|
|
RELATIVE_DAY_MAP,
|
|
TIME_OF_DAY_MAP,
|
|
sanitize,
|
|
stripNoise,
|
|
normalizeDigits,
|
|
} from './tokenMaps';
|
|
|
|
import { parseDateFromText } from './parser';
|
|
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
|
|
|
|
// ─── English Reference Data ─────────────────────────────────────────────────
|
|
|
|
const EN_WEEKDAYS_LIST = [
|
|
'monday',
|
|
'tuesday',
|
|
'wednesday',
|
|
'thursday',
|
|
'friday',
|
|
'saturday',
|
|
'sunday',
|
|
];
|
|
|
|
const EN_MONTHS_LIST = [
|
|
'january',
|
|
'february',
|
|
'march',
|
|
'april',
|
|
'may',
|
|
'june',
|
|
'july',
|
|
'august',
|
|
'september',
|
|
'october',
|
|
'november',
|
|
'december',
|
|
];
|
|
|
|
const EN_DEFAULTS = {
|
|
UNITS: {
|
|
MINUTE: 'minute',
|
|
MINUTES: 'minutes',
|
|
HOUR: 'hour',
|
|
HOURS: 'hours',
|
|
DAY: 'day',
|
|
DAYS: 'days',
|
|
WEEK: 'week',
|
|
WEEKS: 'weeks',
|
|
MONTH: 'month',
|
|
MONTHS: 'months',
|
|
YEAR: 'year',
|
|
YEARS: 'years',
|
|
},
|
|
RELATIVE: {
|
|
TOMORROW: 'tomorrow',
|
|
DAY_AFTER_TOMORROW: 'day after tomorrow',
|
|
NEXT_WEEK: 'next week',
|
|
NEXT_MONTH: 'next month',
|
|
THIS_WEEKEND: 'this weekend',
|
|
NEXT_WEEKEND: 'next weekend',
|
|
},
|
|
TIME_OF_DAY: {
|
|
MORNING: 'morning',
|
|
AFTERNOON: 'afternoon',
|
|
EVENING: 'evening',
|
|
NIGHT: 'night',
|
|
NOON: 'noon',
|
|
MIDNIGHT: 'midnight',
|
|
},
|
|
WORD_NUMBERS: {
|
|
ONE: 'one',
|
|
TWO: 'two',
|
|
THREE: 'three',
|
|
FOUR: 'four',
|
|
FIVE: 'five',
|
|
SIX: 'six',
|
|
SEVEN: 'seven',
|
|
EIGHT: 'eight',
|
|
NINE: 'nine',
|
|
TEN: 'ten',
|
|
TWELVE: 'twelve',
|
|
FIFTEEN: 'fifteen',
|
|
TWENTY: 'twenty',
|
|
THIRTY: 'thirty',
|
|
},
|
|
ORDINALS: {
|
|
FIRST: 'first',
|
|
SECOND: 'second',
|
|
THIRD: 'third',
|
|
FOURTH: 'fourth',
|
|
FIFTH: 'fifth',
|
|
},
|
|
MERIDIEM: { AM: 'am', PM: 'pm' },
|
|
HALF: 'half',
|
|
NEXT: 'next',
|
|
THIS: 'this',
|
|
AT: 'at',
|
|
IN: 'in',
|
|
OF: 'of',
|
|
AFTER: 'after',
|
|
WEEK: 'week',
|
|
DAY: 'day',
|
|
FROM_NOW: 'from now',
|
|
NEXT_YEAR: 'next year',
|
|
};
|
|
|
|
const STRUCTURAL_WORDS = [
|
|
'at',
|
|
'in',
|
|
'next',
|
|
'this',
|
|
'from',
|
|
'now',
|
|
'after',
|
|
'half',
|
|
'same',
|
|
'time',
|
|
'weekend',
|
|
'end',
|
|
'of',
|
|
'the',
|
|
'eod',
|
|
'am',
|
|
'pm',
|
|
'week',
|
|
'day',
|
|
'first',
|
|
'second',
|
|
'third',
|
|
'fourth',
|
|
'fifth',
|
|
];
|
|
|
|
const ENGLISH_VOCAB = new Set([
|
|
...Object.keys(WEEKDAY_MAP),
|
|
...Object.keys(MONTH_MAP),
|
|
...Object.keys(UNIT_MAP),
|
|
...Object.keys(WORD_NUMBER_MAP),
|
|
...Object.keys(RELATIVE_DAY_MAP),
|
|
...Object.keys(TIME_OF_DAY_MAP),
|
|
...EN_WEEKDAYS_LIST,
|
|
...EN_MONTHS_LIST,
|
|
...STRUCTURAL_WORDS,
|
|
]);
|
|
|
|
// ─── Regex for token replacement ────────────────────────────────────────────
|
|
|
|
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
|
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
|
|
const NUM_TOD_RE =
|
|
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
|
|
const TOD_TO_MERIDIEM = {
|
|
morning: 'am',
|
|
noon: 'pm',
|
|
afternoon: 'pm',
|
|
evening: 'pm',
|
|
night: 'pm',
|
|
};
|
|
|
|
// ─── Translation Cache ──────────────────────────────────────────────────────
|
|
|
|
const safeString = v => (v == null ? '' : String(v));
|
|
const MAX_PAIRS_CACHE = 20;
|
|
const pairsCache = new Map();
|
|
const CACHE_SECTIONS = [
|
|
'UNITS',
|
|
'RELATIVE',
|
|
'TIME_OF_DAY',
|
|
'WORD_NUMBERS',
|
|
'ORDINALS',
|
|
'MERIDIEM',
|
|
];
|
|
const SINGLE_KEYS = [
|
|
'HALF',
|
|
'NEXT',
|
|
'THIS',
|
|
'AT',
|
|
'IN',
|
|
'OF',
|
|
'AFTER',
|
|
'WEEK',
|
|
'DAY',
|
|
'FROM_NOW',
|
|
'NEXT_YEAR',
|
|
];
|
|
|
|
/** Create a string key from translations so we can cache results. */
|
|
const translationSignature = translations => {
|
|
if (!translations || typeof translations !== 'object') return 'none';
|
|
return [
|
|
...CACHE_SECTIONS.flatMap(section => {
|
|
const values = translations[section] || {};
|
|
return Object.keys(values)
|
|
.sort()
|
|
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
|
|
}),
|
|
...SINGLE_KEYS.map(
|
|
k => `${k}:${safeString(translations[k]).toLowerCase()}`
|
|
),
|
|
].join('|');
|
|
};
|
|
|
|
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
|
|
const buildReplacementPairsUncached = (translations, locale) => {
|
|
const pairs = [];
|
|
const seen = new Set();
|
|
const t = translations || {};
|
|
|
|
const addPair = (local, en) => {
|
|
const l = sanitize(safeString(local));
|
|
const e = safeString(en).toLowerCase();
|
|
const key = `${l}\0${e}`;
|
|
if (l && e && l !== e && !seen.has(key)) {
|
|
seen.add(key);
|
|
pairs.push([l, e]);
|
|
}
|
|
};
|
|
|
|
CACHE_SECTIONS.forEach(section => {
|
|
const localSection = t[section] || {};
|
|
const enSection = EN_DEFAULTS[section] || {};
|
|
Object.keys(enSection).forEach(key => {
|
|
addPair(localSection[key], enSection[key]);
|
|
});
|
|
});
|
|
|
|
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
|
|
|
|
try {
|
|
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
|
|
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
|
|
EN_WEEKDAYS_LIST.forEach((en, i) => {
|
|
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
|
|
});
|
|
} catch {
|
|
/* locale not supported */
|
|
}
|
|
|
|
try {
|
|
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
|
|
EN_MONTHS_LIST.forEach((en, i) => {
|
|
addPair(moFmt.format(new Date(2024, i, 1)), en);
|
|
});
|
|
} catch {
|
|
/* locale not supported */
|
|
}
|
|
|
|
pairs.sort((a, b) => b[0].length - a[0].length);
|
|
return pairs;
|
|
};
|
|
|
|
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
|
|
const buildReplacementPairs = (translations, locale) => {
|
|
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
|
|
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
|
|
const pairs = buildReplacementPairsUncached(translations, locale);
|
|
if (pairsCache.size >= MAX_PAIRS_CACHE)
|
|
pairsCache.delete(pairsCache.keys().next().value);
|
|
pairsCache.set(cacheKey, pairs);
|
|
return pairs;
|
|
};
|
|
|
|
// ─── Token Replacement ──────────────────────────────────────────────────────
|
|
|
|
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
/** Swap localized words for their English versions in the text. */
|
|
const substituteLocalTokens = (text, pairs) => {
|
|
let r = text;
|
|
pairs.forEach(([local, en]) => {
|
|
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
|
r = r.replace(re, en);
|
|
});
|
|
return r;
|
|
};
|
|
|
|
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
|
|
const filterToEnglishVocab = text =>
|
|
normalizeDigits(text)
|
|
.replace(/(\d+)h\b/g, '$1:00')
|
|
.split(/\s+/)
|
|
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
|
|
.join(' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
|
|
const repositionNextYear = text => {
|
|
if (!MONTH_NAME_RE.test(text)) return text;
|
|
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
|
|
/next/i.test(m) ? m : 'next year'
|
|
);
|
|
if (!/\bnext\s+year\b/i.test(r)) return r;
|
|
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
|
|
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
|
|
const timePart = withoutNY.match(timeRe);
|
|
if (timePart) {
|
|
const beforeTime = withoutNY.slice(0, timePart.index).trim();
|
|
r = `${beforeTime} next year ${timePart[0].trim()}`;
|
|
} else {
|
|
r = `${withoutNY} next year`;
|
|
}
|
|
return r;
|
|
};
|
|
|
|
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
|
|
const replaceTokens = (text, pairs) => {
|
|
const substituted = substituteLocalTokens(text, pairs);
|
|
const filtered = filterToEnglishVocab(substituted);
|
|
const fixed = filtered.replace(
|
|
NUM_TOD_RE,
|
|
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
|
|
);
|
|
return stripNoise(repositionNextYear(fixed));
|
|
};
|
|
|
|
/** Convert English words back to the user's language for display. */
|
|
const reverseTokens = (text, pairs) =>
|
|
pairs.reduce(
|
|
(r, [local, en]) =>
|
|
r.replace(
|
|
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
|
|
local
|
|
),
|
|
text
|
|
);
|
|
|
|
// ─── Main Suggestion Generator ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate snooze suggestions from what the user has typed so far.
|
|
* Works with any language if translations are provided. Returns up to 5
|
|
* unique results, each with a label, date, and unix timestamp.
|
|
*
|
|
* @param {string} text - what the user typed
|
|
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
|
* @param {{ translations?: object, locale?: string }} [options] - i18n config
|
|
* @returns {Array<{ label: string, date: Date, unix: number }>}
|
|
*/
|
|
export const generateDateSuggestions = (
|
|
text,
|
|
referenceDate = new Date(),
|
|
{ translations, locale } = {}
|
|
) => {
|
|
if (!text || typeof text !== 'string') return [];
|
|
const normalized = sanitize(text);
|
|
if (!normalized) return [];
|
|
|
|
const stripped = stripNoise(normalized);
|
|
const pairs =
|
|
locale && locale !== 'en'
|
|
? buildReplacementPairs(translations, locale)
|
|
: [];
|
|
|
|
// Try English parse first, then translated parse if we have locale pairs.
|
|
// This avoids the problem where a single overlapping word (e.g. "in" in German)
|
|
// would skip token translation entirely.
|
|
const directParse = parseDateFromText(stripped, referenceDate);
|
|
|
|
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
|
|
const translatedParse =
|
|
translated && translated !== stripped
|
|
? parseDateFromText(translated, referenceDate)
|
|
: null;
|
|
|
|
// Prefer direct English parse; fall back to translated parse
|
|
const useTranslated = !directParse && !!translatedParse;
|
|
const englishInput = useTranslated ? translated : stripped;
|
|
|
|
const seen = new Set();
|
|
const results = [];
|
|
|
|
const exact = directParse || translatedParse;
|
|
if (exact) {
|
|
seen.add(exact.unix);
|
|
const exactLabel =
|
|
useTranslated && pairs.length
|
|
? reverseTokens(englishInput, pairs)
|
|
: englishInput;
|
|
results.push({ label: exactLabel, query: englishInput, ...exact });
|
|
}
|
|
|
|
buildSuggestionCandidates(englishInput).some(candidate => {
|
|
if (results.length >= MAX_SUGGESTIONS) return true;
|
|
const result = parseDateFromText(candidate, referenceDate);
|
|
if (result && !seen.has(result.unix)) {
|
|
seen.add(result.unix);
|
|
const label =
|
|
useTranslated && pairs.length
|
|
? reverseTokens(candidate, pairs)
|
|
: candidate;
|
|
results.push({ label, query: candidate, ...result });
|
|
}
|
|
return false;
|
|
});
|
|
|
|
return results;
|
|
};
|