Skip to content

Add a natural-language quick-entry field to the New Event popover - #310

Open
scouttyg wants to merge 16 commits into
sfsam:masterfrom
scouttyg:natural-language-support-pr
Open

Add a natural-language quick-entry field to the New Event popover#310
scouttyg wants to merge 16 commits into
sfsam:masterfrom
scouttyg:natural-language-support-pr

Conversation

@scouttyg

@scouttyg scouttyg commented Jul 27, 2026

Copy link
Copy Markdown

Summary

This PR adds a single-line text field to the top of the New Event popover where you can type an event and Itsycal will fill in the title, date, time, duration, location, and repeat fields for you. E.g.:

Meeting with Bob at Cafe Luna for 30 minutes this Friday at 2pm

Fills in:

  • Title: Meeting with Bob
  • Date: Friday, at 2:00 PM
  • Duration: 30 minutes
  • Location: Cafe Luna

I believe this also covers #193 's issue as well.

As you type, recognized pieces of the phrase are underlined in the field with a color per field type (date, duration, location, repeat), and hovering over an underline shows a small tooltip explaining what was recognized and what it resolved to — so you can tell at a glance whether Itsycal understood you correctly.

demo.mp4
Screenshot 2026-07-27 at 3 16 52 PM Screenshot 2026-07-27 at 3 16 47 PM

What it recognizes

  • Date & time — mostly delegated to NSDataDetector, which already handles the bulk of natural date/time phrases ("tomorrow", "next Tuesday", "March 3rd", "at 6pm", "in 3 days", etc.) via the system's own locale-aware recognition.
  • Relative periods NSDataDetector doesn't cover — verified empirically that it has no support at all for bare phrases like "next week", "next month", or "next year", even standalone. A small custom detector fills this gap and additionally recognizes "in N weeks/months/years" and "N weeks/months/years from now" (digits or spelled out, e.g. "two months from now"), combining with a nearby explicit time on either side ("next week at 2pm" or "at 2pm next week").
  • Duration — "for 30 minutes", "for an hour", "for half an hour", "for 2 hours".
  • Location — "at Cafe Luna", "in Conference Room B", or the shorthand "@cafe Luna".
  • Repeat — "every day", "daily", "every week", "every other week", "biweekly", "every month", "every year", etc.

Recognized text is stripped out of what becomes the event title, so the title stays clean rather than repeating the date/time/location/repeat phrase verbatim.

How it works

  • Masked-text pipeline (EventQuickEntryParser) — each detector (date, duration, location, repeat) runs in turn over a mutable copy of the input. When a detector finds a match, that range is blanked out (replaced with equal-length spaces) before the next detector runs, so later detectors never re-match text an earlier one already claimed, and all recognized ranges stay valid for underlining. Whatever's left over after all four passes, collapsed and trimmed, becomes the title.
  • Language packs (EventQuickEntryLanguagePack protocol) — the matching logic is fixed; the words are not. EventQuickEntryKeywordLanguagePack is a generic implementation of the protocol driven entirely by an EventQuickEntryKeywords config object (word lists, phrase templates, tooltip labels). Adding a new language means adding a .strings file.
  • Localized .strings resources — keyword data lives in Base.lproj/EventQuickEntryKeywords.strings and es.lproj/EventQuickEntryKeywords.strings, following the project's existing localization convention (same shape as Localizable.strings, discovered via NSBundle's standard localized-resource lookup). English and Spanish are included; Spanish's keyword choices were verified against real NSDataDetector output where relevant (documented inline in that file, including a few deliberate omissions where NSDataDetector's Spanish behavior didn't match the naive translation).
  • Custom tooltip window — the field's hover tooltips use a small custom NSPanel (mirroring the existing MoCalToolTipWC pattern already in the app) rather than AppKit's native tooltip, because ItsycalWindow runs at NSMainMenuWindowLevel, which sits above where native tooltips render.

Extending to a new language

