Adds recurrence to natural language parser
This commit is contained in:
parent
8073284fab
commit
337f41a86b
@ -643,6 +643,92 @@ function initNaturalEventParser(root = document) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const weekdayCodeMap = {
|
||||||
|
su: 'SU', sun: 'SU', sunday: 'SU', sundays: 'SU',
|
||||||
|
mo: 'MO', mon: 'MO', monday: 'MO', mondays: 'MO',
|
||||||
|
tu: 'TU', tue: 'TU', tues: 'TU', tuesday: 'TU', tuesdays: 'TU',
|
||||||
|
we: 'WE', wed: 'WE', wednesday: 'WE', wednesdays: 'WE',
|
||||||
|
th: 'TH', thu: 'TH', thur: 'TH', thurs: 'TH', thursday: 'TH', thursdays: 'TH',
|
||||||
|
fr: 'FR', fri: 'FR', friday: 'FR', fridays: 'FR',
|
||||||
|
sa: 'SA', sat: 'SA', saturday: 'SA', saturdays: 'SA',
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekdayNameMap = {
|
||||||
|
SU: 'Sunday',
|
||||||
|
MO: 'Monday',
|
||||||
|
TU: 'Tuesday',
|
||||||
|
WE: 'Wednesday',
|
||||||
|
TH: 'Thursday',
|
||||||
|
FR: 'Friday',
|
||||||
|
SA: 'Saturday',
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekdayCodeFromDate = (date) => ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][date.getDay()];
|
||||||
|
|
||||||
|
const parseRecurrenceToken = (text) => {
|
||||||
|
const repeatMatch = text.match(/\brepeats?\b([\s\S]*)$/i);
|
||||||
|
if (!repeatMatch) return null;
|
||||||
|
|
||||||
|
const tail = (repeatMatch[1] || '').trim().toLowerCase();
|
||||||
|
const recurrence = {
|
||||||
|
frequency: null,
|
||||||
|
interval: 1,
|
||||||
|
weekdays: [],
|
||||||
|
index: repeatMatch.index ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const everyNumbered = tail.match(/\bevery\s+(\d+)\s*(day|week|month|year)s?\b/i);
|
||||||
|
if (everyNumbered) {
|
||||||
|
const count = parseInt(everyNumbered[1], 10);
|
||||||
|
const unit = everyNumbered[2].toLowerCase();
|
||||||
|
if (!Number.isNaN(count) && count > 0) {
|
||||||
|
recurrence.interval = count;
|
||||||
|
}
|
||||||
|
recurrence.frequency = {
|
||||||
|
day: 'daily',
|
||||||
|
week: 'weekly',
|
||||||
|
month: 'monthly',
|
||||||
|
year: 'yearly',
|
||||||
|
}[unit] || null;
|
||||||
|
} else if (/\b(daily|every day)\b/i.test(tail)) {
|
||||||
|
recurrence.frequency = 'daily';
|
||||||
|
} else if (/\b(weekly|every week)\b/i.test(tail)) {
|
||||||
|
recurrence.frequency = 'weekly';
|
||||||
|
} else if (/\b(monthly|every month)\b/i.test(tail)) {
|
||||||
|
recurrence.frequency = 'monthly';
|
||||||
|
} else if (/\b(yearly|every year)\b/i.test(tail)) {
|
||||||
|
recurrence.frequency = 'yearly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recurrence.frequency) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence.frequency === 'weekly') {
|
||||||
|
const onMatch = tail.match(/\bon\s+(.+)$/i);
|
||||||
|
if (onMatch) {
|
||||||
|
const weekdays = [];
|
||||||
|
const tokens = onMatch[1]
|
||||||
|
.replace(/[,/]/g, ' ')
|
||||||
|
.replace(/\band\b/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((token) => token.trim().toLowerCase().replace(/[^a-z]/g, ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
const code = weekdayCodeMap[token];
|
||||||
|
if (code && !weekdays.includes(code)) {
|
||||||
|
weekdays.push(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recurrence.weekdays = weekdays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recurrence;
|
||||||
|
};
|
||||||
|
|
||||||
const parseLocationToken = (text) => {
|
const parseLocationToken = (text) => {
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
const index = lower.lastIndexOf(' at ');
|
const index = lower.lastIndexOf(' at ');
|
||||||
@ -656,11 +742,12 @@ function initNaturalEventParser(root = document) {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTitleToken = (text, dateToken, timeToken, relativeToken, locationToken) => {
|
const parseTitleToken = (text, dateToken, timeToken, relativeToken, recurrenceToken, locationToken) => {
|
||||||
const boundaries = [];
|
const boundaries = [];
|
||||||
if (dateToken) boundaries.push(dateToken.index);
|
if (dateToken) boundaries.push(dateToken.index);
|
||||||
if (timeToken) boundaries.push(timeToken.index);
|
if (timeToken) boundaries.push(timeToken.index);
|
||||||
if (relativeToken) boundaries.push(relativeToken.index);
|
if (relativeToken) boundaries.push(relativeToken.index);
|
||||||
|
if (recurrenceToken) boundaries.push(recurrenceToken.index);
|
||||||
|
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
const durationIndex = lower.indexOf(' for ');
|
const durationIndex = lower.indexOf(' for ');
|
||||||
@ -684,11 +771,12 @@ function initNaturalEventParser(root = document) {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const relativeToken = parseRelativeToken(trimmed, baseDate);
|
const relativeToken = parseRelativeToken(trimmed, baseDate);
|
||||||
|
const recurrenceToken = parseRecurrenceToken(trimmed);
|
||||||
const dateToken = parseDateToken(trimmed, baseDate);
|
const dateToken = parseDateToken(trimmed, baseDate);
|
||||||
const timeToken = parseTimeToken(trimmed);
|
const timeToken = parseTimeToken(trimmed);
|
||||||
const durationMinutes = parseDurationMinutes(trimmed);
|
const durationMinutes = parseDurationMinutes(trimmed);
|
||||||
const location = parseLocationToken(trimmed);
|
const location = parseLocationToken(trimmed);
|
||||||
const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, location);
|
const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, recurrenceToken, location);
|
||||||
const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
|
const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -697,6 +785,7 @@ function initNaturalEventParser(root = document) {
|
|||||||
dateToken,
|
dateToken,
|
||||||
timeToken,
|
timeToken,
|
||||||
relativeToken,
|
relativeToken,
|
||||||
|
recurrenceToken,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
allDay,
|
allDay,
|
||||||
};
|
};
|
||||||
@ -725,6 +814,108 @@ function initNaturalEventParser(root = document) {
|
|||||||
renderModalAside(dialog, [], true);
|
renderModalAside(dialog, [], true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearRecurrenceInputs = (form) => {
|
||||||
|
const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
|
||||||
|
const intervalInput = form.querySelector('input[name="repeat_interval"]');
|
||||||
|
const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
|
||||||
|
const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
|
||||||
|
const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
|
||||||
|
|
||||||
|
if (frequencyInput) {
|
||||||
|
frequencyInput.value = '';
|
||||||
|
frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
if (intervalInput) {
|
||||||
|
intervalInput.value = '1';
|
||||||
|
}
|
||||||
|
weekdayInputs.forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
});
|
||||||
|
monthlyModeInputs.forEach((input) => {
|
||||||
|
input.checked = input.value === 'days';
|
||||||
|
});
|
||||||
|
monthDayInputs.forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRecurrenceInputs = (form, recurrenceToken, anchorDate) => {
|
||||||
|
const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
|
||||||
|
const intervalInput = form.querySelector('input[name="repeat_interval"]');
|
||||||
|
const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
|
||||||
|
const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
|
||||||
|
const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
|
||||||
|
|
||||||
|
if (!frequencyInput || !recurrenceToken?.frequency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
frequencyInput.value = recurrenceToken.frequency;
|
||||||
|
frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
if (intervalInput) {
|
||||||
|
intervalInput.value = String(recurrenceToken.interval || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'weekly') {
|
||||||
|
let weekdays = recurrenceToken.weekdays || [];
|
||||||
|
if (!weekdays.length && anchorDate) {
|
||||||
|
weekdays = [weekdayCodeFromDate(anchorDate)];
|
||||||
|
}
|
||||||
|
weekdayInputs.forEach((input) => {
|
||||||
|
input.checked = weekdays.includes(input.value);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
weekdayInputs.forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'monthly') {
|
||||||
|
monthlyModeInputs.forEach((input) => {
|
||||||
|
input.checked = input.value === 'days';
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayOfMonth = anchorDate ? anchorDate.getDate() : null;
|
||||||
|
monthDayInputs.forEach((input) => {
|
||||||
|
input.checked = dayOfMonth ? Number(input.value) === dayOfMonth : false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
monthDayInputs.forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurrenceSummary = (recurrenceToken, anchorDate) => {
|
||||||
|
if (!recurrenceToken?.frequency) return '';
|
||||||
|
|
||||||
|
const interval = recurrenceToken.interval || 1;
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'daily') {
|
||||||
|
return interval === 1 ? 'Daily' : `Every ${interval} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'weekly') {
|
||||||
|
const days = (recurrenceToken.weekdays || []).length
|
||||||
|
? recurrenceToken.weekdays
|
||||||
|
: (anchorDate ? [weekdayCodeFromDate(anchorDate)] : []);
|
||||||
|
const dayLabels = days.map((code) => weekdayNameMap[code] || code);
|
||||||
|
const head = interval === 1 ? 'Every week' : `Every ${interval} weeks`;
|
||||||
|
return dayLabels.length ? `${head} on ${dayLabels.join(' and ')}` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'monthly') {
|
||||||
|
return interval === 1 ? 'Monthly' : `Every ${interval} months`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceToken.frequency === 'yearly') {
|
||||||
|
return interval === 1 ? 'Yearly' : `Every ${interval} years`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const applyParsedData = (input) => {
|
const applyParsedData = (input) => {
|
||||||
const dialog = input.closest('dialog');
|
const dialog = input.closest('dialog');
|
||||||
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
||||||
@ -793,6 +984,14 @@ function initNaturalEventParser(root = document) {
|
|||||||
endInput.value = toLocalDatetimeInputValue(end);
|
endInput.value = toLocalDatetimeInputValue(end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.recurrenceToken) {
|
||||||
|
applyRecurrenceInputs(form, parsed.recurrenceToken, baseDate);
|
||||||
|
input.dataset.nlRecurrenceApplied = '1';
|
||||||
|
} else if (input.dataset.nlRecurrenceApplied === '1') {
|
||||||
|
clearRecurrenceInputs(form);
|
||||||
|
input.dataset.nlRecurrenceApplied = '0';
|
||||||
|
}
|
||||||
|
|
||||||
const summaryItems = [];
|
const summaryItems = [];
|
||||||
if (parsed.title) {
|
if (parsed.title) {
|
||||||
summaryItems.push({ label: 'Title', value: parsed.title });
|
summaryItems.push({ label: 'Title', value: parsed.title });
|
||||||
@ -814,6 +1013,12 @@ function initNaturalEventParser(root = document) {
|
|||||||
summaryItems.push({ label: 'Date', value: summaryDate });
|
summaryItems.push({ label: 'Date', value: summaryDate });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parsed.recurrenceToken) {
|
||||||
|
const repeat = recurrenceSummary(parsed.recurrenceToken, baseDate);
|
||||||
|
if (repeat) {
|
||||||
|
summaryItems.push({ label: 'Repeat', value: repeat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (modal?.classList.contains('natural-collapsed')) {
|
if (modal?.classList.contains('natural-collapsed')) {
|
||||||
renderModalAside(dialog, summaryItems, true);
|
renderModalAside(dialog, summaryItems, true);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user