diff --git a/Itsycal.xcodeproj/project.pbxproj b/Itsycal.xcodeproj/project.pbxproj index a14a3178..af471e2b 100644 --- a/Itsycal.xcodeproj/project.pbxproj +++ b/Itsycal.xcodeproj/project.pbxproj @@ -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 */ @@ -171,6 +175,15 @@ B0A100012F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/MainMenu.strings; sourceTree = ""; }; B0A100022F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; B0A100032F74710000AAA001 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryParser.h; sourceTree = ""; }; + B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryParser.m; sourceTree = ""; }; + B0B2E0001900000AAA100000 /* EventQuickEntryLanguagePack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryLanguagePack.h; sourceTree = ""; }; + B0B2E0001A00000AAA100000 /* EventQuickEntryKeywords.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryKeywords.h; sourceTree = ""; }; + B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryKeywords.m; sourceTree = ""; }; + B0B2E0001C00000AAA100000 /* EventQuickEntryLanguagePackRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventQuickEntryLanguagePackRegistry.h; sourceTree = ""; }; + B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryLanguagePackRegistry.m; sourceTree = ""; }; + B0B2E0001200000AAA100000 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/EventQuickEntryKeywords.strings; sourceTree = ""; }; + B0B2E0001300000AAA100000 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/EventQuickEntryKeywords.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -265,6 +278,7 @@ 925B471A1A93CD2A00E0329A /* TooltipViewController.m */, 92658E601A9DBD8E002AE86C /* EventViewController.h */, 92658E611A9DBD8E002AE86C /* EventViewController.m */, + B0B2E0002200000AAA100000 /* EventQuickEntry */, 9261EB2E1EEF447500DCFFC4 /* Themer.h */, 9261EB2F1EEF447500DCFFC4 /* Themer.m */, 92B28CCD210BD17500485AE2 /* Sizer.h */, @@ -326,6 +340,21 @@ name = MoCode; sourceTree = ""; }; + 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 = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -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; }; @@ -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 */, @@ -513,6 +546,15 @@ name = MainMenu.xib; sourceTree = ""; }; + B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */ = { + isa = PBXVariantGroup; + children = ( + B0B2E0001200000AAA100000 /* Base */, + B0B2E0001300000AAA100000 /* es */, + ); + name = EventQuickEntryKeywords.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/Itsycal/Base.lproj/EventQuickEntryKeywords.strings b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings new file mode 100644 index 00000000..cf0153a5 --- /dev/null +++ b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings @@ -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"; diff --git a/Itsycal/EventQuickEntryKeywords.h b/Itsycal/EventQuickEntryKeywords.h new file mode 100644 index 00000000..e991decf --- /dev/null +++ b/Itsycal/EventQuickEntryKeywords.h @@ -0,0 +1,148 @@ +// +// EventQuickEntryKeywords.h +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import +#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 *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 *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 *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 *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 *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 *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 *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 *> *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 *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 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 ", " N s" (e.g. "in 2 +// weeks"), and "N s " (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 *weekUnitWords; // e.g. @[@"weeks", @"week"] (en) +@property (nonatomic, copy) NSArray *monthUnitWords; // e.g. @[@"months", @"month"] (en) +@property (nonatomic, copy) NSArray *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 *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." +// / "recurrenceLabel." 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 *)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 + +- (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 diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m new file mode 100644 index 00000000..c381b1a9 --- /dev/null +++ b/Itsycal/EventQuickEntryKeywords.m @@ -0,0 +1,702 @@ +// +// EventQuickEntryKeywords.m +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import "EventQuickEntryKeywords.h" +#import "EventQuickEntryParser.h" + +@implementation EventQuickEntryKeywords + +// Fixed key order for the two recurrence dictionaries, both here and in +// EventQuickEntryKeywordLanguagePack's regex construction — must match +// EventQuickEntryRecurrence's non-None cases. ++ (NSArray *)recurrenceFrequencyKeys +{ + return @[@"every2Weeks", @"everyDay", @"everyWeek", @"everyMonth", @"everyYear"]; +} + ++ (NSArray *)arrayForDelimitedString:(nullable NSString *)value +{ + if (value.length == 0) return @[]; + return [value componentsSeparatedByString:@"|"]; +} + ++ (nullable instancetype)keywordsWithContentsOfStringsFileAtPath:(NSString *)path +{ + NSDictionary *strings = [NSDictionary dictionaryWithContentsOfFile:path]; + if (!strings) return nil; + + NSMutableDictionary *> *recurrencePhrases = [NSMutableDictionary new]; + NSMutableDictionary *recurrenceLabels = [NSMutableDictionary new]; + for (NSString *key in [self recurrenceFrequencyKeys]) { + recurrencePhrases[key] = [self arrayForDelimitedString:strings[[@"recurrencePhrases." stringByAppendingString:key]]]; + recurrenceLabels[key] = strings[[@"recurrenceLabel." stringByAppendingString:key]] ?: @""; + } + + EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; + keywords.languageCode = strings[@"languageCode"]; + keywords.durationPrefixWord = strings[@"durationPrefixWord"]; + keywords.halfHourPhrase = strings[@"halfHourPhrase"]; + keywords.oneHourPhrases = [self arrayForDelimitedString:strings[@"oneHourPhrases"]]; + keywords.hourUnitWords = [self arrayForDelimitedString:strings[@"hourUnitWords"]]; + keywords.minuteUnitWords = [self arrayForDelimitedString:strings[@"minuteUnitWords"]]; + keywords.locationPrefixWords = [self arrayForDelimitedString:strings[@"locationPrefixWords"]]; + keywords.danglingPrefixWords = [self arrayForDelimitedString:strings[@"danglingPrefixWords"]]; + keywords.mealWords = [self arrayForDelimitedString:strings[@"mealWords"]]; + keywords.explicitTimeWords = [self arrayForDelimitedString:strings[@"explicitTimeWords"]]; + keywords.recurrencePhrasesByFrequencyKey = recurrencePhrases; + keywords.recurrenceLabelsByFrequencyKey = recurrenceLabels; + keywords.dateSpanLabel = strings[@"dateSpanLabel"]; + keywords.durationSpanLabel = strings[@"durationSpanLabel"]; + keywords.locationSpanLabel = strings[@"locationSpanLabel"]; + keywords.repeatSpanLabel = strings[@"repeatSpanLabel"]; + keywords.dateTimeConnector = strings[@"dateTimeConnector"]; + keywords.nextWeekPhrase = strings[@"nextWeekPhrase"]; + keywords.nextMonthPhrase = strings[@"nextMonthPhrase"]; + keywords.nextYearPhrase = strings[@"nextYearPhrase"]; + keywords.relativePeriodPrefixWord = strings[@"relativePeriodPrefixWord"]; + keywords.relativePeriodFromNowSuffix = strings[@"relativePeriodFromNowSuffix"]; + keywords.weekUnitWords = [self arrayForDelimitedString:strings[@"weekUnitWords"]]; + keywords.monthUnitWords = [self arrayForDelimitedString:strings[@"monthUnitWords"]]; + keywords.yearUnitWords = [self arrayForDelimitedString:strings[@"yearUnitWords"]]; + keywords.numberWords = [self arrayForDelimitedString:strings[@"numberWords"]]; + keywords.placeholderExample = strings[@"placeholderExample"]; + return keywords; +} + +@end + +#pragma mark - +#pragma mark EventQuickEntryKeywordLanguagePack + +@implementation EventQuickEntryKeywordLanguagePack +{ + EventQuickEntryKeywords *_keywords; + NSRegularExpression *_timeRegex; + NSRegularExpression *_durationRegex; + NSRegularExpression *_mealWordRegex; // nil if no meal words configured + NSRegularExpression *_danglingPrefixRegex; // nil if no dangling words configured + NSRegularExpression *_locationPrefixRegex; + NSRegularExpression *_trailingLocationPrefixRegex; + NSRegularExpression *_relativePeriodRegex; + NSArray *_recurrenceRegexes; // ordered: every2Weeks, everyDay, everyWeek, everyMonth, everyYear + NSArray *_recurrenceValues; // parallel EventQuickEntryRecurrence values + NSArray *_recurrenceLabels; // parallel display labels +} + +- (instancetype)initWithKeywords:(EventQuickEntryKeywords *)keywords +{ + self = [super init]; + if (self) { + _keywords = keywords; + [self buildTimeRegexFromKeywords:keywords]; + [self buildDurationRegexFromKeywords:keywords]; + [self buildMealWordRegexFromKeywords:keywords]; + [self buildDanglingPrefixRegexFromKeywords:keywords]; + [self buildLocationRegexFromKeywords:keywords]; + [self buildRecurrenceRegexesFromKeywords:keywords]; + [self buildRelativePeriodRegexFromKeywords:keywords]; + } + return self; +} + +#pragma mark - +#pragma mark Regex construction + +// Returns nil (not an empty string) when `phrases` is empty. This matters: +// an empty alternation group like `(?:)` is valid regex syntax that matches +// the *empty string* at every position, so silently interpolating "" into +// a larger pattern turns "this branch should never match" into "this +// branch matches almost anything" — e.g. an empty oneHourPhrases list +// would otherwise make `\bfor\s+()\b` match any "for ", not just +// "for a hour"/"for an hour". Every call site must explicitly decide what +// an absent word list means for its pattern (see the two call sites below +// for the two valid answers: omit the whole branch, or substitute "(?!)", +// a group that can never match, to keep capture-group numbering fixed). ++ (nullable NSString *)alternationPatternForPhrases:(NSArray *)phrases +{ + if (phrases.count == 0) return nil; + NSMutableArray *escaped = [NSMutableArray new]; + for (NSString *phrase in phrases) { + [escaped addObject:[NSRegularExpression escapedPatternForString:phrase]]; + } + return [escaped componentsJoinedByString:@"|"]; +} + +- (void)buildTimeRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + // The digit-based patterns are universal (digits are digits regardless + // of language); only the non-numeric time-of-day words vary. No + // explicitTimeWords configured just means that whole alternative is + // omitted — the universal digit patterns still work. + NSString *wordsAlt = [[self class] alternationPatternForPhrases:keywords.explicitTimeWords]; + NSString *wordsBranch = wordsAlt ? [NSString stringWithFormat:@"|\\b(?:%@)\\b", wordsAlt] : @""; + NSString *pattern = [NSString stringWithFormat:@"(?i)\\b\\d{1,2}(:\\d{2})?\\s*(am|pm|a\\.m\\.|p\\.m\\.)\\b|\\b\\d{1,2}:\\d{2}\\b%@", wordsBranch]; + _timeRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + +- (void)buildDurationRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + if (keywords.durationPrefixWord.length == 0) { + _durationRegex = nil; + return; + } + NSString *forWord = [NSRegularExpression escapedPatternForString:keywords.durationPrefixWord]; + + // Each alternative's word list is independently optional, but unlike + // buildTimeRegexFromKeywords: above, a missing one can't just be + // omitted here — -durationSpanInMasked:result: reads specific capture + // group *indices* (1=half-hour, 2=one-hour, 3=hours, 4=minutes) to + // classify which alternative matched, so all four must stay + // structurally present. A missing word list instead gets "(?!)" — a + // group that can never match — so that alternative is effectively + // disabled without shifting the others' group numbers. + NSString *halfHour = keywords.halfHourPhrase.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.halfHourPhrase] + : @"(?!)"; + NSString *oneHourAlt = [[self class] alternationPatternForPhrases:keywords.oneHourPhrases] ?: @"(?!)"; + NSString *hourUnitAlt = [[self class] alternationPatternForPhrases:keywords.hourUnitWords] ?: @"(?!)"; + NSString *minuteUnitAlt = [[self class] alternationPatternForPhrases:keywords.minuteUnitWords] ?: @"(?!)"; + + NSString *pattern = [NSString stringWithFormat: + @"(?i)\\b%@\\s+(%@)\\b|\\b%@\\s+(%@)\\b|\\b%@\\s+(\\d+)\\s*(?:%@)\\b|\\b%@\\s+(\\d+)\\s*(?:%@)\\b", + forWord, halfHour, forWord, oneHourAlt, forWord, hourUnitAlt, forWord, minuteUnitAlt]; + _durationRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + +- (void)buildMealWordRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + if (keywords.mealWords.count == 0) { + _mealWordRegex = nil; + return; + } + NSString *alt = [[self class] alternationPatternForPhrases:keywords.mealWords]; + NSString *pattern = [NSString stringWithFormat:@"(?i)^\\b(?:%@)\\b\\s*", alt]; + _mealWordRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + +- (void)buildDanglingPrefixRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + if (keywords.danglingPrefixWords.count == 0) { + _danglingPrefixRegex = nil; + return; + } + NSString *alt = [[self class] alternationPatternForPhrases:keywords.danglingPrefixWords]; + NSString *pattern = [NSString stringWithFormat:@"(?i)\\b(?:%@)\\s+$", alt]; + _danglingPrefixRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + +- (void)buildLocationRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + // "@" is always recognized as a location prefix regardless of language, + // so locationPrefixWords is optional — a language pack with none still + // works via "@" alone. The word-alternatives are wrapped in \b so the + // boundary doesn't apply to "@" itself (a non-word character can't be + // preceded by \b the way "@ Cafe Luna" needs it to). + // + // This only matches the prefix word itself, not the location phrase + // that follows — see -locationSpanInMasked:original:result: for why: a + // regex working on `masked` alone can't tell "more location text" + // apart from a previously-blanked span's leftover spaces, since both + // look like plain whitespace. Telling them apart needs a comparison + // against `original`, which a regex can't express. + NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; + NSString *prefixGroup = prefixAlt ? [NSString stringWithFormat:@"\\b(?:%@)|@", prefixAlt] : @"@"; + NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+", prefixGroup]; + _locationPrefixRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; + + // Matches a prefix word sitting at the very end of a string, with the + // whitespace that separates it from real place text before it — used + // to detect and trim a leftover dangling preposition off the tail of + // an already-walked location range. See + // -placeCoreEndByTrimmingDanglingPrefixFromMasked:range:. + NSString *trailingPattern = [NSString stringWithFormat:@"(?i)\\s+(?:%@)$", prefixGroup]; + _trailingLocationPrefixRegex = [NSRegularExpression regularExpressionWithPattern:trailingPattern options:0 error:nil]; +} + +- (void)buildRecurrenceRegexesFromKeywords:(EventQuickEntryKeywords *)keywords +{ + // "every other week"-style phrases are checked before the plain + // "every week"-style pattern so they aren't shadowed by it. + NSArray *orderedKeys = [EventQuickEntryKeywords recurrenceFrequencyKeys]; + NSArray *orderedValues = @[ + @(EventQuickEntryRecurrenceEvery2Weeks), + @(EventQuickEntryRecurrenceEveryDay), + @(EventQuickEntryRecurrenceEveryWeek), + @(EventQuickEntryRecurrenceEveryMonth), + @(EventQuickEntryRecurrenceEveryYear), + ]; + NSMutableArray *regexes = [NSMutableArray new]; + NSMutableArray *values = [NSMutableArray new]; + NSMutableArray *labels = [NSMutableArray new]; + for (NSInteger i = 0; i < (NSInteger)orderedKeys.count; i++) { + NSString *key = orderedKeys[i]; + NSArray *phrases = keywords.recurrencePhrasesByFrequencyKey[key]; + // A frequency with no configured phrases is skipped entirely, + // rather than built into `\b(?:)\b` — which, since an empty + // alternation group matches the empty string, would match at + // *every* word boundary in *any* input (see + // +alternationPatternForPhrases:). regexes/values/labels are + // appended together so they stay index-parallel despite the skip. + NSString *alt = [[self class] alternationPatternForPhrases:phrases]; + if (!alt) continue; + NSString *pattern = [NSString stringWithFormat:@"(?i)\\b(?:%@)\\b", alt]; + [regexes addObject:[NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]]; + [values addObject:orderedValues[i]]; + [labels addObject:keywords.recurrenceLabelsByFrequencyKey[key] ?: @""]; + } + _recurrenceRegexes = regexes; + _recurrenceValues = values; + _recurrenceLabels = labels; +} + +- (void)buildRelativePeriodRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + // Nine independently-optional alternatives, each keyed by a fixed + // capture-group index that -relativePeriodSpanInMasked:original:result:calendar: + // reads directly: groups 1-3 are "next week"/"next month"/"next year"; + // groups 4-9 are the prefix ("in N weeks") and "from now" suffix ("N + // weeks from now") forms, two groups per unit in week/month/year order. + // A missing phrase/word list gets "(?!)" rather than being omitted, to + // keep every other group's numbering fixed — same reasoning as + // buildDurationRegexFromKeywords: above. + NSString *prefixWord = keywords.relativePeriodPrefixWord.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.relativePeriodPrefixWord] : @"(?!)"; + NSString *fromNowSuffix = keywords.relativePeriodFromNowSuffix.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.relativePeriodFromNowSuffix] : @"(?!)"; + NSString *countAlt = [[self class] countAlternationForNumberWords:keywords.numberWords]; + + NSArray *nextPhrases = @[keywords.nextWeekPhrase ?: @"", keywords.nextMonthPhrase ?: @"", keywords.nextYearPhrase ?: @""]; + NSArray *> *unitWordsByUnit = @[keywords.weekUnitWords ?: @[], keywords.monthUnitWords ?: @[], keywords.yearUnitWords ?: @[]]; + + NSMutableArray *branches = [NSMutableArray new]; + for (NSString *phrase in nextPhrases) { + NSString *escaped = phrase.length > 0 ? [NSRegularExpression escapedPatternForString:phrase] : @"(?!)"; + [branches addObject:[NSString stringWithFormat:@"\\b(%@)\\b", escaped]]; + } + for (NSArray *unitWords in unitWordsByUnit) { + NSString *unitAlt = [[self class] alternationPatternForPhrases:unitWords] ?: @"(?!)"; + [branches addObject:[NSString stringWithFormat:@"\\b%@\\s+(%@)\\s*(?:%@)\\b", prefixWord, countAlt, unitAlt]]; + [branches addObject:[NSString stringWithFormat:@"\\b(%@)\\s*(?:%@)\\s+%@\\b", countAlt, unitAlt, fromNowSuffix]]; + } + + NSString *pattern = [NSString stringWithFormat:@"(?i)%@", [branches componentsJoinedByString:@"|"]]; + _relativePeriodRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + +// Digit strings ("2") are always recognized, language-independent; word +// forms from numberWords are additionally recognized on top when configured. ++ (NSString *)countAlternationForNumberWords:(NSArray *)numberWords +{ + NSString *wordsAlt = [self alternationPatternForPhrases:numberWords]; + return wordsAlt ? [NSString stringWithFormat:@"\\d+|%@", wordsAlt] : @"\\d+"; +} + +#pragma mark - +#pragma mark EventQuickEntryLanguagePack — placeholder + +- (NSString *)placeholderExample +{ + return _keywords.placeholderExample; +} + +#pragma mark - +#pragma mark Shared masking utility + ++ (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked +{ + NSString *blank = [@"" stringByPaddingToLength:range.length withString:@" " startingAtIndex:0]; + [masked replaceCharactersInRange:range withString:blank]; +} + +#pragma mark - +#pragma mark EventQuickEntryLanguagePack — Date/time + +- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result calendar:(NSCalendar *)calendar +{ + // Tried first: bare relative-period phrases NSDataDetector doesn't + // recognize at all — see -relativePeriodSpanInMasked:original:result:calendar:. + // Falls through to NSDataDetector unchanged when none is configured or + // none is present in this input. + EventQuickEntrySpan *relativeSpan = [self relativePeriodSpanInMasked:masked original:original result:result calendar:calendar]; + if (relativeSpan) return relativeSpan; + + NSError *error = nil; + NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error]; + if (!detector) return nil; + + NSTextCheckingResult *match = [detector firstMatchInString:original + options:0 + range:NSMakeRange(0, original.length)]; + if (!match || !match.date) return nil; + + result.date = match.date; + NSString *matchedText = [original substringWithRange:match.range]; + result.hasExplicitTime = [self textContainsExplicitTime:matchedText]; + + // NSDataDetector computes a duration for time-range phrases like + // "3-4pm". Set it here so an explicit "for X minutes"-style phrase + // (handled by -durationSpanInMasked:result:) can still unconditionally + // override it, since it runs later in the pipeline. + if (match.duration > 0) { + result.durationMinutes = (NSInteger)round(match.duration / 60.0); + } + + NSRange rangeToBlank = [self rangeToBlankForDateMatch:match inOriginal:original]; + [[self class] blankRange:rangeToBlank inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = rangeToBlank; + span.kind = EventQuickEntrySpanKindDate; + span.label = _keywords.dateSpanLabel; + span.displayValue = [self displayValueForDate:result.date hasExplicitTime:result.hasExplicitTime calendar:calendar]; + return span; +} + +// Handles bare relative-period phrases NSDataDetector has no support for +// at all — confirmed empirically: it resolves day-of-week-based phrases +// ("next Tuesday") and day-count phrases ("in 3 days", "tomorrow") fine, +// but returns no match whatsoever for "next week", "next month", "next +// year", "in N weeks/months/years", or "N weeks/months/years from now", +// even standalone. Computes today's date (at noon, matching +// NSDataDetector's own convention for a date with no time) plus the +// matched offset, then — since this phrase gives no time of its own — +// folds in a nearby explicit time if one immediately follows, using +// NSDataDetector just to resolve that isolated time phrase (which it does +// correctly on its own, per the same probing). +- (nullable EventQuickEntrySpan *)relativePeriodSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result calendar:(NSCalendar *)calendar +{ + NSTextCheckingResult *match = [_relativePeriodRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (!match) return nil; + + NSDateComponents *offset = [NSDateComponents new]; + if ([self group:1 presentInMatch:match]) { + offset.weekOfYear = 1; + } else if ([self group:2 presentInMatch:match]) { + offset.month = 1; + } else if ([self group:3 presentInMatch:match]) { + offset.year = 1; + } else if ([self group:4 presentInMatch:match]) { + offset.weekOfYear = [self integerValueForCountRange:[match rangeAtIndex:4] inMasked:masked]; + } else if ([self group:5 presentInMatch:match]) { + offset.weekOfYear = [self integerValueForCountRange:[match rangeAtIndex:5] inMasked:masked]; + } else if ([self group:6 presentInMatch:match]) { + offset.month = [self integerValueForCountRange:[match rangeAtIndex:6] inMasked:masked]; + } else if ([self group:7 presentInMatch:match]) { + offset.month = [self integerValueForCountRange:[match rangeAtIndex:7] inMasked:masked]; + } else if ([self group:8 presentInMatch:match]) { + offset.year = [self integerValueForCountRange:[match rangeAtIndex:8] inMasked:masked]; + } else if ([self group:9 presentInMatch:match]) { + offset.year = [self integerValueForCountRange:[match rangeAtIndex:9] inMasked:masked]; + } else { + return nil; + } + + NSDateComponents *todayComponents = [calendar components:(NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay) fromDate:[NSDate date]]; + todayComponents.hour = 12; + NSDate *baseDate = [calendar dateByAddingComponents:offset toDate:[calendar dateFromComponents:todayComponents] options:0]; + + NSRange matchRange = match.range; + BOOL hasExplicitTime = NO; + NSDate *combinedDate = [self dateByCombiningBaseDate:baseDate withNearbyTimeInOriginal:original aroundRange:match.range hasExplicitTime:&hasExplicitTime matchRangeOut:&matchRange calendar:calendar]; + + result.date = combinedDate; + result.hasExplicitTime = hasExplicitTime; + [[self class] blankRange:matchRange inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = matchRange; + span.kind = EventQuickEntrySpanKindDate; + span.label = _keywords.dateSpanLabel; + span.displayValue = [self displayValueForDate:combinedDate hasExplicitTime:hasExplicitTime calendar:calendar]; + return span; +} + +- (BOOL)group:(NSUInteger)index presentInMatch:(NSTextCheckingResult *)match +{ + return match.numberOfRanges > index && [match rangeAtIndex:index].location != NSNotFound; +} + +// The captured count text is either all digits (always recognized, +// language-independent) or one of the configured spelled-out number +// words — see numberWords, whose *position* (not the word itself) carries +// the value: index 0 means 1, index 1 means 2, etc. Returns 0 if somehow +// neither matches, which can't actually happen given the regex that +// produced `range` only ever captures one of those two things. +- (NSInteger)integerValueForCountRange:(NSRange)range inMasked:(NSString *)masked +{ + NSString *text = [masked substringWithRange:range]; + if ([text rangeOfCharacterFromSet:[NSCharacterSet decimalDigitCharacterSet]].location != NSNotFound) { + return [text integerValue]; + } + for (NSUInteger i = 0; i < _keywords.numberWords.count; i++) { + if ([_keywords.numberWords[i] caseInsensitiveCompare:text] == NSOrderedSame) return (NSInteger)i + 1; + } + return 0; +} + +// Looks for an explicit time phrase (e.g. "2pm") immediately before or +// after `range` — covering both "next week at 2pm" and "at 2pm next +// week" — allowing one short connecting word in between (e.g. "at") but +// not arbitrary distance — a time phrase found much further away isn't +// clearly related to this date and is left alone for something else (or +// nothing) to deal with. When found, resolves just that isolated phrase's +// time of day via NSDataDetector — which handles a bare time correctly, +// unlike a bare relative period — and applies it to `baseDate`. Extends +// `*matchRangeOut` to cover the connector and time phrase too, so both get +// blanked out of the title along with the relative-period phrase itself. +- (NSDate *)dateByCombiningBaseDate:(NSDate *)baseDate withNearbyTimeInOriginal:(NSString *)original aroundRange:(NSRange)range hasExplicitTime:(BOOL *)hasExplicitTime matchRangeOut:(NSRange *)matchRangeOut calendar:(NSCalendar *)calendar +{ + NSTextCheckingResult *timeMatch = [self timeMatchNearRange:range inOriginal:original]; + if (!timeMatch) return baseDate; + + NSError *error = nil; + NSDataDetector *timeDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error]; + NSString *timeText = [original substringWithRange:timeMatch.range]; + NSTextCheckingResult *resolvedTime = [timeDetector firstMatchInString:timeText options:0 range:NSMakeRange(0, timeText.length)]; + if (!resolvedTime.date) return baseDate; + + NSDateComponents *timeComponents = [calendar components:(NSCalendarUnitHour | NSCalendarUnitMinute) fromDate:resolvedTime.date]; + *hasExplicitTime = YES; + NSUInteger start = MIN(matchRangeOut->location, timeMatch.range.location); + NSUInteger end = MAX(NSMaxRange(*matchRangeOut), NSMaxRange(timeMatch.range)); + *matchRangeOut = NSMakeRange(start, end - start); + return [calendar dateBySettingHour:timeComponents.hour minute:timeComponents.minute second:0 ofDate:baseDate options:0]; +} + +// Tries the text right after `range` first, then right before it, each +// subject to the same short-connector-gap requirement. If both happen to +// have a qualifying time phrase (vanishingly unlikely), the following one +// wins, matching the more literal reading of "next week [at 2pm]" over +// "[at 2pm] next week". +- (nullable NSTextCheckingResult *)timeMatchNearRange:(NSRange)range inOriginal:(NSString *)original +{ + NSUInteger afterStart = NSMaxRange(range); + NSTextCheckingResult *after = [_timeRegex firstMatchInString:original options:0 range:NSMakeRange(afterStart, original.length - afterStart)]; + if (after && [self isShortConnectorGapInOriginal:original from:afterStart to:after.range.location]) return after; + + NSArray *beforeMatches = [_timeRegex matchesInString:original options:0 range:NSMakeRange(0, range.location)]; + NSTextCheckingResult *before = beforeMatches.lastObject; + if (before && [self isShortConnectorGapInOriginal:original from:NSMaxRange(before.range) to:range.location]) return before; + + return nil; +} + +// A length cap alone (not also requiring a single word) is deliberate: a +// two-word connector like Spanish "a las" must still qualify, not just +// English's one-word "at". +- (BOOL)isShortConnectorGapInOriginal:(NSString *)original from:(NSUInteger)start to:(NSUInteger)end +{ + NSString *gapText = [original substringWithRange:NSMakeRange(start, end - start)]; + NSString *gap = [gapText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + return gap.length <= 6; +} + +// NSDataDetector's own match range doesn't always line up with what should +// actually be blanked out of the title: it sometimes folds a leading meal +// word into its own match as a time-of-day reference, and it never +// includes a dangling leading word (e.g. English "on", Spanish "el"/"esta") +// that precedes the match. This adjusts for both before anything is blanked. +- (NSRange)rangeToBlankForDateMatch:(NSTextCheckingResult *)match inOriginal:(NSString *)original +{ + NSRange coreRange = match.range; + if (_mealWordRegex) { + NSTextCheckingResult *mealMatch = [_mealWordRegex firstMatchInString:original options:0 range:match.range]; + if (mealMatch) { + NSUInteger newLocation = NSMaxRange(mealMatch.range); + coreRange = NSMakeRange(newLocation, NSMaxRange(match.range) - newLocation); + } + } + + NSRange rangeToBlank = coreRange; + if (_danglingPrefixRegex) { + NSTextCheckingResult *danglingMatch = [_danglingPrefixRegex firstMatchInString:original + options:0 + range:NSMakeRange(0, coreRange.location)]; + if (danglingMatch) { + rangeToBlank = NSMakeRange(danglingMatch.range.location, NSMaxRange(coreRange) - danglingMatch.range.location); + } + } + return rangeToBlank; +} + +- (NSString *)displayValueForDate:(NSDate *)date hasExplicitTime:(BOOL)hasExplicitTime calendar:(NSCalendar *)calendar +{ + NSLocale *locale = [NSLocale localeWithLocaleIdentifier:_keywords.languageCode]; + + NSDateFormatter *dateFormatter = [NSDateFormatter new]; + dateFormatter.calendar = calendar; + dateFormatter.locale = locale; + [dateFormatter setLocalizedDateFormatFromTemplate:@"EEEEMMMMd"]; + NSString *dateString = [dateFormatter stringFromDate:date]; + + if (!hasExplicitTime) return dateString; + + NSDateFormatter *timeFormatter = [NSDateFormatter new]; + timeFormatter.calendar = calendar; + timeFormatter.locale = locale; + timeFormatter.dateStyle = NSDateFormatterNoStyle; + timeFormatter.timeStyle = NSDateFormatterShortStyle; + NSString *timeString = [timeFormatter stringFromDate:date]; + + return [NSString stringWithFormat:@"%@ %@ %@", dateString, _keywords.dateTimeConnector, timeString]; +} + +- (BOOL)textContainsExplicitTime:(NSString *)text +{ + return [_timeRegex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)] != nil; +} + +#pragma mark - +#pragma mark EventQuickEntryLanguagePack — Duration + +- (nullable EventQuickEntrySpan *)durationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + NSTextCheckingResult *match = [_durationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (!match) return nil; + + NSInteger minutes = 0; + if (match.numberOfRanges > 1 && [match rangeAtIndex:1].location != NSNotFound) { + minutes = 30; // half-hour idiom + } else if (match.numberOfRanges > 2 && [match rangeAtIndex:2].location != NSNotFound) { + minutes = 60; // one-hour idiom + } else if (match.numberOfRanges > 3 && [match rangeAtIndex:3].location != NSNotFound) { + minutes = [[masked substringWithRange:[match rangeAtIndex:3]] integerValue] * 60; + } else if (match.numberOfRanges > 4 && [match rangeAtIndex:4].location != NSNotFound) { + minutes = [[masked substringWithRange:[match rangeAtIndex:4]] integerValue]; + } + + result.durationMinutes = minutes; + [[self class] blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindDuration; + span.label = _keywords.durationSpanLabel; + span.displayValue = [self displayValueForDurationMinutes:minutes]; + return span; +} + +- (NSString *)displayValueForDurationMinutes:(NSInteger)minutes +{ + NSString *hourWordPlural = _keywords.hourUnitWords.firstObject ?: @""; + NSString *hourWordSingular = _keywords.hourUnitWords.count > 1 ? _keywords.hourUnitWords[1] : hourWordPlural; + NSString *minuteWordPlural = _keywords.minuteUnitWords.firstObject ?: @""; + + NSInteger hours = minutes / 60; + NSInteger remainder = minutes % 60; + + if (hours == 0) { + return [NSString stringWithFormat:@"%ld %@", (long)remainder, minuteWordPlural]; + } + NSString *hoursPart = (hours == 1) + ? [NSString stringWithFormat:@"1 %@", hourWordSingular] + : [NSString stringWithFormat:@"%ld %@", (long)hours, hourWordPlural]; + if (remainder == 0) { + return hoursPart; + } + return [NSString stringWithFormat:@"%@ %ld %@", hoursPart, (long)remainder, minuteWordPlural]; +} + +#pragma mark - +#pragma mark EventQuickEntryLanguagePack — Location + +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result +{ + NSArray *prefixMatches = [_locationPrefixRegex matchesInString:masked options:0 range:NSMakeRange(0, masked.length)]; + for (NSTextCheckingResult *prefixMatch in prefixMatches) { + NSUInteger placeStart = NSMaxRange(prefixMatch.range); + NSUInteger placeEnd = [self locationPlaceEndInMasked:masked original:original from:placeStart]; + if (placeEnd == NSNotFound) continue; + + // The walked text may end in a leftover dangling preposition, e.g. + // "Cafe Luna at" when what followed ("noon") has already been + // blanked by the date detector. That trailing word is folded into + // the overall (blanked) matchRange below, but trimmed back out of + // `place` — the value actually assigned to result.location. + NSUInteger placeCoreEnd = [self placeCoreEndByTrimmingDanglingPrefixFromMasked:masked range:NSMakeRange(placeStart, placeEnd - placeStart)]; + + NSString *place = [[masked substringWithRange:NSMakeRange(placeStart, placeCoreEnd - placeStart)] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (place.length == 0) continue; + + NSRange matchRange = NSMakeRange(prefixMatch.range.location, placeEnd - prefixMatch.range.location); + + result.location = place; + [[self class] blankRange:matchRange inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = matchRange; + span.kind = EventQuickEntrySpanKindLocation; + span.label = _keywords.locationSpanLabel; + span.displayValue = place; + return span; + } + return nil; +} + +// Walks forward from `start`, stopping at a comma (as the old regex did) +// or — the rule a regex can't express — at the first character an earlier +// detector has already blanked, detected by `masked` and `original` no +// longer agreeing at that index. Without this, a location phrase followed +// by an already-blanked span (e.g. "At Cafe Luna for 15 minutes", once +// duration is blanked to spaces) has no way to tell where its own text +// ends and the blanked span's leftover spaces begin — a plain regex +// anchored on "nothing but whitespace to the end of the string" would +// swallow that leftover run right along with the real location text. +// Returns NSNotFound if nothing but whitespace follows `start`. +- (NSUInteger)locationPlaceEndInMasked:(NSString *)masked original:(NSString *)original from:(NSUInteger)start +{ + NSCharacterSet *whitespace = [NSCharacterSet whitespaceCharacterSet]; + NSUInteger length = masked.length; + NSUInteger lastNonWhitespace = NSNotFound; + for (NSUInteger i = start; i < length; i++) { + unichar maskedChar = [masked characterAtIndex:i]; + if (maskedChar != [original characterAtIndex:i]) break; + if (maskedChar == ',') break; + if (![whitespace characterIsMember:maskedChar]) lastNonWhitespace = i; + } + return lastNonWhitespace == NSNotFound ? NSNotFound : lastNonWhitespace + 1; +} + +// `range` (within `masked`) always ends right where an earlier detector's +// blanked region begins, or at the true end of the string — that's how +// -locationPlaceEndInMasked:original:from: found it. So if `range` itself +// ends in " PREFIX" (e.g. "Cafe Luna at"), that trailing +// PREFIX has nothing of its own following it — it's a leftover dangling +// preposition from whatever got blanked next door, not part of the place +// name. Returns the end index of the real place text, excluding that +// trailing word (or `range`'s own end, unchanged, if there is none). +- (NSUInteger)placeCoreEndByTrimmingDanglingPrefixFromMasked:(NSString *)masked range:(NSRange)range +{ + NSString *text = [masked substringWithRange:range]; + NSTextCheckingResult *trailingPrefixMatch = [_trailingLocationPrefixRegex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)]; + if (!trailingPrefixMatch) return NSMaxRange(range); + return range.location + trailingPrefixMatch.range.location; +} + +#pragma mark - +#pragma mark EventQuickEntryLanguagePack — Recurrence + +- (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + for (NSInteger i = 0; i < (NSInteger)_recurrenceRegexes.count; i++) { + NSTextCheckingResult *match = [_recurrenceRegexes[i] firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (match) { + result.recurrence = (EventQuickEntryRecurrence)_recurrenceValues[i].integerValue; + [[self class] blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindRepeat; + span.label = _keywords.repeatSpanLabel; + span.displayValue = _recurrenceLabels[i]; + return span; + } + } + return nil; +} + +@end diff --git a/Itsycal/EventQuickEntryLanguagePack.h b/Itsycal/EventQuickEntryLanguagePack.h new file mode 100644 index 00000000..94f14fe9 --- /dev/null +++ b/Itsycal/EventQuickEntryLanguagePack.h @@ -0,0 +1,40 @@ +// +// EventQuickEntryLanguagePack.h +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class EventQuickEntrySpan; +@class EventQuickEntryResult; + +// Implement this to add a new language. Each method receives the same +// "masked text" pipeline EventQuickEntryParser already uses: recognized +// ranges are blanked out (replaced with equal-length spaces) as each +// detector runs, so later detectors never re-match text an earlier one +// already claimed, and returned ranges stay valid throughout. A detector +// that finds nothing must return nil, not a zero-length span — every +// field this protocol can set (result.date, result.durationMinutes, etc.) +// is only applied by callers when a value was actually found. +// +// Most languages only need different *words*, not different matching +// logic — see EventQuickEntryKeywordLanguagePack, a generic implementation +// of this protocol driven by an EventQuickEntryKeywords config, which is +// almost certainly what you want. Implement this protocol directly only +// if your language's grammar doesn't fit that shape (e.g. no equivalent +// of English-style prepositions for location/time). +@protocol EventQuickEntryLanguagePack +// Example phrase shown as the quick-entry field's placeholder text, e.g. +// "Meeting with Bob for 30 min this Friday" (en). +@property (nonatomic, readonly, copy) NSString *placeholderExample; +- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result calendar:(NSCalendar *)calendar; +- (nullable EventQuickEntrySpan *)durationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result; +- (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; +@end + +NS_ASSUME_NONNULL_END diff --git a/Itsycal/EventQuickEntryLanguagePackRegistry.h b/Itsycal/EventQuickEntryLanguagePackRegistry.h new file mode 100644 index 00000000..985a8cc7 --- /dev/null +++ b/Itsycal/EventQuickEntryLanguagePackRegistry.h @@ -0,0 +1,27 @@ +// +// EventQuickEntryLanguagePackRegistry.h +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import +#import "EventQuickEntryLanguagePack.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface EventQuickEntryLanguagePackRegistry : NSObject +// Returns the language pack for a BCP-47-ish language code (e.g. from +// -[NSBundle preferredLocalizations]), or nil if no pack is registered for +// it. A language is "registered" simply by the presence of an +// EventQuickEntryKeywords.strings resource in that language's .lproj +// folder (English lives in Base.lproj, like the rest of the app's +// development-language resources) — the same localized-resource-variant +// mechanism already used for Localizable.strings/MainMenu.xib. Adding a +// new keyword-based language means adding a new .lproj/EventQuickEntryKeywords.strings +// file, not writing or compiling any code. Matches by primary language +// subtag, so "en-US"/"en-GB"/etc. all resolve to the "en" (Base) pack. ++ (nullable id)packForLanguageCode:(NSString *)languageCode; +@end + +NS_ASSUME_NONNULL_END diff --git a/Itsycal/EventQuickEntryLanguagePackRegistry.m b/Itsycal/EventQuickEntryLanguagePackRegistry.m new file mode 100644 index 00000000..abf65b28 --- /dev/null +++ b/Itsycal/EventQuickEntryLanguagePackRegistry.m @@ -0,0 +1,33 @@ +// +// EventQuickEntryLanguagePackRegistry.m +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import "EventQuickEntryLanguagePackRegistry.h" +#import "EventQuickEntryKeywords.h" + +@implementation EventQuickEntryLanguagePackRegistry + ++ (nullable id)packForLanguageCode:(NSString *)languageCode +{ + NSBundle *bundle = [NSBundle bundleForClass:[EventQuickEntryKeywords class]]; + NSString *primaryCode = [languageCode componentsSeparatedByString:@"-"].firstObject ?: languageCode; + + NSURL *url = [bundle URLForResource:@"EventQuickEntryKeywords" withExtension:@"strings" subdirectory:nil localization:primaryCode]; + if (!url && [primaryCode isEqualToString:@"en"]) { + // English keywords live in Base.lproj — the project's development- + // language folder — like the rest of the app's base-language + // resources; there's no separate en.lproj. + url = [bundle URLForResource:@"EventQuickEntryKeywords" withExtension:@"strings" subdirectory:nil localization:@"Base"]; + } + if (!url) return nil; + + EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords keywordsWithContentsOfStringsFileAtPath:url.path]; + if (!keywords) return nil; + + return [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; +} + +@end diff --git a/Itsycal/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h new file mode 100644 index 00000000..175cf230 --- /dev/null +++ b/Itsycal/EventQuickEntryParser.h @@ -0,0 +1,63 @@ +// +// EventQuickEntryParser.h +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import +#import "EventQuickEntryLanguagePack.h" + +NS_ASSUME_NONNULL_BEGIN + +// Values match the index of the corresponding item in EventViewController's +// _repPopup (EventViewController.m): 0 = None, 1 = Every Day, 2 = Every Week, +// 3 = Every 2 Weeks, 4 = Every Month, 5 = Every Year. +typedef NS_ENUM(NSInteger, EventQuickEntryRecurrence) { + EventQuickEntryRecurrenceNone = 0, + EventQuickEntryRecurrenceEveryDay = 1, + EventQuickEntryRecurrenceEveryWeek = 2, + EventQuickEntryRecurrenceEvery2Weeks = 3, + EventQuickEntryRecurrenceEveryMonth = 4, + EventQuickEntryRecurrenceEveryYear = 5, +}; + +// A stable, non-localized identifier for what kind of thing a span +// represents — distinct from `label`, which is display text. Consumers +// that need to branch on span type (e.g. picking a highlight color) +// should switch on `kind`, not compare `label` strings, since `label` +// is the one part of a span meant to vary by language pack. +typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { + EventQuickEntrySpanKindDate, + EventQuickEntrySpanKindDuration, + EventQuickEntrySpanKindLocation, + EventQuickEntrySpanKindRepeat, +}; + +@interface EventQuickEntrySpan : NSObject +@property (nonatomic) NSRange range; // into the original input string +@property (nonatomic) EventQuickEntrySpanKind kind; +@property (nonatomic, copy) NSString *label; // "Date", "Duration", "Location", "Repeat" +@property (nonatomic, copy) NSString *displayValue; // human-readable interpreted value +@end + +@interface EventQuickEntryResult : NSObject +@property (nonatomic, copy) NSString *title; +@property (nonatomic, nullable) NSDate *date; +@property (nonatomic) BOOL hasExplicitTime; +@property (nonatomic) NSInteger durationMinutes; // 0 == not detected +@property (nonatomic, copy, nullable) NSString *location; +@property (nonatomic) EventQuickEntryRecurrence recurrence; +@property (nonatomic, copy) NSArray *recognizedSpans; +@end + +@interface EventQuickEntryParser : NSObject +// Convenience initializer using the English language pack. +- (instancetype)init; +- (instancetype)initWithLanguagePack:(id)languagePack NS_DESIGNATED_INITIALIZER; +// Parses free-form text into title/date/duration/location/recurrence. +// `calendar` is used for all date component arithmetic. +- (EventQuickEntryResult *)parse:(NSString *)text calendar:(NSCalendar *)calendar; +@end + +NS_ASSUME_NONNULL_END diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m new file mode 100644 index 00000000..d6b9bc94 --- /dev/null +++ b/Itsycal/EventQuickEntryParser.m @@ -0,0 +1,92 @@ +// +// EventQuickEntryParser.m +// Itsycal +// +// Created by Scott Goci on 7/27/26. +// + +#import "EventQuickEntryParser.h" +#import "EventQuickEntryLanguagePackRegistry.h" + +@implementation EventQuickEntrySpan @end + +@implementation EventQuickEntryResult + +- (instancetype)init +{ + self = [super init]; + if (self) { + _title = @""; + _date = nil; + _hasExplicitTime = NO; + _durationMinutes = 0; + _location = nil; + _recurrence = EventQuickEntryRecurrenceNone; + _recognizedSpans = @[]; + } + return self; +} + +@end + +@implementation EventQuickEntryParser +{ + id _languagePack; +} + +- (instancetype)init +{ + return [self initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en"]]; +} + +- (instancetype)initWithLanguagePack:(id)languagePack +{ + self = [super init]; + if (self) { + _languagePack = languagePack; + } + return self; +} + +- (EventQuickEntryResult *)parse:(NSString *)text calendar:(NSCalendar *)calendar +{ + EventQuickEntryResult *result = [EventQuickEntryResult new]; + NSString *original = text ?: @""; + NSMutableString *masked = [original mutableCopy]; + NSMutableArray *recognizedSpans = [NSMutableArray new]; + + EventQuickEntrySpan *dateSpan = [_languagePack dateSpanInMasked:masked original:original result:result calendar:calendar]; + if (dateSpan) [recognizedSpans addObject:dateSpan]; + + EventQuickEntrySpan *durationSpan = [_languagePack durationSpanInMasked:masked result:result]; + if (durationSpan) [recognizedSpans addObject:durationSpan]; + + EventQuickEntrySpan *locationSpan = [_languagePack locationSpanInMasked:masked original:original result:result]; + if (locationSpan) [recognizedSpans addObject:locationSpan]; + + EventQuickEntrySpan *recurrenceSpan = [_languagePack recurrenceSpanInMasked:masked result:result]; + if (recurrenceSpan) [recognizedSpans addObject:recurrenceSpan]; + + result.title = [[self class] titleFromMaskedText:masked]; + result.recognizedSpans = recognizedSpans; + + return result; +} + ++ (NSString *)titleFromMaskedText:(NSString *)masked +{ + NSString *collapsed = [self collapseWhitespace:masked]; + return [collapsed stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + ++ (NSString *)collapseWhitespace:(NSString *)text +{ + static NSRegularExpression *whitespaceRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + whitespaceRegex = [NSRegularExpression regularExpressionWithPattern:@"\\s+" options:0 error:nil]; + }); + return [whitespaceRegex stringByReplacingMatchesInString:text options:0 range:NSMakeRange(0, text.length) withTemplate:@" "]; +} + +@end diff --git a/Itsycal/EventViewController.m b/Itsycal/EventViewController.m index f3ab5c6e..44152143 100644 --- a/Itsycal/EventViewController.m +++ b/Itsycal/EventViewController.m @@ -8,10 +8,13 @@ #import "EventViewController.h" #import "EventCenter.h" +#import "EventQuickEntryParser.h" +#import "EventQuickEntryLanguagePackRegistry.h" #import "MoThemeView.h" #import "MoVFLHelper.h" #import "NSMenuItem+NoImages.h" #import "Themer.h" +#import @interface HackyTextView : NSTextView // The placeholderAttributedString property in NSTextView @@ -39,6 +42,68 @@ - (BOOL)becomeFirstResponder } @end +// A lightweight, borderless tooltip window shown above the app's own +// window, which runs at NSMainMenuWindowLevel (see ItsycalWindow.m). +// AppKit's native tooltip mechanism (NSToolTipAttributeName / addToolTipRect:) +// draws its tooltip at a level too low to appear above a window already at +// menu-bar level, so it's invisible there — the same problem MoCalToolTipWC +// solves for calendar day-cell tooltips. This mirrors that approach. +@interface QuickEntryTooltipWindow : NSPanel +@property (nonatomic, readonly) NSTextField *label; +- (void)showText:(NSString *)text atScreenPoint:(NSPoint)screenPoint; +- (void)hide; +@end + +@implementation QuickEntryTooltipWindow + +- (instancetype)init +{ + self = [super initWithContentRect:NSZeroRect styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + if (self) { + self.backgroundColor = [NSColor clearColor]; + self.opaque = NO; + self.level = NSPopUpMenuWindowLevel; + self.ignoresMouseEvents = YES; + self.hasShadow = YES; + + NSView *background = [NSView new]; + background.wantsLayer = YES; + background.layer.backgroundColor = [NSColor controlBackgroundColor].CGColor; + background.layer.cornerRadius = 5; + background.layer.borderWidth = 1; + background.layer.borderColor = [NSColor separatorColor].CGColor; + self.contentView = background; + + _label = [NSTextField labelWithString:@""]; + _label.font = [NSFont toolTipsFontOfSize:0]; + _label.translatesAutoresizingMaskIntoConstraints = NO; + [background addSubview:_label]; + [NSLayoutConstraint activateConstraints:@[ + [_label.topAnchor constraintEqualToAnchor:background.topAnchor constant:4], + [_label.bottomAnchor constraintEqualToAnchor:background.bottomAnchor constant:-4], + [_label.leadingAnchor constraintEqualToAnchor:background.leadingAnchor constant:6], + [_label.trailingAnchor constraintEqualToAnchor:background.trailingAnchor constant:-6], + ]]; + } + return self; +} + +- (void)showText:(NSString *)text atScreenPoint:(NSPoint)screenPoint +{ + self.label.stringValue = text; + NSSize size = self.contentView.fittingSize; + NSRect frame = NSMakeRect(screenPoint.x + 10, screenPoint.y - size.height - 10, size.width, size.height); + [self setFrame:frame display:YES]; + [self orderFront:nil]; +} + +- (void)hide +{ + [self orderOut:nil]; +} + +@end + // These values map to _alertAllDayStrings and _alertRegularStrings. enum { kAlertAllDayNumOffsets = 5, @@ -66,7 +131,7 @@ - (BOOL)becomeFirstResponder @implementation EventViewController { - NSTextField *_title, *_location, *_url, *_repEndLabel; + NSTextField *_quickEntry, *_title, *_location, *_url, *_repEndLabel; NSButton *_allDayCheckbox, *_saveButton; NSDatePicker *_startDate, *_endDate, *_repEndDate; NSPopUpButton *_repPopup, *_repEndPopup, *_alertPopup, *_calPopup; @@ -75,6 +140,15 @@ @implementation EventViewController NSScrollView *_notesScrollView; NSLayoutConstraint *_notesScrollViewHeightConstraint; CGFloat _notesHeightOfOneLine; + EventQuickEntryParser *_quickEntryParser; + NSArray *_quickEntryRecognizedSpans; + QuickEntryTooltipWindow *_quickEntryTooltipWindow; +} + +- (nullable id)quickEntryLanguagePackForCurrentLocale +{ + NSString *language = [NSBundle mainBundle].preferredLocalizations.firstObject; + return [EventQuickEntryLanguagePackRegistry packForLanguageCode:language ?: @""]; } - (void)loadView @@ -130,6 +204,25 @@ - (void)loadView return btn; }; + // Rather than show a field that silently fails to understand phrases in + // unsupported languages, only create it when a language pack is + // registered for the app's active language (see EventQuickEntryParser.h). + id quickEntryLanguagePack = [self quickEntryLanguagePackForCurrentLocale]; + BOOL quickEntrySupported = quickEntryLanguagePack != nil; + if (quickEntrySupported) { + _quickEntry = txt(quickEntryLanguagePack.placeholderExample, YES); + _quickEntry.delegate = self; + _quickEntryParser = [[EventQuickEntryParser alloc] initWithLanguagePack:quickEntryLanguagePack]; + + // Drives our own tooltip window (see QuickEntryTooltipWindow above) since + // native tooltips can't appear above this app's NSMainMenuWindowLevel window. + NSTrackingArea *quickEntryTrackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect + options:NSTrackingMouseMoved | NSTrackingMouseEnteredAndExited | NSTrackingActiveInActiveApp | NSTrackingInVisibleRect + owner:self + userInfo:nil]; + [_quickEntry addTrackingArea:quickEntryTrackingArea]; + } + // Title, location, and URL text fields _title = txt(NSLocalizedString(@"New Event", @""), YES); _title.delegate = self; @@ -251,9 +344,19 @@ - (void)loadView _saveButton.enabled = NO; // we'll enable when the form is valid. NSButton *cancelButton = btn(NSLocalizedString(@"Cancel", @""), self, @selector(cancelOperation:)); - MoVFLHelper *vfl = [[MoVFLHelper alloc] initWithSuperview:v metrics:nil views:NSDictionaryOfVariableBindings(_title, _calPopup, _location, _allDayCheckbox, allDayLabel, startsLabel, endsLabel, _startDate, _endDate, repLabel, alertLabel, _repPopup, _repEndLabel, _repEndPopup, _repEndDate, _alertPopup, _notesScrollView, _url, cancelButton, _saveButton)]; + NSMutableDictionary *views = [NSDictionaryOfVariableBindings(_title, _calPopup, _location, _allDayCheckbox, allDayLabel, startsLabel, endsLabel, _startDate, _endDate, repLabel, alertLabel, _repPopup, _repEndLabel, _repEndPopup, _repEndDate, _alertPopup, _notesScrollView, _url, cancelButton, _saveButton) mutableCopy]; + if (quickEntrySupported) { + views[@"_quickEntry"] = _quickEntry; + } + MoVFLHelper *vfl = [[MoVFLHelper alloc] initWithSuperview:v metrics:nil views:views]; - [vfl :@"V:|-[_title]-[_location]-15-[_allDayCheckbox]"]; + if (quickEntrySupported) { + [vfl :@"V:|-[_quickEntry]-[_title]-[_location]-15-[_allDayCheckbox]"]; + [vfl :@"H:|-[_quickEntry]-|"]; + } + else { + [vfl :@"V:|-[_title]-[_location]-15-[_allDayCheckbox]"]; + } [vfl :@"V:[_allDayCheckbox]-[_startDate]-[_endDate]-[_repPopup]-[_repEndPopup]-[_alertPopup]" :NSLayoutFormatAlignAllLeading]; [vfl :@"V:[_alertPopup]-20-[_notesScrollView]-10-[_url]-15-[_saveButton]-|"]; [vfl :@"H:|-[_title]-[_calPopup]-|" :NSLayoutFormatAlignAllCenterY]; @@ -335,6 +438,7 @@ - (void)viewWillAppear // Initial values for form fields. _title.stringValue = @""; + _quickEntry.stringValue = @""; _location.stringValue = @""; _url.stringValue = @""; _notes.string = @""; @@ -440,16 +544,174 @@ - (NSInteger)defaultDurationInMinutesForNewEvent { - (void)cancelOperation:(id)sender { // User hit 'esc' or pressed Cancel button. + [_quickEntryTooltipWindow hide]; [self.enclosingPopover close]; } - (void)controlTextDidChange:(NSNotification *)obj +{ + if (obj.object == _quickEntry) { + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(applyQuickEntryParse) object:nil]; + [self performSelector:@selector(applyQuickEntryParse) withObject:nil afterDelay:0.2]; + return; + } + [self updateSaveButtonEnabledState]; +} + +- (void)updateSaveButtonEnabledState { // Enable the Save button if the title is non-whitespace. NSString *trimmedTitle = [_title.stringValue stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; _saveButton.enabled = ![trimmedTitle isEqualToString:@""]; } +- (void)applyQuickEntryParse +{ + // Spans are about to move/change; hide any tooltip showing stale content + // until the next mouse movement re-evaluates against the new spans. + [_quickEntryTooltipWindow hide]; + + EventQuickEntryResult *result = [_quickEntryParser parse:_quickEntry.stringValue calendar:self.cal]; + + if (result.date) { + NSCalendarUnit units = NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour | NSCalendarUnitMinute; + NSDateComponents *parsedComponents = [self.cal components:units fromDate:result.date]; + NSDateComponents *startComponents = [self.cal components:units fromDate:_startDate.dateValue]; + startComponents.year = parsedComponents.year; + startComponents.month = parsedComponents.month; + startComponents.day = parsedComponents.day; + if (result.hasExplicitTime) { + startComponents.hour = parsedComponents.hour; + startComponents.minute = parsedComponents.minute; + } + NSDate *newStart = [self.cal dateFromComponents:startComponents]; + _startDate.dateValue = newStart; + // Reuses the existing clamp/default-duration logic. + [self startDateChanged:_startDate]; + } + + if (result.durationMinutes > 0) { + _endDate.minDate = _startDate.dateValue; + _endDate.dateValue = [self.cal dateByAddingUnit:NSCalendarUnitMinute + value:result.durationMinutes + toDate:_startDate.dateValue + options:0]; + } + + if (result.location.length > 0) { + _location.stringValue = result.location; + } + + if (result.recurrence != EventQuickEntryRecurrenceNone) { + [_repPopup selectItemAtIndex:result.recurrence]; + [self repPopupChanged:_repPopup]; + } + + if (result.title.length > 0) { + _title.stringValue = result.title; + } + _quickEntryRecognizedSpans = result.recognizedSpans; + [self updateSaveButtonEnabledState]; + [self highlightRecognizedSpans:result.recognizedSpans]; +} + +- (nullable NSTextView *)activeQuickEntryFieldEditor +{ + // Only the active field editor has an NSTextStorage/NSLayoutManager we + // can safely use without disturbing the field's cursor position or undo + // stack. Returns nil when _quickEntry isn't the field currently being + // edited. + NSText *fieldEditor = [self.view.window fieldEditor:NO forObject:_quickEntry]; + return [fieldEditor isKindOfClass:[NSTextView class]] ? (NSTextView *)fieldEditor : nil; +} + +- (void)highlightRecognizedSpans:(NSArray *)spans +{ + NSTextView *textView = [self activeQuickEntryFieldEditor]; + if (!textView) return; + + NSTextStorage *storage = textView.textStorage; + NSRange fullRange = NSMakeRange(0, storage.length); + + // NSToolTipAttributeName isn't used here — native tooltips can't appear + // above this app's NSMainMenuWindowLevel window (see ItsycalWindow.m), + // so hover feedback is driven by QuickEntryTooltipWindow instead, via + // -mouseMoved:/-mouseExited: below. + [storage removeAttribute:NSUnderlineStyleAttributeName range:fullRange]; + [storage removeAttribute:NSUnderlineColorAttributeName range:fullRange]; + + for (EventQuickEntrySpan *span in spans) { + NSRange range = span.range; + if (NSMaxRange(range) > storage.length) continue; + + // Color-coded by span type so adjacent spans (e.g. "@ Cafe Luna on + // Tuesday") are visually distinguishable even when they abut with + // only a single space between them, which barely reads as a gap. + [storage addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:range]; + [storage addAttribute:NSUnderlineColorAttributeName value:[self colorForSpanKind:span.kind] range:range]; + } +} + +- (NSColor *)colorForSpanKind:(EventQuickEntrySpanKind)kind +{ + switch (kind) { + case EventQuickEntrySpanKindDate: return [NSColor systemBlueColor]; + case EventQuickEntrySpanKindDuration: return [NSColor systemOrangeColor]; + case EventQuickEntrySpanKindLocation: return [NSColor systemGreenColor]; + case EventQuickEntrySpanKindRepeat: return [NSColor systemPurpleColor]; + } + return [NSColor labelColor]; +} + +- (void)mouseMoved:(NSEvent *)event +{ + [self updateQuickEntryTooltipForEvent:event]; +} + +- (void)mouseExited:(NSEvent *)event +{ + [_quickEntryTooltipWindow hide]; +} + +- (void)updateQuickEntryTooltipForEvent:(NSEvent *)event +{ + NSTextView *textView = [self activeQuickEntryFieldEditor]; + if (!textView || _quickEntryRecognizedSpans.count == 0) { + [_quickEntryTooltipWindow hide]; + return; + } + + // boundingRectForGlyphRange:inTextContainer: and characterIndexForPoint: + // both operate in the text CONTAINER's coordinate space, which is offset + // from the view's own bounds by textContainerOrigin. + NSPoint pointInEditor = [textView convertPoint:event.locationInWindow fromView:nil]; + NSPoint pointInContainer = NSMakePoint(pointInEditor.x - textView.textContainerOrigin.x, + pointInEditor.y - textView.textContainerOrigin.y); + NSUInteger charIndex = [textView.layoutManager characterIndexForPoint:pointInContainer + inTextContainer:textView.textContainer + fractionOfDistanceBetweenInsertionPoints:NULL]; + + EventQuickEntrySpan *hitSpan = nil; + for (EventQuickEntrySpan *span in _quickEntryRecognizedSpans) { + if (NSLocationInRange(charIndex, span.range)) { + hitSpan = span; + break; + } + } + + if (!hitSpan) { + [_quickEntryTooltipWindow hide]; + return; + } + + if (!_quickEntryTooltipWindow) { + _quickEntryTooltipWindow = [QuickEntryTooltipWindow new]; + } + NSPoint screenPoint = [self.view.window convertPointToScreen:event.locationInWindow]; + NSString *text = [NSString stringWithFormat:@"%@: %@", hitSpan.label, hitSpan.displayValue]; + [_quickEntryTooltipWindow showText:text atScreenPoint:screenPoint]; +} + - (void)allDayClicked:(NSButton *)allDayCheckbox { // The All-day checkbox toggles the hour/minute controls of the date pickers. @@ -644,6 +906,7 @@ - (void)saveEvent:(id)sender [[NSAlert alertWithError:error] runModal]; } else { + [_quickEntryTooltipWindow hide]; [self.enclosingPopover close]; } } diff --git a/Itsycal/es.lproj/EventQuickEntryKeywords.strings b/Itsycal/es.lproj/EventQuickEntryKeywords.strings new file mode 100644 index 00000000..c26bb2d6 --- /dev/null +++ b/Itsycal/es.lproj/EventQuickEntryKeywords.strings @@ -0,0 +1,89 @@ +/* Keyword data for EventQuickEntryKeywordLanguagePack (see EventQuickEntryParser.h). + Array-valued entries are pipe-delimited ("word1|word2"). + + Best-effort translation, verified against real NSDataDetector output but + NOT reviewed by a native Spanish speaker — please review before relying + on it. + + locationPrefixWords deliberately omits "a" (too common a short + preposition; risks false-positive location matches). + + danglingPrefixWords: "el" and "esta" were individually verified via + NSDataDetector probing to get stranded before a date match (e.g. "el + próximo lunes", "esta noche"); "la"/"los"/"las"/"este" were added by + analogy, not each individually verified. + + explicitTimeWords deliberately excludes "mañana": it means both + "tomorrow" and "morning", and probing showed NSDataDetector treating it + as a plain date word (no specific hour) far more often than as a + time-of-day word — including it would make hasExplicitTime incorrectly + true for phrases that only mean "tomorrow". "tarde"/"mediodía"/ + "medianoche" are included by analogy with "noche" (individually + verified to resolve to a specific hour) but were not each verified. + + recurrencePhrases/recurrenceLabel match the app's existing + es.lproj/Localizable.strings translations for the repeat popup, so + quick-entry's tooltip text stays consistent with what the popup itself + already shows. + + nextWeekPhrase/nextMonthPhrase/nextYearPhrase/relativePeriodPrefixWord/ + relativePeriodFromNowSuffix/weekUnitWords/monthUnitWords/yearUnitWords/ + numberWords are plain translations, not verified against NSDataDetector + output — unlike the rest of this file, NSDataDetector plays no role in + recognizing these phrases (that's the point of them), so there was + nothing to probe. relativePeriodFromNowSuffix ("a partir de ahora") is a + literal translation of "from now"; a native speaker may prefer a + different construction (e.g. "dentro de dos semanas" as a prefix form + instead) — flagging for review rather than guessing further. */ + +"languageCode" = "es"; + +/* Duration */ +"durationPrefixWord" = "durante"; +"halfHourPhrase" = "media hora"; +"oneHourPhrases" = "una hora"; +"hourUnitWords" = "horas|hora"; +"minuteUnitWords" = "minutos|minuto"; + +/* Location */ +"locationPrefixWords" = "en"; + +/* Date */ +"danglingPrefixWords" = "el|la|los|las|esta|este"; +"mealWords" = "almuerzo|desayuno|cena"; +"explicitTimeWords" = "mediodía|medianoche|tarde|noche"; +"dateTimeConnector" = "a las"; + +/* Relative-period phrases NSDataDetector doesn't recognize on its own */ +"nextWeekPhrase" = "la próxima semana"; +"nextMonthPhrase" = "el próximo mes"; +"nextYearPhrase" = "el próximo año"; +"relativePeriodPrefixWord" = "en"; +"relativePeriodFromNowSuffix" = "a partir de ahora"; +"weekUnitWords" = "semanas|semana"; +"monthUnitWords" = "meses|mes"; +"yearUnitWords" = "años|año"; +"numberWords" = "uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce"; + +/* Recurrence phrases, keyed by frequency */ +"recurrencePhrases.every2Weeks" = "cada 2 semanas|cada dos semanas|quincenal"; +"recurrencePhrases.everyDay" = "todos los días|todos los dias|diariamente|diario"; +"recurrencePhrases.everyWeek" = "todas las semanas|semanalmente"; +"recurrencePhrases.everyMonth" = "todos los meses|mensualmente"; +"recurrencePhrases.everyYear" = "todos los años|todos los anos|anualmente"; + +/* Recurrence display labels, keyed by frequency (matches es.lproj/Localizable.strings) */ +"recurrenceLabel.every2Weeks" = "Cada 2 semanas"; +"recurrenceLabel.everyDay" = "Todos los días"; +"recurrenceLabel.everyWeek" = "Todas las semanas"; +"recurrenceLabel.everyMonth" = "Todos los meses"; +"recurrenceLabel.everyYear" = "Todos los años"; + +/* Span tooltip category labels */ +"dateSpanLabel" = "Fecha"; +"durationSpanLabel" = "Duración"; +"locationSpanLabel" = "Ubicación"; +"repeatSpanLabel" = "Repetir"; + +/* Quick-entry field placeholder */ +"placeholderExample" = "Reunión con Bob durante 30 min este viernes";