Add <lang>.lproj/EventQuickEntryKeywords.strings with the same keys as the English/Spanish files (word lists are pipe-delimited: "word1|word2"). No code changes required — EventQuickEntryLanguagePackRegistry picks it up automatically based on the app's active language.

Known limitations

  • Date/time recognition beyond the relative-period detector is only as good as NSDataDetector's own parsing — genuinely ambiguous or unusual phrasings may not resolve the way you'd expect.
  • If a relative-period phrase (e.g. "next week") appears early in the input, the custom detector claims it immediately and doesn't give NSDataDetector a chance to additionally interpret anything after it in the same breath (e.g. "next week Tuesday" resolves to "next week", not "the Tuesday of next week").
  • Minute counts are never singularized ("1 minutes", not "1 minute") — a pre-existing simplification, not something introduced here.

Other notes

  • I used Claude Code to help structure and build this feature -- that being said, I made sure to try to match existing patterns / formatting / etc so that the code would feel natural.

scouttyg added 16 commits July 27, 2026 12:42
…on/recurrence parsing

Parses free-form quick-entry text into title/date/time/duration/location/recurrence via NSDataDetector plus custom regexes, using a masked-text technique so each detection pass never re-matches text already claimed by an earlier pass.

Includes an ItsycalTests target (none existed previously) and 37 unit tests covering each detector, span/highlight metadata, and several NSDataDetector edge cases (dangling prepositions, meal-word references, implied durations from time ranges).
Adds a quick-entry text field above Title in the New Event form. Typing a phrase debounces into a parse via EventQuickEntryParser and applies the result onto the existing form controls, only overwriting a field when the parser actually found a value for it.

Recognized spans are underlined and color-coded by type, with a hover tooltip explaining what was understood — using a custom tooltip window rather than AppKit's native tooltip mechanism, since native tooltips render at a level too low to appear above this app's own NSMainMenuWindowLevel window (see ItsycalWindow.m).

The field is only shown when the app's active language resolves to English, since the parser's keyword/date matching is English-only.
…acks

Replaces the hardcoded English-only regexes in EventQuickEntryParser with an EventQuickEntryLanguagePack protocol, so a new language can be added without touching the parser's core logic.

EventQuickEntryKeywordLanguagePack is a generic protocol implementation driven by an EventQuickEntryKeywords config (duration/location/recurrence words, dangling prepositions, meal words, explicit-time words, tooltip labels, a placeholder example) - most languages only need to supply words, not new matching logic. A language whose grammar doesn't fit this shape can implement the protocol directly instead.

EventQuickEntryEnglishLanguagePack and EventQuickEntrySpanishLanguagePack prove the abstraction. The English pack's keywords are transcribed directly from the prior hardcoded regexes, and all 37 pre-existing tests pass completely unchanged against the refactored implementation - the primary regression guardrail for this change. The Spanish pack's keyword choices (dangling articles, meal words, explicit-time words) are grounded in direct NSDataDetector probing, not assumption, but are a best-effort translation, not reviewed by a native speaker.

EventQuickEntryLanguagePackRegistry resolves a language code to a pack (or nil); EventViewController's locale gate now asks the registry instead of hardcoding an English-only check, and the quick-entry field's placeholder text is sourced from the active pack instead of being hardcoded English.

13 new tests: 3 for the registry, 10 for the Spanish pack.
…ect's existing localization convention

