Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
65ef87b
Add EventQuickEntryParser: natural-language date/time/duration/locati…
scouttyg Jul 27, 2026
116a15c
Wire quick-entry field into the New Event popover
scouttyg Jul 27, 2026
e6fab9e
Add extensible language pack architecture, with English and Spanish p…
scouttyg Jul 27, 2026
9701839
Move language pack data into .lproj resource files, matching the proj…
scouttyg Jul 27, 2026
afb6aaf
Fix empty-keyword-array false positives; add missing doc comments
scouttyg Jul 27, 2026
d40c9c9
Add file headers matching the project's convention
scouttyg Jul 27, 2026
959806b
Split EventQuickEntryParser into one file per class, matching existin…
scouttyg Jul 27, 2026
4fb129c
Remove ItsycalTests target for this PR
scouttyg Jul 27, 2026
87ac443
Add blank lines between commented properties in EventQuickEntryKeywor…
scouttyg Jul 27, 2026
b64f0b9
Match mowglii's pragma mark and trivial-class conventions
scouttyg Jul 27, 2026
578bea4
Un-wrap dateSpanInMasked: signature to match project's one-line conve…
scouttyg Jul 27, 2026
eb9217f
Fix location span swallowing trailing blanked spans into its range
scouttyg Jul 27, 2026
d0fc736
Fix location swallowing trailing text it never actually recognized
scouttyg Jul 27, 2026
2b0e807
Add custom detection for relative-period phrases NSDataDetector misses
scouttyg Jul 27, 2026
e4654c7
Extend relative-period detection to years and the 'from now' suffix form
scouttyg Jul 27, 2026
0315c45
Fix relative-period time connector rejecting multi-word connectors
scouttyg Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions Itsycal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
929223EB1E2D6E0900F1A119 /* HighlightPicker.m in Sources */ = {isa = PBXBuildFile; fileRef = 929223EA1E2D6E0800F1A119 /* HighlightPicker.m */; };
92B28CCF210BD17500485AE2 /* Sizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 92B28CCE210BD17500485AE2 /* Sizer.m */; };
92CDDE7B1A955D7F00D7675C /* AgendaViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 92CDDE7A1A955D7F00D7675C /* AgendaViewController.m */; };
B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; };
B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; };
B0B2E0001E00000AAA100000 /* EventQuickEntryKeywords.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */; };
B0B2E0002000000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -171,6 +175,15 @@
B0A100012F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/MainMenu.strings; sourceTree = "<group>"; };
B0A100022F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
B0A100032F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryParser.h; sourceTree = "<group>"; };
B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryParser.m; sourceTree = "<group>"; };
B0B2E0001900000AAA100000 /* EventQuickEntryLanguagePack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryLanguagePack.h; sourceTree = "<group>"; };
B0B2E0001A00000AAA100000 /* EventQuickEntryKeywords.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryKeywords.h; sourceTree = "<group>"; };
B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryKeywords.m; sourceTree = "<group>"; };
B0B2E0001C00000AAA100000 /* EventQuickEntryLanguagePackRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryLanguagePackRegistry.h; sourceTree = "<group>"; };
B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryLanguagePackRegistry.m; sourceTree = "<group>"; };
B0B2E0001200000AAA100000 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/EventQuickEntryKeywords.strings; sourceTree = "<group>"; };
B0B2E0001300000AAA100000 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/EventQuickEntryKeywords.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -265,6 +278,7 @@
925B471A1A93CD2A00E0329A /* TooltipViewController.m */,
92658E601A9DBD8E002AE86C /* EventViewController.h */,
92658E611A9DBD8E002AE86C /* EventViewController.m */,
B0B2E0002200000AAA100000 /* EventQuickEntry */,
9261EB2E1EEF447500DCFFC4 /* Themer.h */,
9261EB2F1EEF447500DCFFC4 /* Themer.m */,
92B28CCD210BD17500485AE2 /* Sizer.h */,
Expand Down Expand Up @@ -326,6 +340,21 @@
name = MoCode;
sourceTree = "<group>";
};
B0B2E0002200000AAA100000 /* EventQuickEntry */ = {
isa = PBXGroup;
children = (
B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */,
B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */,
B0B2E0001900000AAA100000 /* EventQuickEntryLanguagePack.h */,
B0B2E0001A00000AAA100000 /* EventQuickEntryKeywords.h */,
B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */,
B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */,
B0B2E0001C00000AAA100000 /* EventQuickEntryLanguagePackRegistry.h */,
B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */,
);
name = EventQuickEntry;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -404,6 +433,7 @@
922B6BC41A87EDBE00DEA9CD /* InfoPlist.strings in Resources */,
922B6BBE1A87EDBD00DEA9CD /* Localizable.strings in Resources */,
9242A56A1A82A136005AA4B3 /* MainMenu.xib in Resources */,
B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -438,6 +468,9 @@
920D6D51266A83AC00498E19 /* MoLoginItem.m in Sources */,
9242A5921A82A40B005AA4B3 /* MoCalGrid.m in Sources */,
92658E621A9DBD8E002AE86C /* EventViewController.m in Sources */,
B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */,
B0B2E0001E00000AAA100000 /* EventQuickEntryKeywords.m in Sources */,
B0B2E0002000000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */,
922232CD1A8DF672003BFAD9 /* EventCenter.m in Sources */,
920B5D7C2B4D10B6004A10D3 /* DatePickerVC.m in Sources */,
926A6D8F1E3EC89100AAA991 /* PrefsVC.m in Sources */,
Expand Down Expand Up @@ -513,6 +546,15 @@
name = MainMenu.xib;
sourceTree = "<group>";
};
B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */ = {
isa = PBXVariantGroup;
children = (
B0B2E0001200000AAA100000 /* Base */,
B0B2E0001300000AAA100000 /* es */,
);
name = EventQuickEntryKeywords.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */

/* Begin XCBuildConfiguration section */
Expand Down
54 changes: 54 additions & 0 deletions Itsycal/Base.lproj/EventQuickEntryKeywords.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* Keyword data for EventQuickEntryKeywordLanguagePack (see EventQuickEntryParser.h).
Array-valued entries are pipe-delimited ("word1|word2"). */

"languageCode" = "en";

/* Duration */
"durationPrefixWord" = "for";
"halfHourPhrase" = "half an hour";
"oneHourPhrases" = "a hour|an hour";
"hourUnitWords" = "hours|hour|hrs|hr";
"minuteUnitWords" = "minutes|minute|mins|min";

/* Location */
"locationPrefixWords" = "at|in";

/* Date */
"danglingPrefixWords" = "on";
"mealWords" = "lunch|breakfast|dinner|brunch";
"explicitTimeWords" = "noon|midnight|morning|afternoon|evening";
"dateTimeConnector" = "at";

/* Relative-period phrases NSDataDetector doesn't recognize on its own */
"nextWeekPhrase" = "next week";
"nextMonthPhrase" = "next month";
"nextYearPhrase" = "next year";
"relativePeriodPrefixWord" = "in";
"relativePeriodFromNowSuffix" = "from now";
"weekUnitWords" = "weeks|week";
"monthUnitWords" = "months|month";
"yearUnitWords" = "years|year";
"numberWords" = "one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve";

/* Recurrence phrases, keyed by frequency */
"recurrencePhrases.every2Weeks" = "every other week|biweekly";
"recurrencePhrases.everyDay" = "every day|daily";
"recurrencePhrases.everyWeek" = "every week|weekly";
"recurrencePhrases.everyMonth" = "every month|monthly";
"recurrencePhrases.everyYear" = "every year|yearly|annually";

/* Recurrence display labels, keyed by frequency */
"recurrenceLabel.every2Weeks" = "Every 2 Weeks";
"recurrenceLabel.everyDay" = "Every Day";
"recurrenceLabel.everyWeek" = "Every Week";
"recurrenceLabel.everyMonth" = "Every Month";
"recurrenceLabel.everyYear" = "Every Year";

/* Span tooltip category labels */
"dateSpanLabel" = "Date";
"durationSpanLabel" = "Duration";
"locationSpanLabel" = "Location";
"repeatSpanLabel" = "Repeat";

/* Quick-entry field placeholder */
"placeholderExample" = "Meeting with Bob for 30 min this Friday";
148 changes: 148 additions & 0 deletions Itsycal/EventQuickEntryKeywords.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//
// EventQuickEntryKeywords.h
// Itsycal
//
// Created by Scott Goci on 7/27/26.
//

#import <Foundation/Foundation.h>
#import "EventQuickEntryLanguagePack.h"

NS_ASSUME_NONNULL_BEGIN

// The word/phrase lists that drive EventQuickEntryKeywordLanguagePack.
// A new keyword-based language pack only needs to supply one of these —
// no new matching logic. All arrays are case-insensitive whole-word/phrase
// matches; NSDataDetector's own date/time recognition (locale-aware,
// not configured here) still does the heavy lifting for the date itself.
@interface EventQuickEntryKeywords : NSObject

// BCP-47-ish language code, e.g. "en", "es". Used to localize the date
// formatter used for tooltip display text.
@property (nonatomic, copy) NSString *languageCode;

// The word before a duration phrase, e.g. "for" (en) / "durante" (es).
@property (nonatomic, copy) NSString *durationPrefixWord;

// The idiom for exactly 30 minutes, e.g. "half an hour" (en) / "media hora" (es).
@property (nonatomic, copy) NSString *halfHourPhrase;

// Idioms for exactly one hour, e.g. @[@"a hour", @"an hour"] (en) / @[@"una hora"] (es).
@property (nonatomic, copy) NSArray<NSString *> *oneHourPhrases;

// Words for "hours", e.g. @[@"hours", @"hour", @"hrs", @"hr"] (en) / @[@"horas", @"hora"] (es).
// Order matters beyond matching: index 0 is used as the plural display
// word and index 1 as the singular ("1 hour" vs "2 hours") when rendering
// a duration span's tooltip text — see -displayValueForDurationMinutes:.
@property (nonatomic, copy) NSArray<NSString *> *hourUnitWords;

// Words for "minutes", e.g. @[@"minutes", @"minute", @"mins", @"min"] (en) / @[@"minutos", @"minuto"] (es).
// Index 0 is used as the display word for any minute count, including
// exactly 1 (e.g. English always renders "1 minutes", never singularizing
// it) — a known simplification carried over unchanged from before this
// class existed, not something introduced here.
@property (nonatomic, copy) NSArray<NSString *> *minuteUnitWords;

// Words introducing a location, e.g. @[@"at", @"in"] (en) / @[@"en", @"a"] (es).
// "@" is always recognized as a location prefix regardless of language.
@property (nonatomic, copy) NSArray<NSString *> *locationPrefixWords;

// Words that can precede a date/time reference without being part of it,
// so NSDataDetector's match (which never includes them) doesn't leave
// them stranded in the title — e.g. @[@"on"] (en) / @[@"el", @"la", @"esta", @"este"] (es).
@property (nonatomic, copy) NSArray<NSString *> *danglingPrefixWords;

// Meal words NSDataDetector may fold into its own date match as an
// implicit time-of-day reference, e.g. @[@"lunch", @"breakfast", @"dinner", @"brunch"] (en).
@property (nonatomic, copy) NSArray<NSString *> *mealWords;

// Non-numeric words that indicate NSDataDetector resolved an explicit
// time (beyond the universal digit+am/pm and digit:digit patterns, which
// are always recognized), e.g. @[@"noon", @"midnight", @"morning", @"afternoon", @"evening"] (en).
@property (nonatomic, copy) NSArray<NSString *> *explicitTimeWords;

// Phrases for each recurrence frequency. Keys are fixed and match
// EventQuickEntryRecurrence's non-None cases: "everyDay", "everyWeek",
// "every2Weeks", "everyMonth", "everyYear".
@property (nonatomic, copy) NSDictionary<NSString *, NSArray<NSString *> *> *recurrencePhrasesByFrequencyKey;

// Display label for each recurrence frequency, same keys as above — must
// match the corresponding item title in EventViewController's _repPopup.
@property (nonatomic, copy) NSDictionary<NSString *, NSString *> *recurrenceLabelsByFrequencyKey;

// Category labels shown in span tooltips, e.g. "Date"/"Duration"/
// "Location"/"Repeat" (en) or "Fecha"/"Duración"/"Ubicación"/"Repetir" (es).
@property (nonatomic, copy) NSString *dateSpanLabel;
@property (nonatomic, copy) NSString *durationSpanLabel;
@property (nonatomic, copy) NSString *locationSpanLabel;
@property (nonatomic, copy) NSString *repeatSpanLabel;

// Word joining a date and time in the date span's tooltip display value,
// e.g. "Friday, January 2 <connector> 3:00 PM" — "at" (en) / "a las" (es).
@property (nonatomic, copy) NSString *dateTimeConnector;

// Relative-period phrases NSDataDetector doesn't recognize on its own —
// confirmed empirically: it resolves day-of-week-based phrases like "next
// Tuesday" and count-of-days phrases like "in 3 days" or "tomorrow" just
// fine, but has no support at all for bare relative-period phrases like
// "next week", "next month", or "next year", even standalone. These
// properties drive a small custom detector that runs before NSDataDetector
// to cover that gap, recognizing three forms per unit (week/month/year):
// "next <unit>", "<relativePeriodPrefixWord> N <unit>s" (e.g. "in 2
// weeks"), and "N <unit>s <relativePeriodFromNowSuffix>" (e.g. "2 weeks
// from now"). N may be a digit string or one of numberWords. Leaving any
// property empty simply disables the phrase(s) it drives — see
// -relativePeriodSpanInMasked:original:result:calendar:.
@property (nonatomic, copy) NSString *nextWeekPhrase; // e.g. "next week" (en)
@property (nonatomic, copy) NSString *nextMonthPhrase; // e.g. "next month" (en)
@property (nonatomic, copy) NSString *nextYearPhrase; // e.g. "next year" (en)
@property (nonatomic, copy) NSString *relativePeriodPrefixWord; // e.g. "in" (en) — shared across all three units
@property (nonatomic, copy) NSString *relativePeriodFromNowSuffix; // e.g. "from now" (en) — shared across all three units
@property (nonatomic, copy) NSArray<NSString *> *weekUnitWords; // e.g. @[@"weeks", @"week"] (en)
@property (nonatomic, copy) NSArray<NSString *> *monthUnitWords; // e.g. @[@"months", @"month"] (en)
@property (nonatomic, copy) NSArray<NSString *> *yearUnitWords; // e.g. @[@"years", @"year"] (en)

// Spelled-out counts, ordered 1..N by position — e.g. @[@"one", @"two", ...]
// so index 0 means 1, index 1 means 2, etc. Digit strings ("2") are always
// recognized regardless of this list; this only adds word forms on top.
@property (nonatomic, copy) NSArray<NSString *> *numberWords;

// Example phrase shown as the quick-entry field's placeholder text.
@property (nonatomic, copy) NSString *placeholderExample;

// Loads keywords from a .strings file on disk with the shape shown by
// Base.lproj/EventQuickEntryKeywords.strings / es.lproj/EventQuickEntryKeywords.strings
// — the same flat "key" = "value"; format as Localizable.strings. Array-
// valued properties are a single pipe-delimited entry ("word1|word2");
// the two recurrence dictionaries are flattened into "recurrencePhrases.<key>"
// / "recurrenceLabel.<key>" entries, one per frequency. Returns nil if the
// file is missing or isn't a valid .strings file.
+ (nullable instancetype)keywordsWithContentsOfStringsFileAtPath:(NSString *)path;

// The fixed key order for recurrencePhrasesByFrequencyKey/
// recurrenceLabelsByFrequencyKey — every2Weeks is deliberately checked
// before everyWeek so "every other week"-style phrases aren't shadowed by
// the plain "every week" pattern.
+ (NSArray<NSString *> *)recurrenceFrequencyKeys;

@end

// Generic EventQuickEntryLanguagePack implementation driven entirely by
// an EventQuickEntryKeywords config — the matching *logic* (masked-text
// pipeline, regex shapes) is fixed; only the words are configurable. Every
// keyword-based language uses this same class directly, constructed from
// keywords loaded from that language's .strings file — see
// EventQuickEntryLanguagePackRegistry, which is how you actually get one.
@interface EventQuickEntryKeywordLanguagePack : NSObject <EventQuickEntryLanguagePack>

- (instancetype)initWithKeywords:(EventQuickEntryKeywords *)keywords NS_DESIGNATED_INITIALIZER;

// Shared utility available to any EventQuickEntryLanguagePack implementation
// (not just this base class) for the masked-text technique: replaces
// `range` in `masked` with spaces of the same length, so indices into the
// original string stay valid and later detectors never re-match it.
+ (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked;

@end

NS_ASSUME_NONNULL_END
Loading