Adds recurrence to natural language parser

This commit is contained in:
Andrew Gioia 2026-02-19 13:46:23 -05:00
parent 8073284fab
commit 337f41a86b
Signed by: andrew
GPG Key ID: FC09694A000800C8

View File

@ -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);