Replaces the hardcoded EventQuickEntryEnglishLanguagePack/EventQuickEntrySpanishLanguagePack Objective-C classes with EventQuickEntryKeywords.strings files in Base.lproj (English, the project's development-language folder) and es.lproj - the same localized-resource-variant mechanism (PBXVariantGroup) already used for Localizable.strings/InfoPlist.strings/MainMenu.xib, not a new one.

Array-valued keywords are pipe-delimited single entries; the two recurrence dictionaries are flattened into recurrencePhrases.<frequency> / recurrenceLabel.<frequency> entries. EventQuickEntryLanguagePackRegistry now resolves a language by looking up this resource via -[NSBundle URLForResource:withExtension:subdirectory:localization:] instead of a hardcoded if/else over class names.

This was the actual point of the whole exercise: adding a new keyword-based language now means adding one <code>.lproj/EventQuickEntryKeywords.strings file - the same workflow a translator already uses for this project - not writing or compiling any Objective-C.

Required adding a Resources build phase to the ItsycalTests target, which had none before (it only needed Sources/Frameworks). All 51 tests pass against the real bundle-resource-loading path, not a mock.
An empty array-valued keyword (e.g. oneHourPhrases left blank by a future contributor's incomplete .strings file) previously turned into an empty regex alternation group (?:), which matches the empty string at *any* position rather than nowhere. Verified empirically before fixing: an empty oneHourPhrases made any phrase containing "for " match as a 60-minute duration; an empty recurrence phrase list for any frequency made every quick-entry phrase match that recurrence, even "Buy groceries".

+alternationPatternForPhrases: now returns nil (not an empty string) for an empty list, and every call site explicitly handles that: the time/location regexes omit the affected branch entirely, the duration regex substitutes "(?!)" (never matches) to preserve its capture-group numbering, and the recurrence builder skips that frequency's regex, keeping its three parallel arrays in sync by construction instead of leaving index gaps.

Also fixes a stale comment left over from the plist to .strings migration, and documents that hourUnitWords/minuteUnitWords array order is load-bearing for display grammar (index 0 = plural, index 1 = singular), which wasn't previously stated anywhere.

3 new regression tests construct a deliberately incomplete EventQuickEntryKeywords directly to lock in the fix.
…g project conventions

EventQuickEntryLanguagePack, EventQuickEntryKeywords, EventQuickEntryKeywordLanguagePack, and EventQuickEntryLanguagePackRegistry all previously lived inside EventQuickEntryParser.h/.m. Split into their own files, following the project's actual precedent rather than my own judgment call:

- MoCalCell/MoCalGrid/MoCalendar/MoCalToolTipWC/MoCalResizeHandle and PrefsVC/PrefsGeneralVC/PrefsAppearanceVC/PrefsAboutVC are each separate file pairs, grouped under a virtual (name-only, no path) Xcode group - not crammed into one file, even though each set is a single subsystem.
- MoCalTooltipProvider.h exists with no .m - a pure protocol declaration, exactly EventQuickEntryLanguagePack's shape.

Result: EventQuickEntryParser.h/.m now hold only EventQuickEntrySpan/EventQuickEntryResult/EventQuickEntryParser (the orchestrator); EventQuickEntryLanguagePack.h is protocol-only; EventQuickEntryKeywords.h/.m holds the config class plus the generic EventQuickEntryKeywordLanguagePack it drives; EventQuickEntryLanguagePackRegistry.h/.m holds the lookup. All five files (plus the existing EventQuickEntryKeywords.strings resource) are grouped under a new virtual "EventQuickEntry" Xcode group, mirroring "MoCalendar"/"Prefs".

No behavior change. 54/54 tests pass; both Keywords.m and LanguagePackRegistry.m compile into both the app and test targets, same dual-compilation pattern EventQuickEntryParser.m already used.
This project has no existing test infrastructure. Adding an XCTest target is a separate decision the maintainer should get to make deliberately, not something to bundle into a feature PR - so this branch drops the ItsycalTests target and its test file entirely.

The feature code (EventQuickEntryParser, the language pack architecture, EventViewController's quick-entry wiring) is unaffected; the test target never contained anything besides its own test file and duplicate Sources/Resources entries for files also compiled into the app target directly.

The full branch with all 54 tests remains on natural-language-support for local reference.
…ds.h

Matches the existing spacing convention in EventCenter.h.
- Collapse EventQuickEntrySpan's implementation to one line, matching
  EventCenter.m's CalendarInfo/EventInfo pattern for property-only classes.
- Convert EventQuickEntryKeywords.m's section marks to the two-line
  '#pragma mark -' / '#pragma mark Name' form used throughout
  AgendaViewController.m, ItsycalWindow.m, MoCalToolTipWC.m, and
  PrefsGeneralVC.m.
…ntion

mowglii never wraps method declarations across multiple lines regardless
of length (confirmed no colon-aligned multi-line signatures anywhere in
the existing codebase, including 100+ char ones).
The location regex ended in a consuming \s*$, which folded any
trailing whitespace into the match — including the equal-length spaces
left by an already-blanked duration span, since duration is parsed
before location. That inflated the location span's range far past its
actual text, overlapping the duration span and rendering as a single
merged underline instead of two separate ones. Switched to a zero-width
lookahead (?=\s*$) so the same 'nothing but whitespace follows'
requirement holds without consuming those characters into the match.
The previous fix (a zero-width lookahead requiring nothing but
whitespace to the end of the string) handled a location followed only
by an already-blanked span, but broke again once any genuinely
unrecognized text trailed it too — e.g. 'Meeting with Bob at Cafe Luna
for 15 minutes at 2pm next week', where NSDataDetector only recognizes
'2pm' and leaves 'next week' unblanked. The regex had no way to
distinguish 'more location text' from a blanked span's leftover spaces
using `masked` alone, so it kept expanding to try to reach the string's
end and pulled in everything up to and including that trailing text.

Location matching now walks forward manually from the prefix word,
comparing `masked` against `original` (added as a new parameter to
the protocol method, mirroring dateSpanInMasked's existing `original`
parameter) to find exactly where an earlier detector's blanking begins
— the one distinction a regex can't express on its own. A trailing
dangling preposition immediately before that boundary (e.g. 'Cafe Luna
at' before a blanked 'noon') is still trimmed out of the location value
and folded into the blanked range, preserving the existing
disambiguation behavior.
Probed NSDataDetector directly: it resolves day-of-week-based phrases
('next Tuesday') and day-count phrases ('in 3 days', 'tomorrow') fine,
but returns no match whatsoever for bare relative-period phrases like
'next week' or 'next month', even standalone — confirmed this is why
'Meeting ... at 2pm next week' only ever picked up '2pm' and silently
dropped 'next week'.

Adds a small keyword-driven detector (nextWeekPhrase/nextMonthPhrase/
relativeWeeksPrefixWord/weekUnitWords) that runs before NSDataDetector
gets a chance, covering 'next week', 'next month', and 'in N weeks'.
Since these phrases carry no time of their own, it separately looks
for an explicit time phrase immediately before or after (e.g. 'next
week at 2pm' or 'at 2pm next week') and combines them, resolving the
isolated time phrase via NSDataDetector directly since that part it
already handles correctly on its own.

Left as a known, documented limitation: phrases further from this
mechanism's coverage (e.g. 'in 2 months', 'next year', '3 weeks from
now') still fall through to NSDataDetector's existing gaps.
Generalizes the previous next-week/next-month/in-N-weeks detector to
all three units symmetrically: 'next week/month/year', 'in N
weeks/months/years', and the equivalent 'N weeks/months/years from
now' suffix form. Renamed relativeWeeksPrefixWord to
relativePeriodPrefixWord since it's now shared across all three units,
and added relativePeriodFromNowSuffix alongside it.

Also recognizes spelled-out counts ('two weeks from now', not just
'2 weeks from now') via a new numberWords keyword list, whose position
(not the word itself) carries the value — digit strings are still
always recognized regardless of what's configured there.
The gap check between a relative-period phrase and a nearby explicit
time required a single word with no internal whitespace, which fit
English 'at' but rejected Spanish's two-word 'a las' — so the time
never combined and leaked straight into the title instead. The length
cap alone is sufficient to bound false positives; the single-word
requirement wasn't needed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant