Files
chatwoot/app/javascript/dashboard/helper/snoozeDateParser/suggestions.js
T

102 lines
3.1 KiB
JavaScript

/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...TOD_NAMES.map(tod => `tomorrow ${tod}`),
...TOD_NAMES.map(tod => `tomorrow at ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(text)) {
const num = text.match(/^\d+(?:\.5)?/)[0];
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
const trimmed = text.replace(/\s+/g, ' ').trim();
return trimmed.length > num.length
? candidates.filter(c => c.startsWith(trimmed))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};