diff --git a/package-lock.json b/package-lock.json index 054be889ab..42fa7cbe3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@nextcloud/timezones": "^1.0.2", "@nextcloud/vue": "^9.8.2", "autosize": "^6.0.1", + "chrono-node": "^2.9.1", "color-convert": "^3.1.3", "color-string": "^2.1.4", "core-js": "^3.49.0", @@ -2730,9 +2731,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2747,9 +2745,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2764,9 +2759,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2781,9 +2773,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5320,6 +5309,15 @@ "node": ">=6.0" } }, + "node_modules/chrono-node": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.1.tgz", + "integrity": "sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", diff --git a/package.json b/package.json index 8334bf1128..7ee4c9f502 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@nextcloud/timezones": "^1.0.2", "@nextcloud/vue": "^9.8.2", "autosize": "^6.0.1", + "chrono-node": "^2.9.1", "color-convert": "^3.1.3", "color-string": "^2.1.4", "core-js": "^3.49.0", diff --git a/src/components/Editor/Properties/TimeSuggestion.vue b/src/components/Editor/Properties/TimeSuggestion.vue new file mode 100644 index 0000000000..4746611341 --- /dev/null +++ b/src/components/Editor/Properties/TimeSuggestion.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/src/mixins/TimeSuggestionMixin.js b/src/mixins/TimeSuggestionMixin.js new file mode 100644 index 0000000000..20b4f9ee9f --- /dev/null +++ b/src/mixins/TimeSuggestionMixin.js @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLanguage } from '@nextcloud/l10n' +import { parseNaturalLanguageTime } from '../services/naturalLanguageTimeParserService.js' + +export default { + data() { + return { + timeSuggestion: null, + } + }, + + methods: { + handleTitleUpdate(value) { + this.updateTitle(value) + const parsed = parseNaturalLanguageTime( + value, + this.calendarObjectInstance?.startDate ?? null, + getLanguage(), + ) + if (parsed) { + const before = value.slice(0, parsed.matchedIndex) + const after = value.slice(parsed.matchedIndex + parsed.matchedText.length) + parsed.strippedTitle = (before + after).replace(/\s+/g, ' ').trim() + } + this.timeSuggestion = parsed + }, + + async applyTimeSuggestion() { + const s = this.timeSuggestion + if (!s || !this.calendarObjectInstance) { + return + } + + const base = new Date(this.calendarObjectInstance.startDate) + + switch (s.type) { + case 'all-day': + if (!this.isAllDay) { + this.toggleAllDay() + } + break + case 'time-range': { + if (this.isAllDay) { + this.toggleAllDay() + } + const start = new Date(base) + start.setHours(s.startHour, s.startMinute, 0, 0) + const end = new Date(base) + end.setHours(s.endHour, s.endMinute, 0, 0) + this.updateStartTime(start) + this.updateEndTime(end) + break + } + case 'time-only': { + if (this.isAllDay) { + this.toggleAllDay() + } + const start = new Date(base) + start.setHours(s.startHour, s.startMinute, 0, 0) + this.updateStartTime(start) + break + } + case 'date-range': { + if (!this.isAllDay) { + this.toggleAllDay() + } + this.updateStartDate(new Date(s.startYear, s.startMonth - 1, s.startDay)) + this.updateEndDate(new Date(s.endYear, s.endMonth - 1, s.endDay)) + break + } + case 'date': { + if (!this.isAllDay) { + this.toggleAllDay() + } + const d = new Date(s.year, s.month - 1, s.day) + this.updateStartDate(d) + this.updateEndDate(d) + break + } + case 'datetime': { + if (this.isAllDay) { + this.toggleAllDay() + } + const d = new Date(s.year, s.month - 1, s.day, s.startHour, s.startMinute, 0, 0) + this.updateStartDate(d) + this.updateStartTime(d) + break + } + } + + // Clear suggestion first so Vue flushes that re-render, then update the + // title in a separate tick so the NcTextField double-useModel chain + // propagates the empty value cleanly to the DOM input. + const strippedTitle = s.strippedTitle ?? '' + this.timeSuggestion = null + await this.$nextTick() + this.updateTitle(strippedTitle) + }, + + dismissTimeSuggestion() { + this.timeSuggestion = null + }, + + handleTitleAreaKeydown(event) { + if (!this.timeSuggestion) { + return + } + if (event.key === 'Enter') { + event.preventDefault() + this.applyTimeSuggestion() + } else if (event.key === 'Escape') { + event.stopPropagation() + this.dismissTimeSuggestion() + } + }, + }, +} diff --git a/src/services/naturalLanguageTimeParserService.js b/src/services/naturalLanguageTimeParserService.js new file mode 100644 index 0000000000..fbd3a72b84 --- /dev/null +++ b/src/services/naturalLanguageTimeParserService.js @@ -0,0 +1,240 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import * as chrono from 'chrono-node' + +// Extend the casual English parser with the European DD.MM.YY(YY) date format. +const europeanDateParser = { + pattern: () => /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/, + extract(_context, match) { + const day = parseInt(match[1], 10) + const month = parseInt(match[2], 10) + let year = parseInt(match[3], 10) + if (year < 100) { + year = year < 50 ? 2000 + year : 1900 + year + } + if (day < 1 || day > 31 || month < 1 || month > 12) { + return null + } + return { day, month, year } + }, +} + +/** + * @param {object} base chrono locale object + * @return {object} chrono instance with the European date parser prepended + */ +function buildInstance(base) { + const instance = base.casual.clone() + // Prepend so the European DD.MM.YY parser takes priority over chrono's built-in + // numeric date parser, which would otherwise misread e.g. "12.07.2026" as December 7. + instance.parsers.unshift(europeanDateParser) + return instance +} + +// One pre-built instance per supported locale, created once at module load. +const chronoInstances = { + en: buildInstance(chrono.en), + de: buildInstance(chrono.de), + fr: buildInstance(chrono.fr), + es: buildInstance(chrono.es), + it: buildInstance(chrono.it), + nl: buildInstance(chrono.nl), + pt: buildInstance(chrono.pt), + ru: buildInstance(chrono.ru), + uk: buildInstance(chrono.uk), + sv: buildInstance(chrono.sv), + fi: buildInstance(chrono.fi), + ja: buildInstance(chrono.ja), + vi: buildInstance(chrono.vi), + 'zh-hans': buildInstance(chrono.zh.hans), + 'zh-hant': buildInstance(chrono.zh.hant), +} + +/** + * Map a Nextcloud locale code (e.g. "de_DE", "zh_CN") to a chrono instance. + * + * @param {string|null} language Nextcloud language string from getLanguage() + * @return {object} chrono instance + */ +function getChronoInstance(language) { + if (!language) { + return chronoInstances.en + } + const lang = language.toLowerCase().replace(/_/g, '-') + // Chinese variants: Simplified (CN/SG/default) vs Traditional (TW/HK/MO) + if (lang === 'zh' || lang === 'zh-cn' || lang === 'zh-sg') { + return chronoInstances['zh-hans'] + } + if (lang === 'zh-tw' || lang === 'zh-hk' || lang === 'zh-mo') { + return chronoInstances['zh-hant'] + } + // Try full locale, then base language, then fall back to English + const base = lang.split('-')[0] + return chronoInstances[lang] ?? chronoInstances[base] ?? chronoInstances.en +} + +/** + * @param {number} n number to pad + * @return {string} zero-padded two-digit string + */ +function pad(n) { + return String(n).padStart(2, '0') +} + +/** + * @param {number} hour hour (0-23) + * @param {number} minute minute (0-59) + * @return {string} formatted time string "HH:MM" + */ +function formatTimeParts(hour, minute) { + return `${pad(hour)}:${pad(minute)}` +} + +/** + * Parse natural language time expressions from an event title. + * + * Recognises clock times (2pm, 14:00), time ranges (10:00–14:00, 10am to 2pm), + * dates (23.07.26, July 23, 07/23/2026) and the literal "all day". + * + * @param {string|null} text Event title to examine + * @param {Date|null} referenceDate Reference date for forward-date resolution (defaults to now) + * @param {string|null} language Nextcloud language code, e.g. "de" or "zh_CN" (defaults to English) + * @return {object|null} Parsed suggestion or null when nothing was recognised + */ +export function parseNaturalLanguageTime(text, referenceDate = null, language = null) { + if (!text || text.trim() === '') { + return null + } + + // "all day" / "all-day" — chrono-node does not handle this phrase + const allDayMatch = text.match(/\ball[\s-]?day\b/i) + if (allDayMatch) { + return { + type: 'all-day', + displayText: 'All day', + matchedText: allDayMatch[0], + matchedIndex: allDayMatch.index, + } + } + + const ref = referenceDate ?? new Date() + const results = getChronoInstance(language).parse(text, ref, { forwardDate: true }) + + if (!results.length) { + return null + } + + const result = results[0] + const start = result.start + const end = result.end + + const startHourCertain = start.isCertain('hour') + // weekday covers relative expressions like "next Monday" / "this Friday" + const startDateCertain = start.isCertain('day') || start.isCertain('month') || start.isCertain('weekday') + + if (end && end.isCertain('hour')) { + // Time range: "10:00 – 14:00", "10am to 2pm" + const sh = start.get('hour') + const sm = start.get('minute') ?? 0 + const eh = end.get('hour') + const em = end.get('minute') ?? 0 + return { + type: 'time-range', + startHour: sh, + startMinute: sm, + endHour: eh, + endMinute: em, + displayText: `${formatTimeParts(sh, sm)} – ${formatTimeParts(eh, em)}`, + matchedText: result.text, + matchedIndex: result.index, + } + } + + if (startHourCertain && !startDateCertain) { + // Time only: "2pm", "14:00" + const sh = start.get('hour') + const sm = start.get('minute') ?? 0 + return { + type: 'time-only', + startHour: sh, + startMinute: sm, + displayText: formatTimeParts(sh, sm), + matchedText: result.text, + matchedIndex: result.index, + } + } + + const endDateCertain = end && (end.isCertain('day') || end.isCertain('month')) + + if (startDateCertain && endDateCertain && !startHourCertain) { + // Date range: "June 1 to June 3", "June 1st - June 3rd" + const sy = start.get('year') + const smo = start.get('month') + const sd = start.get('day') + const ey = end.get('year') + const emo = end.get('month') + const ed = end.get('day') + const fmt = { day: 'numeric', month: 'long', year: 'numeric' } + return { + type: 'date-range', + startYear: sy, + startMonth: smo, + startDay: sd, + endYear: ey, + endMonth: emo, + endDay: ed, + displayText: `${new Date(sy, smo - 1, sd).toLocaleDateString(undefined, fmt)} – ${new Date(ey, emo - 1, ed).toLocaleDateString(undefined, fmt)}`, + matchedText: result.text, + matchedIndex: result.index, + } + } + + if (startDateCertain && !startHourCertain) { + // Date only: "July 23", "23.07.26", "07/23/2026" + const y = start.get('year') + const mo = start.get('month') + const d = start.get('day') + return { + type: 'date', + year: y, + month: mo, + day: d, + displayText: new Date(y, mo - 1, d).toLocaleDateString(undefined, { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + matchedText: result.text, + matchedIndex: result.index, + } + } + + if (startDateCertain && startHourCertain) { + // Date + time: "July 23, 4pm", "14.08.2026 19:00" + const y = start.get('year') + const mo = start.get('month') + const d = start.get('day') + const sh = start.get('hour') + const sm = start.get('minute') ?? 0 + return { + type: 'datetime', + year: y, + month: mo, + day: d, + startHour: sh, + startMinute: sm, + displayText: `${new Date(y, mo - 1, d).toLocaleDateString(undefined, { + day: 'numeric', + month: 'long', + year: 'numeric', + })} ${formatTimeParts(sh, sm)}`, + matchedText: result.text, + matchedIndex: result.index, + } + } + + return null +} diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue index 2a08cc0e91..6496d19c57 100644 --- a/src/views/EditFull.vue +++ b/src/views/EditFull.vue @@ -40,10 +40,18 @@ - +
+ + + +
@@ -382,10 +390,12 @@ import PropertySelectMultiple from '../components/Editor/Properties/PropertySele import PropertyText from '../components/Editor/Properties/PropertyText.vue' import PropertyTitle from '../components/Editor/Properties/PropertyTitle.vue' import PropertyTitleTimePicker from '../components/Editor/Properties/PropertyTitleTimePicker.vue' +import TimeSuggestion from '../components/Editor/Properties/TimeSuggestion.vue' import Repeat from '../components/Editor/Repeat/Repeat.vue' import ResourceList from '../components/Editor/Resources/ResourceList.vue' import SaveButtons from '../components/Editor/SaveButtons.vue' import EditorMixin from '../mixins/EditorMixin.js' +import TimeSuggestionMixin from '../mixins/TimeSuggestionMixin.js' import { shareFile } from '../services/attachmentService.js' import getTimezoneManager from '../services/timezoneDataProviderService.js' import useCalendarObjectInstanceStore from '../store/calendarObjectInstance.js' @@ -426,6 +436,7 @@ export default { AttachmentsList, CalendarPickerHeader, PropertyTitle, + TimeSuggestion, IconVideo, HelpCircleIcon, NcActions, @@ -435,6 +446,7 @@ export default { mixins: [ EditorMixin, + TimeSuggestionMixin, ], data() { @@ -545,6 +557,12 @@ export default { }, }, + watch: { + calendarObjectInstance() { + this.timeSuggestion = null + }, + }, + mounted() { window.addEventListener('keydown', this.keyboardCloseEditor) window.addEventListener('keydown', this.keyboardSaveEvent) @@ -807,6 +825,7 @@ export default { this.toggleAllDay() }, + }, } diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue index 61102d50f2..4d08e105c9 100644 --- a/src/views/EditSimple.vue +++ b/src/views/EditSimple.vue @@ -129,11 +129,19 @@ :isViewedByAttendee="isViewedByOrganizer === false" @update:value="changeCalendar" /> - +
+ + + +
{{ $t('calendar', 'This event was cancelled') }} @@ -302,8 +310,10 @@ import PropertyText from '../components/Editor/Properties/PropertyText.vue' import PropertyTitle from '../components/Editor/Properties/PropertyTitle.vue' import PropertyTitleTimePicker from '../components/Editor/Properties/PropertyTitleTimePicker.vue' +import TimeSuggestion from '../components/Editor/Properties/TimeSuggestion.vue' import SaveButtons from '../components/Editor/SaveButtons.vue' import EditorMixin from '../mixins/EditorMixin.js' +import TimeSuggestionMixin from '../mixins/TimeSuggestionMixin.js' import useCalendarObjectInstanceStore from '../store/calendarObjectInstance.js' import useSettingsStore from '../store/settings.js' import useWidgetStore from '../store/widget.js' @@ -317,6 +327,7 @@ export default { PropertyText, PropertyTitleTimePicker, PropertyTitle, + TimeSuggestion, NcPopover, Actions, ActionButton, @@ -344,6 +355,7 @@ export default { mixins: [ EditorMixin, + TimeSuggestionMixin, ], props: { @@ -457,6 +469,7 @@ export default { this.hasAttendees = false this.hasAlarms = false this.isCancelled = false + this.timeSuggestion = null if (this.calendarObjectInstance) { if (typeof this.calendarObjectInstance.location === 'string' && this.calendarObjectInstance.location.trim() !== '') { @@ -824,6 +837,7 @@ export default { this.toggleAllDay() }, + }, } diff --git a/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js b/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js new file mode 100644 index 0000000000..906c71cacf --- /dev/null +++ b/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js @@ -0,0 +1,731 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { parseNaturalLanguageTime } from '../../../../src/services/naturalLanguageTimeParserService.js' + +// Fixed reference: Monday 15 June 2026, 09:00 local time +const REF = new Date(2026, 5, 15, 9, 0, 0) + +describe('services/naturalLanguageTimeParserService', () => { + + describe('null / empty input', () => { + it('returns null for null', () => { + expect(parseNaturalLanguageTime(null)).toBeNull() + }) + + it('returns null for empty string', () => { + expect(parseNaturalLanguageTime('')).toBeNull() + }) + + it('returns null for whitespace-only string', () => { + expect(parseNaturalLanguageTime(' ')).toBeNull() + }) + + it('returns null for plain title with no time', () => { + expect(parseNaturalLanguageTime('Team retrospective', REF)).toBeNull() + }) + }) + + describe('all-day detection', () => { + it('recognises "all day"', () => { + const result = parseNaturalLanguageTime('all day', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('all-day') + }) + + it('recognises "all-day"', () => { + const result = parseNaturalLanguageTime('all-day', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('all-day') + }) + + it('recognises "All Day" (case-insensitive)', () => { + expect(parseNaturalLanguageTime('All Day', REF).type).toBe('all-day') + }) + + it('finds "all day" embedded in a title', () => { + expect(parseNaturalLanguageTime('Birthday party all day', REF).type).toBe('all-day') + }) + + it('provides matchedText and matchedIndex for "all day"', () => { + const result = parseNaturalLanguageTime('Birthday party all day', REF) + expect(result.matchedText).toBe('all day') + expect(result.matchedIndex).toBe(15) + }) + }) + + describe('time-only detection', () => { + it('parses 12-hour time "2pm"', () => { + const result = parseNaturalLanguageTime('2pm', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + expect(result.startMinute).toBe(0) + }) + + it('parses 12-hour time "2 pm"', () => { + const result = parseNaturalLanguageTime('2 pm', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + }) + + it('parses 12-hour time with minutes "2:30pm"', () => { + const result = parseNaturalLanguageTime('2:30pm', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + expect(result.startMinute).toBe(30) + }) + + it('parses AM time "10am"', () => { + const result = parseNaturalLanguageTime('10am', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(10) + expect(result.startMinute).toBe(0) + }) + + it('parses midnight "12am"', () => { + const result = parseNaturalLanguageTime('12am', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(0) + }) + + it('parses noon "12pm"', () => { + const result = parseNaturalLanguageTime('12pm', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(12) + }) + + it('parses 24-hour time "14:00"', () => { + const result = parseNaturalLanguageTime('14:00', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + expect(result.startMinute).toBe(0) + }) + + it('parses 24-hour time "19:00"', () => { + const result = parseNaturalLanguageTime('19:00', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(19) + }) + + it('parses 24-hour time with minutes "09:30"', () => { + const result = parseNaturalLanguageTime('09:30', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(9) + expect(result.startMinute).toBe(30) + }) + + it('picks up time embedded in a title', () => { + const result = parseNaturalLanguageTime('Team standup 9am', REF) + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(9) + }) + + it('includes formatted displayText', () => { + const result = parseNaturalLanguageTime('14:00', REF) + expect(result.displayText).toBe('14:00') + }) + + it('provides matchedText and matchedIndex for embedded time', () => { + const result = parseNaturalLanguageTime('Team standup 9am', REF) + expect(result.matchedText).toBe('9am') + expect(result.matchedIndex).toBe(13) + }) + }) + + describe('time-range detection', () => { + it('parses "10:00 - 14:00"', () => { + const result = parseNaturalLanguageTime('10:00 - 14:00', REF) + expect(result.type).toBe('time-range') + expect(result.startHour).toBe(10) + expect(result.startMinute).toBe(0) + expect(result.endHour).toBe(14) + expect(result.endMinute).toBe(0) + }) + + it('parses "10am to 2pm"', () => { + const result = parseNaturalLanguageTime('10am to 2pm', REF) + expect(result.type).toBe('time-range') + expect(result.startHour).toBe(10) + expect(result.endHour).toBe(14) + }) + + it('parses "9:00 - 10:30" with non-zero minutes', () => { + const result = parseNaturalLanguageTime('9:00 - 10:30', REF) + expect(result.type).toBe('time-range') + expect(result.startHour).toBe(9) + expect(result.startMinute).toBe(0) + expect(result.endHour).toBe(10) + expect(result.endMinute).toBe(30) + }) + + it('parses ranges embedded in a title', () => { + const result = parseNaturalLanguageTime('Workshop 10am - 1pm', REF) + expect(result.type).toBe('time-range') + expect(result.startHour).toBe(10) + expect(result.endHour).toBe(13) + }) + + it('includes both times in displayText', () => { + const result = parseNaturalLanguageTime('10:00 - 14:00', REF) + expect(result.displayText).toContain('10:00') + expect(result.displayText).toContain('14:00') + }) + + it('provides matchedText and matchedIndex for a range', () => { + const result = parseNaturalLanguageTime('Workshop 10:00 - 14:00', REF) + expect(result.matchedText).toBe('10:00 - 14:00') + expect(result.matchedIndex).toBe(9) + }) + }) + + describe('date detection', () => { + it('parses European format "23.07.26"', () => { + const result = parseNaturalLanguageTime('23.07.26', REF) + expect(result.type).toBe('date') + expect(result.year).toBe(2026) + expect(result.month).toBe(7) + expect(result.day).toBe(23) + }) + + it('parses European format with full year "23.07.2026"', () => { + const result = parseNaturalLanguageTime('23.07.2026', REF) + expect(result.type).toBe('date') + expect(result.year).toBe(2026) + expect(result.month).toBe(7) + expect(result.day).toBe(23) + }) + + it('parses US slash format "07/23/2026"', () => { + const result = parseNaturalLanguageTime('07/23/2026', REF) + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + expect(result.year).toBe(2026) + }) + + it('parses month-name format "July 23"', () => { + const result = parseNaturalLanguageTime('July 23', REF) + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + }) + + it('parses month-name with year "July 23 2026"', () => { + const result = parseNaturalLanguageTime('July 23 2026', REF) + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + expect(result.year).toBe(2026) + }) + + it('parses abbreviated month "Sep 4"', () => { + const result = parseNaturalLanguageTime('Sep 4', REF) + expect(result.type).toBe('date') + expect(result.month).toBe(9) + expect(result.day).toBe(4) + }) + + it('picks up date embedded in a title', () => { + const result = parseNaturalLanguageTime('Flight 23.07.26', REF) + expect(result.type).toBe('date') + expect(result.day).toBe(23) + expect(result.month).toBe(7) + }) + + it('provides matchedText and matchedIndex for an embedded date', () => { + const result = parseNaturalLanguageTime('Flight 23.07.26', REF) + expect(result.matchedText).toBe('23.07.26') + expect(result.matchedIndex).toBe(7) + }) + }) + + describe('title stripping via matchedText / matchedIndex', () => { + function stripMatch(title, result) { + const before = title.slice(0, result.matchedIndex) + const after = title.slice(result.matchedIndex + result.matchedText.length) + return (before + after).replace(/\s+/g, ' ').trim() + } + + function parseWithStrip(title) { + const result = parseNaturalLanguageTime(title, REF) + if (!result) return null + result.strippedTitle = stripMatch(title, result) + return result + } + + it('strips standalone "1pm" leaving an empty title', () => { + const r = parseWithStrip('1pm') + expect(r.type).toBe('time-only') + expect(r.matchedText).toBe('1pm') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('') + }) + + it('strips trailing "1pm" — "Meeting 1pm"', () => { + const r = parseWithStrip('Meeting 1pm') + expect(r.matchedText).toBe('1pm') + expect(r.matchedIndex).toBe(8) + expect(r.strippedTitle).toBe('Meeting') + }) + + it('strips leading "1pm" — "1pm dentist"', () => { + const r = parseWithStrip('1pm dentist') + expect(r.matchedText).toBe('1pm') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('dentist') + }) + + it('strips a trailing time — "Team standup 9am"', () => { + const r = parseWithStrip('Team standup 9am') + expect(r.matchedText).toBe('9am') + expect(r.matchedIndex).toBe(13) + expect(r.strippedTitle).toBe('Team standup') + }) + + it('strips a leading time — "9am Team standup"', () => { + const r = parseWithStrip('9am Team standup') + expect(r.matchedText).toBe('9am') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('Team standup') + }) + + it('strips a mid-title time — "Lunch 12:00 with Anna"', () => { + const r = parseWithStrip('Lunch 12:00 with Anna') + expect(r.matchedText).toBe('12:00') + expect(r.matchedIndex).toBe(6) + expect(r.strippedTitle).toBe('Lunch with Anna') + }) + + it('strips a trailing time range — "Workshop 10:00 - 14:00"', () => { + const r = parseWithStrip('Workshop 10:00 - 14:00') + expect(r.matchedText).toBe('10:00 - 14:00') + expect(r.matchedIndex).toBe(9) + expect(r.strippedTitle).toBe('Workshop') + }) + + it('strips a mid-title time range — "Workshop 10:00 - 14:00 venue TBD"', () => { + const r = parseWithStrip('Workshop 10:00 - 14:00 venue TBD') + expect(r.matchedIndex).toBe(9) + expect(r.strippedTitle).toBe('Workshop venue TBD') + }) + + it('strips a leading time range — "10am to 2pm team sync"', () => { + const r = parseWithStrip('10am to 2pm team sync') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('team sync') + }) + + it('strips trailing "all day" — "Birthday party all day"', () => { + const r = parseWithStrip('Birthday party all day') + expect(r.matchedText).toBe('all day') + expect(r.matchedIndex).toBe(15) + expect(r.strippedTitle).toBe('Birthday party') + }) + + it('strips leading "all-day" — "all-day conference"', () => { + const r = parseWithStrip('all-day conference') + expect(r.matchedText).toBe('all-day') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('conference') + }) + + it('strips a trailing European date — "Flight 23.07.26"', () => { + const r = parseWithStrip('Flight 23.07.26') + expect(r.matchedText).toBe('23.07.26') + expect(r.matchedIndex).toBe(7) + expect(r.strippedTitle).toBe('Flight') + }) + + it('strips a leading European date — "23.07.26 dentist"', () => { + const r = parseWithStrip('23.07.26 dentist') + expect(r.matchedText).toBe('23.07.26') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('dentist') + }) + + it('strips a month-name date — "July 23 dentist"', () => { + const r = parseWithStrip('July 23 dentist') + expect(r.matchedIndex).toBe(0) + expect(r.strippedTitle).toBe('dentist') + }) + + it('strips datetime from title — "Dentist July 23, 4pm downtown"', () => { + const r = parseWithStrip('Dentist July 23, 4pm downtown') + expect(r.type).toBe('datetime') + expect(r.strippedTitle).toBe('Dentist downtown') + }) + }) + + describe('datetime detection (date + time combined)', () => { + it('parses "July 23, 4pm"', () => { + const result = parseNaturalLanguageTime('July 23, 4pm', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('datetime') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + expect(result.startHour).toBe(16) + expect(result.startMinute).toBe(0) + }) + + it('parses "July 23 2026 4pm"', () => { + const result = parseNaturalLanguageTime('July 23 2026 4pm', REF) + expect(result.type).toBe('datetime') + expect(result.year).toBe(2026) + expect(result.month).toBe(7) + expect(result.day).toBe(23) + expect(result.startHour).toBe(16) + }) + + it('parses "14.08.2026 19:00"', () => { + const result = parseNaturalLanguageTime('14.08.2026 19:00', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('datetime') + expect(result.year).toBe(2026) + expect(result.month).toBe(8) + expect(result.day).toBe(14) + expect(result.startHour).toBe(19) + expect(result.startMinute).toBe(0) + }) + + it('parses datetime embedded in a title — "Dentist July 23 4pm downtown"', () => { + const result = parseNaturalLanguageTime('Dentist July 23 4pm downtown', REF) + expect(result.type).toBe('datetime') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + expect(result.startHour).toBe(16) + }) + + it('parses European date + 12h time without colon "12.07.2026 9am"', () => { + const result = parseNaturalLanguageTime('12.07.2026 9am', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('datetime') + expect(result.year).toBe(2026) + expect(result.month).toBe(7) + expect(result.day).toBe(12) + expect(result.startHour).toBe(9) + expect(result.startMinute).toBe(0) + }) + + it('parses European short year + 12h time "12.07.26 9am"', () => { + const result = parseNaturalLanguageTime('12.07.26 9am', REF) + expect(result.type).toBe('datetime') + expect(result.month).toBe(7) + expect(result.day).toBe(12) + expect(result.startHour).toBe(9) + }) + + it('does not misread "12.07.2026" as December 7 when day > 12 is not possible', () => { + // Regression: built-in parser would interpret 12.07 as month=12 day=7 + const result = parseNaturalLanguageTime('12.07.2026', REF) + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(12) + }) + + it('includes date and time in displayText', () => { + const result = parseNaturalLanguageTime('July 23, 4pm', REF) + expect(result.displayText).toContain('16:00') + }) + + it('provides matchedText and matchedIndex for "July 23, 4pm"', () => { + const result = parseNaturalLanguageTime('Meeting July 23, 4pm', REF) + expect(result.matchedIndex).toBe(8) + expect(result.matchedText).toBe('July 23, 4pm') + }) + + it('strips datetime from title correctly', () => { + const title = 'Dentist July 23, 4pm downtown' + const result = parseNaturalLanguageTime(title, REF) + const before = title.slice(0, result.matchedIndex) + const after = title.slice(result.matchedIndex + result.matchedText.length) + expect((before + after).replace(/\s+/g, ' ').trim()).toBe('Dentist downtown') + }) + }) + + describe('date-range detection', () => { + it('parses "June 1st to June 3rd"', () => { + const result = parseNaturalLanguageTime('June 1st to June 3rd', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date-range') + expect(result.startMonth).toBe(6) + expect(result.startDay).toBe(1) + expect(result.endMonth).toBe(6) + expect(result.endDay).toBe(3) + }) + + it('parses "June 1 to June 3"', () => { + const result = parseNaturalLanguageTime('June 1 to June 3', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date-range') + expect(result.startDay).toBe(1) + expect(result.endDay).toBe(3) + }) + + it('parses "June 1st - June 3rd"', () => { + const result = parseNaturalLanguageTime('June 1st - June 3rd', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date-range') + expect(result.startDay).toBe(1) + expect(result.endDay).toBe(3) + }) + + it('parses "June 1-3" (short form)', () => { + const result = parseNaturalLanguageTime('June 1-3', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date-range') + expect(result.startDay).toBe(1) + expect(result.endDay).toBe(3) + }) + + it('includes both dates in displayText', () => { + const result = parseNaturalLanguageTime('June 20 to June 25', REF) + expect(result.displayText).toContain('20') + expect(result.displayText).toContain('25') + }) + + it('provides matchedText and matchedIndex', () => { + const result = parseNaturalLanguageTime('Conference June 1st to June 3rd downtown', REF) + expect(result.matchedText).toBe('June 1st to June 3rd') + expect(result.matchedIndex).toBe(11) + }) + + it('strips a date range from a title', () => { + const result = parseNaturalLanguageTime('Conference June 1st to June 3rd', REF) + const before = 'Conference June 1st to June 3rd'.slice(0, result.matchedIndex) + const after = 'Conference June 1st to June 3rd'.slice(result.matchedIndex + result.matchedText.length) + const stripped = (before + after).replace(/\s+/g, ' ').trim() + expect(stripped).toBe('Conference') + }) + }) + + describe('relative date/time expressions', () => { + it('parses "tomorrow" as an all-day date', () => { + const result = parseNaturalLanguageTime('tomorrow', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.day).toBe(16) + expect(result.month).toBe(6) + expect(result.year).toBe(2026) + }) + + it('parses "in 3 days" as a date', () => { + const result = parseNaturalLanguageTime('in 3 days', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.day).toBe(18) + expect(result.month).toBe(6) + }) + + it('parses "next week" as a date', () => { + const result = parseNaturalLanguageTime('next week', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date') + }) + + it('parses "next Monday" as a date', () => { + const result = parseNaturalLanguageTime('next Monday', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.day).toBe(22) + expect(result.month).toBe(6) + }) + + it('parses "this Friday" as a date', () => { + const result = parseNaturalLanguageTime('this Friday', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.day).toBe(19) + expect(result.month).toBe(6) + }) + + it('parses "tomorrow 3pm" as datetime', () => { + const result = parseNaturalLanguageTime('tomorrow 3pm', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('datetime') + expect(result.day).toBe(16) + expect(result.startHour).toBe(15) + }) + + it('parses "next Friday 14:00" as datetime (not time-only)', () => { + const result = parseNaturalLanguageTime('next Friday 14:00', REF) + expect(result).not.toBeNull() + expect(result.type).toBe('datetime') + expect(result.day).toBe(26) + expect(result.startHour).toBe(14) + }) + + it('strips "tomorrow" from title', () => { + const result = parseNaturalLanguageTime('tomorrow', REF) + const before = 'tomorrow'.slice(0, result.matchedIndex) + const after = 'tomorrow'.slice(result.matchedIndex + result.matchedText.length) + expect((before + after).replace(/\s+/g, ' ').trim()).toBe('') + }) + + it('strips "next Monday" from a title', () => { + const result = parseNaturalLanguageTime('Team lunch next Monday', REF) + const before = 'Team lunch next Monday'.slice(0, result.matchedIndex) + const after = 'Team lunch next Monday'.slice(result.matchedIndex + result.matchedText.length) + expect((before + after).replace(/\s+/g, ' ').trim()).toBe('Team lunch') + }) + }) + + describe('multi-language support', () => { + describe('German (de)', () => { + it('parses "14 Uhr" as time-only', () => { + const result = parseNaturalLanguageTime('14 Uhr', REF, 'de') + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + }) + + it('parses "15. Juli" as date', () => { + const result = parseNaturalLanguageTime('15. Juli', REF, 'de') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + + it('parses "20. Juni bis 22. Juni" as date-range', () => { + const result = parseNaturalLanguageTime('20. Juni bis 22. Juni', REF, 'de') + expect(result).not.toBeNull() + expect(result.type).toBe('date-range') + expect(result.startDay).toBe(20) + expect(result.endDay).toBe(22) + }) + + it('parses European date "23.07.2026" via de locale', () => { + const result = parseNaturalLanguageTime('23.07.2026', REF, 'de') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(23) + }) + + it('accepts regional code "de_DE"', () => { + const result = parseNaturalLanguageTime('14 Uhr', REF, 'de_DE') + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + }) + }) + + describe('French (fr)', () => { + it('parses "14h30" as time-only', () => { + const result = parseNaturalLanguageTime('14h30', REF, 'fr') + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + expect(result.startMinute).toBe(30) + }) + + it('parses "15 juillet" as date', () => { + const result = parseNaturalLanguageTime('15 juillet', REF, 'fr') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Spanish (es)', () => { + it('parses "15 de julio" as date', () => { + const result = parseNaturalLanguageTime('15 de julio', REF, 'es') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Italian (it)', () => { + it('parses "15 luglio" as date', () => { + const result = parseNaturalLanguageTime('15 luglio', REF, 'it') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Dutch (nl)', () => { + it('parses "15 juli" as date', () => { + const result = parseNaturalLanguageTime('15 juli', REF, 'nl') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Russian (ru)', () => { + it('parses "15 июля" as date', () => { + const result = parseNaturalLanguageTime('15 июля', REF, 'ru') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Portuguese (pt)', () => { + it('parses "15 julho" as date', () => { + const result = parseNaturalLanguageTime('15 julho', REF, 'pt') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Swedish (sv)', () => { + it('parses "15 juli" as date', () => { + const result = parseNaturalLanguageTime('15 juli', REF, 'sv') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('Chinese Simplified (zh_CN)', () => { + it('parses "7月15日" as date', () => { + const result = parseNaturalLanguageTime('7月15日', REF, 'zh_CN') + expect(result).not.toBeNull() + expect(result.type).toBe('date') + expect(result.month).toBe(7) + expect(result.day).toBe(15) + }) + }) + + describe('language fallback', () => { + it('falls back to English for an unsupported language', () => { + const result = parseNaturalLanguageTime('2pm', REF, 'ar') + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + }) + + it('falls back to English when language is null', () => { + const result = parseNaturalLanguageTime('2pm', REF, null) + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + }) + }) + }) + + describe('edge cases', () => { + it('returns null when there is no recognisable pattern', () => { + expect(parseNaturalLanguageTime('Quarterly review v2', REF)).toBeNull() + }) + + it('works without a reference date argument', () => { + const result = parseNaturalLanguageTime('14:00') + expect(result).not.toBeNull() + expect(result.type).toBe('time-only') + expect(result.startHour).toBe(14) + }) + }) +})