From 65ef87b3b4c1992967a19c0e27ff771cb5b18c06 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 12:42:01 -0400 Subject: [PATCH 01/16] Add EventQuickEntryParser: natural-language date/time/duration/location/recurrence parsing Parses free-form quick-entry text into title/date/time/duration/location/recurrence via NSDataDetector plus custom regexes, using a masked-text technique so each detection pass never re-matches text already claimed by an earlier pass. Includes an ItsycalTests target (none existed previously) and 37 unit tests covering each detector, span/highlight metadata, and several NSDataDetector edge cases (dangling prepositions, meal-word references, implied durations from time ranges). --- Itsycal.xcodeproj/project.pbxproj | 109 +++++++ Itsycal/EventQuickEntryParser.h | 57 ++++ Itsycal/EventQuickEntryParser.m | 334 ++++++++++++++++++++++ ItsycalTests/EventQuickEntryParserTests.m | 295 +++++++++++++++++++ 4 files changed, 795 insertions(+) create mode 100644 Itsycal/EventQuickEntryParser.h create mode 100644 Itsycal/EventQuickEntryParser.m create mode 100644 ItsycalTests/EventQuickEntryParserTests.m diff --git a/Itsycal.xcodeproj/project.pbxproj b/Itsycal.xcodeproj/project.pbxproj index a14a3178..172ff510 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 */; }; + B0B2E0000A00000AAA100000 /* EventQuickEntryParserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */; }; + B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; + B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; + B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0B2E0000F00000AAA100000 /* XCTest.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -171,6 +175,11 @@ 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 = ""; }; + B0B2E0000200000AAA100000 /* ItsycalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ItsycalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryParserTests.m; 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 = ""; }; + B0B2E0000F00000AAA100000 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -183,6 +192,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B0B2E0000700000AAA100000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -216,6 +233,7 @@ isa = PBXGroup; children = ( 9242A55E1A82A136005AA4B3 /* Itsycal */, + B0B2E0000800000AAA100000 /* ItsycalTests */, 9242A55D1A82A136005AA4B3 /* Products */, 92CF00001A82A136005AA4B3 /* Local.xcconfig */, ); @@ -225,6 +243,7 @@ isa = PBXGroup; children = ( 9242A55C1A82A136005AA4B3 /* Itsycal.app */, + B0B2E0000200000AAA100000 /* ItsycalTests.xctest */, ); name = Products; sourceTree = ""; @@ -265,6 +284,8 @@ 925B471A1A93CD2A00E0329A /* TooltipViewController.m */, 92658E601A9DBD8E002AE86C /* EventViewController.h */, 92658E611A9DBD8E002AE86C /* EventViewController.m */, + B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */, + B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */, 9261EB2E1EEF447500DCFFC4 /* Themer.h */, 9261EB2F1EEF447500DCFFC4 /* Themer.m */, 92B28CCD210BD17500485AE2 /* Sizer.h */, @@ -326,6 +347,14 @@ name = MoCode; sourceTree = ""; }; + B0B2E0000800000AAA100000 /* ItsycalTests */ = { + isa = PBXGroup; + children = ( + B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */, + ); + path = ItsycalTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -349,6 +378,22 @@ productReference = 9242A55C1A82A136005AA4B3 /* Itsycal.app */; productType = "com.apple.product-type.application"; }; + B0B2E0000100000AAA100000 /* ItsycalTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B0B2E0000300000AAA100000 /* Build configuration list for PBXNativeTarget "ItsycalTests" */; + buildPhases = ( + B0B2E0000600000AAA100000 /* Sources */, + B0B2E0000700000AAA100000 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ItsycalTests; + productName = ItsycalTests; + productReference = B0B2E0000200000AAA100000 /* ItsycalTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -389,6 +434,7 @@ projectRoot = ""; targets = ( 9242A55B1A82A136005AA4B3 /* Itsycal */, + B0B2E0000100000AAA100000 /* ItsycalTests */, ); }; /* End PBXProject section */ @@ -438,6 +484,7 @@ 920D6D51266A83AC00498E19 /* MoLoginItem.m in Sources */, 9242A5921A82A40B005AA4B3 /* MoCalGrid.m in Sources */, 92658E621A9DBD8E002AE86C /* EventViewController.m in Sources */, + B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */, 922232CD1A8DF672003BFAD9 /* EventCenter.m in Sources */, 920B5D7C2B4D10B6004A10D3 /* DatePickerVC.m in Sources */, 926A6D8F1E3EC89100AAA991 /* PrefsVC.m in Sources */, @@ -470,6 +517,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B0B2E0000600000AAA100000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B0B2E0000A00000AAA100000 /* EventQuickEntryParserTests.m in Sources */, + B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -679,6 +735,50 @@ }; name = Release; }; + B0B2E0000400000AAA100000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu99; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mowglii.ItsycalTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + }; + name = Debug; + }; + B0B2E0000500000AAA100000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu99; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mowglii.ItsycalTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -700,6 +800,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B0B2E0000300000AAA100000 /* Build configuration list for PBXNativeTarget "ItsycalTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B0B2E0000400000AAA100000 /* Debug */, + B0B2E0000500000AAA100000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 9242A5541A82A136005AA4B3 /* Project object */; diff --git a/Itsycal/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h new file mode 100644 index 00000000..7401dfc8 --- /dev/null +++ b/Itsycal/EventQuickEntryParser.h @@ -0,0 +1,57 @@ +// +// EventQuickEntryParser.h +// Itsycal +// + +#import + +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 eventually vary by locale. +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 +// 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..f19dca8e --- /dev/null +++ b/Itsycal/EventQuickEntryParser.m @@ -0,0 +1,334 @@ +// +// EventQuickEntryParser.m +// Itsycal +// + +#import "EventQuickEntryParser.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 + +- (EventQuickEntryResult *)parse:(NSString *)text calendar:(NSCalendar *)calendar +{ + EventQuickEntryResult *result = [EventQuickEntryResult new]; + NSString *original = text ?: @""; + NSMutableString *masked = [original mutableCopy]; + NSMutableArray *recognizedSpans = [NSMutableArray new]; + + EventQuickEntrySpan *dateSpan = [self applyDateDetectionToMasked:masked original:original result:result calendar:calendar]; + if (dateSpan) [recognizedSpans addObject:dateSpan]; + + EventQuickEntrySpan *durationSpan = [self applyDurationDetectionToMasked:masked result:result]; + if (durationSpan) [recognizedSpans addObject:durationSpan]; + + EventQuickEntrySpan *locationSpan = [self applyLocationDetectionToMasked:masked result:result]; + if (locationSpan) [recognizedSpans addObject:locationSpan]; + + EventQuickEntrySpan *recurrenceSpan = [self applyRecurrenceDetectionToMasked:masked result:result]; + if (recurrenceSpan) [recognizedSpans addObject:recurrenceSpan]; + + result.title = [self titleFromMaskedText:masked]; + result.recognizedSpans = recognizedSpans; + + return result; +} + +#pragma mark - Date/time + +- (EventQuickEntrySpan *)applyDateDetectionToMasked:(NSMutableString *)masked + original:(NSString *)original + result:(EventQuickEntryResult *)result + calendar:(NSCalendar *)calendar +{ + 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" phrase (handled + // later in the pipeline) can still unconditionally override it. + if (match.duration > 0) { + result.durationMinutes = (NSInteger)round(match.duration / 60.0); + } + + NSRange rangeToBlank = [self rangeToBlankForDateMatch:match inOriginal:original]; + [self blankRange:rangeToBlank inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = rangeToBlank; + span.kind = EventQuickEntrySpanKindDate; + span.label = @"Date"; + span.displayValue = [self displayValueForDate:result.date hasExplicitTime:result.hasExplicitTime calendar:calendar]; + return span; +} + +// 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 ("Lunch", "Dinner") into its own match as a time-of-day reference, +// and it never includes a dangling leading preposition ("on Friday") that +// precedes the match. This adjusts for both before anything gets blanked. +- (NSRange)rangeToBlankForDateMatch:(NSTextCheckingResult *)match inOriginal:(NSString *)original +{ + NSRange coreRange = match.range; + static NSRegularExpression *mealWordRegex; + static dispatch_once_t mealWordOnceToken; + dispatch_once(&mealWordOnceToken, ^{ + mealWordRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)^\\b(?:lunch|breakfast|dinner|brunch)\\b\\s*" + options:0 + error:nil]; + }); + 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; + + static NSRegularExpression *danglingOnRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + danglingOnRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\bon\\s+$" + options:0 + error:nil]; + }); + + NSTextCheckingResult *onMatch = [danglingOnRegex firstMatchInString:original + options:0 + range:NSMakeRange(0, coreRange.location)]; + if (onMatch) { + rangeToBlank = NSMakeRange(onMatch.range.location, NSMaxRange(coreRange) - onMatch.range.location); + } + + return rangeToBlank; +} + +- (NSString *)displayValueForDate:(NSDate *)date hasExplicitTime:(BOOL)hasExplicitTime calendar:(NSCalendar *)calendar +{ + NSDateFormatter *dateFormatter = [NSDateFormatter new]; + dateFormatter.calendar = calendar; + [dateFormatter setLocalizedDateFormatFromTemplate:@"EEEEMMMMd"]; + NSString *dateString = [dateFormatter stringFromDate:date]; + + if (!hasExplicitTime) return dateString; + + NSDateFormatter *timeFormatter = [NSDateFormatter new]; + timeFormatter.calendar = calendar; + timeFormatter.dateStyle = NSDateFormatterNoStyle; + timeFormatter.timeStyle = NSDateFormatterShortStyle; + NSString *timeString = [timeFormatter stringFromDate:date]; + + return [NSString stringWithFormat:@"%@ at %@", dateString, timeString]; +} + +- (BOOL)textContainsExplicitTime:(NSString *)text +{ + static NSRegularExpression *timeRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + timeRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b\\d{1,2}(:\\d{2})?\\s*(am|pm|a\\.m\\.|p\\.m\\.)\\b|\\b\\d{1,2}:\\d{2}\\b|\\bnoon\\b|\\bmidnight\\b|\\bmorning\\b|\\bafternoon\\b|\\bevening\\b" + options:0 + error:nil]; + }); + return [timeRegex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)] != nil; +} + +#pragma mark - Duration + +- (EventQuickEntrySpan *)applyDurationDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + static NSRegularExpression *durationRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + durationRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\bfor\\s+half\\s+an\\s+hour\\b|\\bfor\\s+(?:a|an)\\s+hour\\b|\\bfor\\s+(\\d+)\\s*(?:hours|hour|hrs|hr)\\b|\\bfor\\s+(\\d+)\\s*(?:minutes|minute|mins|min)\\b" + options:0 + error:nil]; + }); + + NSTextCheckingResult *match = [durationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (!match) return nil; + + NSString *matchedText = [masked substringWithRange:match.range]; + NSString *lowerMatched = matchedText.lowercaseString; + NSInteger minutes = 0; + if ([lowerMatched containsString:@"half"]) { + minutes = 30; + } else if ([lowerMatched hasSuffix:@"hour"]) { + minutes = 60; // "for a hour" / "for an hour" + } else if (match.numberOfRanges > 1 && [match rangeAtIndex:1].location != NSNotFound) { + minutes = [[masked substringWithRange:[match rangeAtIndex:1]] integerValue] * 60; + } else if (match.numberOfRanges > 2 && [match rangeAtIndex:2].location != NSNotFound) { + minutes = [[masked substringWithRange:[match rangeAtIndex:2]] integerValue]; + } + + result.durationMinutes = minutes; + [self blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindDuration; + span.label = @"Duration"; + span.displayValue = [self displayValueForDurationMinutes:minutes]; + return span; +} + +- (NSString *)displayValueForDurationMinutes:(NSInteger)minutes +{ + NSInteger hours = minutes / 60; + NSInteger remainder = minutes % 60; + + if (hours == 0) { + return [NSString stringWithFormat:@"%ld minutes", (long)remainder]; + } + NSString *hoursPart = (hours == 1) ? @"1 hour" : [NSString stringWithFormat:@"%ld hours", (long)hours]; + if (remainder == 0) { + return hoursPart; + } + return [NSString stringWithFormat:@"%@ %ld minutes", hoursPart, (long)remainder]; +} + +#pragma mark - Location + +- (EventQuickEntrySpan *)applyLocationDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + static NSRegularExpression *locationRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + locationRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)(?:\\b(?:at|in)|@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:at|in|@))?\\s*$" + options:0 + error:nil]; + }); + + NSTextCheckingResult *match = [locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (!match) return nil; + + NSRange placeRange = [match rangeAtIndex:1]; + if (placeRange.location == NSNotFound) return nil; + + NSString *place = [[masked substringWithRange:placeRange] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (place.length == 0) return nil; + + result.location = place; + [self blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindLocation; + span.label = @"Location"; + span.displayValue = place; + return span; +} + +#pragma mark - Recurrence + +- (EventQuickEntrySpan *)applyRecurrenceDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + static NSArray *recurrenceRegexes; + static NSArray *recurrenceValues; + static NSArray *recurrenceLabels; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + // "every other week"/"biweekly" is checked before the plain + // "every week" pattern so it isn't shadowed by it. + NSArray *patterns = @[ + @"(?i)\\bevery\\s+other\\s+week\\b|\\bbiweekly\\b", + @"(?i)\\bevery\\s+day\\b|\\bdaily\\b", + @"(?i)\\bevery\\s+week\\b|\\bweekly\\b", + @"(?i)\\bevery\\s+month\\b|\\bmonthly\\b", + @"(?i)\\bevery\\s+year\\b|\\byearly\\b|\\bannually\\b", + ]; + NSMutableArray *regexes = [NSMutableArray new]; + for (NSString *pattern in patterns) { + [regexes addObject:[NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]]; + } + recurrenceRegexes = regexes; + recurrenceValues = @[ + @(EventQuickEntryRecurrenceEvery2Weeks), + @(EventQuickEntryRecurrenceEveryDay), + @(EventQuickEntryRecurrenceEveryWeek), + @(EventQuickEntryRecurrenceEveryMonth), + @(EventQuickEntryRecurrenceEveryYear), + ]; + // Index-parallel with recurrenceValues; must match EventViewController's + // _repPopup item titles exactly (EventViewController.m). + recurrenceLabels = @[ + @"Every 2 Weeks", + @"Every Day", + @"Every Week", + @"Every Month", + @"Every Year", + ]; + }); + + 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 blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindRepeat; + span.label = @"Repeat"; + span.displayValue = recurrenceLabels[i]; + return span; + } + } + return nil; +} + +#pragma mark - Title / helpers + +- (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:@" "]; +} + +- (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked +{ + NSString *blank = [@"" stringByPaddingToLength:range.length withString:@" " startingAtIndex:0]; + [masked replaceCharactersInRange:range withString:blank]; +} + +@end diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m new file mode 100644 index 00000000..795c77d5 --- /dev/null +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -0,0 +1,295 @@ +// +// EventQuickEntryParserTests.m +// ItsycalTests +// + +#import +#import "EventQuickEntryParser.h" + +@interface EventQuickEntryParserTests : XCTestCase +@property (nonatomic) EventQuickEntryParser *parser; +@property (nonatomic) NSCalendar *calendar; +@end + +@implementation EventQuickEntryParserTests + +- (void)setUp +{ + self.parser = [EventQuickEntryParser new]; + self.calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; +} + +- (void)testPlainTextBecomesTitleWithNoOtherFieldsSet +{ + EventQuickEntryResult *result = [self.parser parse:@"Team standup" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Team standup"); + XCTAssertNil(result.date); + XCTAssertEqual(result.durationMinutes, 0); + XCTAssertNil(result.location); + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); + XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)0); +} + +- (void)testDetectsDateOnlyPhraseWithoutExplicitTime +{ + EventQuickEntryResult *result = [self.parser parse:@"Dentist tomorrow" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Dentist"); + XCTAssertNotNil(result.date); + XCTAssertFalse(result.hasExplicitTime); + XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); +} + +- (void)testDetectsDateWithExplicitTime +{ + EventQuickEntryResult *result = [self.parser parse:@"Call mom tomorrow at 3pm" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Call mom"); + XCTAssertNotNil(result.date); + XCTAssertTrue(result.hasExplicitTime); +} + +- (void)testDanglingOnPrepositionBeforeDateIsConsumed +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting with Michele on Friday at 6PM for 10 minutes" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Meeting with Michele"); + XCTAssertNotNil(result.date); + XCTAssertTrue(result.hasExplicitTime); + XCTAssertEqual(result.durationMinutes, 10); +} + +- (void)testNoDatePhraseLeavesDateNil +{ + EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; + XCTAssertNil(result.date); + XCTAssertFalse(result.hasExplicitTime); +} + +- (void)testDetectsDurationInMinutes +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting for 30 minutes" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Meeting"); + XCTAssertEqual(result.durationMinutes, 30); +} + +- (void)testDetectsDurationInHours +{ + EventQuickEntryResult *result = [self.parser parse:@"Workshop for 2 hours" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Workshop"); + XCTAssertEqual(result.durationMinutes, 120); +} + +- (void)testDetectsHalfAnHour +{ + EventQuickEntryResult *result = [self.parser parse:@"Sync for half an hour" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Sync"); + XCTAssertEqual(result.durationMinutes, 30); +} + +- (void)testDetectsAnHour +{ + EventQuickEntryResult *result = [self.parser parse:@"1:1 for an hour" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"1:1"); + XCTAssertEqual(result.durationMinutes, 60); +} + +- (void)testDurationAndDateTogether +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting with Michele Goci for 30 minutes this Friday" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Meeting with Michele Goci"); + XCTAssertEqual(result.durationMinutes, 30); + XCTAssertNotNil(result.date); +} + +- (void)testDetectsLocationWithAt +{ + EventQuickEntryResult *result = [self.parser parse:@"Lunch at Cafe Luna" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Lunch"); + XCTAssertEqualObjects(result.location, @"Cafe Luna"); +} + +- (void)testDetectsLocationWithIn +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup in Conference Room B" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Standup"); + XCTAssertEqualObjects(result.location, @"Conference Room B"); +} + +- (void)testDisambiguatesAtTimeFromAtLocation +{ + EventQuickEntryResult *result = [self.parser parse:@"Lunch with Sam at Cafe Luna at noon" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Lunch with Sam"); + XCTAssertEqualObjects(result.location, @"Cafe Luna"); + XCTAssertNotNil(result.date); + XCTAssertTrue(result.hasExplicitTime); +} + +- (void)testNoLocationPhraseLeavesLocationNil +{ + EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; + XCTAssertNil(result.location); +} + +- (void)testDetectsEveryDay +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup every day" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Standup"); + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); +} + +- (void)testDetectsDaily +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup daily" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); +} + +- (void)testDetectsEveryWeek +{ + EventQuickEntryResult *result = [self.parser parse:@"1:1 every week" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"1:1"); + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryWeek); +} + +- (void)testDetectsEveryOtherWeek +{ + EventQuickEntryResult *result = [self.parser parse:@"Sprint planning every other week" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Sprint planning"); + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEvery2Weeks); +} + +- (void)testDetectsBiweekly +{ + EventQuickEntryResult *result = [self.parser parse:@"Sprint planning biweekly" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEvery2Weeks); +} + +- (void)testDetectsEveryMonth +{ + EventQuickEntryResult *result = [self.parser parse:@"Rent due every month" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryMonth); +} + +- (void)testDetectsEveryYear +{ + EventQuickEntryResult *result = [self.parser parse:@"Anniversary every year" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryYear); +} + +- (void)testNoRecurrencePhraseLeavesRecurrenceNone +{ + EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); +} + +- (void)testDetectsLocationWithAtSign +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup @ Cafe Luna" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Standup"); + XCTAssertEqualObjects(result.location, @"Cafe Luna"); +} + +- (void)testAtSignWithoutSpaceIsNotRecognizedAsLocation +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup @Cafe Luna" calendar:self.calendar]; + XCTAssertNil(result.location); + XCTAssertEqualObjects(result.title, @"Standup @Cafe Luna"); +} + +- (void)testDateSpanHasLabelAndDisplayValueWithoutExplicitTime +{ + EventQuickEntryResult *result = [self.parser parse:@"Dentist tomorrow" calendar:self.calendar]; + XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Date"); + XCTAssertFalse([span.displayValue containsString:@":"]); // no time-of-day rendered +} + +- (void)testDateSpanHasDisplayValueWithExplicitTime +{ + EventQuickEntryResult *result = [self.parser parse:@"Call mom tomorrow at 3pm" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Date"); + XCTAssertTrue([span.displayValue containsString:@"at"]); +} + +- (void)testDurationSpanDisplayValueMinutesOnly +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting for 30 minutes" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Duration"); + XCTAssertEqualObjects(span.displayValue, @"30 minutes"); +} + +- (void)testDurationSpanDisplayValueHoursOnly +{ + EventQuickEntryResult *result = [self.parser parse:@"Workshop for 2 hours" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.displayValue, @"2 hours"); +} + +- (void)testDurationSpanDisplayValueHoursAndMinutes +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting for 90 minutes" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.displayValue, @"1 hour 30 minutes"); +} + +- (void)testLocationSpanDisplayValueIsThePlace +{ + EventQuickEntryResult *result = [self.parser parse:@"Lunch at Cafe Luna" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Location"); + XCTAssertEqualObjects(span.displayValue, @"Cafe Luna"); +} + +- (void)testRecurrenceSpanDisplayValueMatchesRepPopupTitle +{ + EventQuickEntryResult *result = [self.parser parse:@"Standup every day" calendar:self.calendar]; + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Repeat"); + XCTAssertEqualObjects(span.displayValue, @"Every Day"); +} + +- (void)testUsesImpliedDurationFromTimeRange +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting 3-4pm Friday" calendar:self.calendar]; + XCTAssertEqual(result.durationMinutes, 60); + XCTAssertTrue(result.hasExplicitTime); +} + +- (void)testExplicitForPhraseWinsOverImpliedDuration +{ + EventQuickEntryResult *result = [self.parser parse:@"Meeting 3-4pm for 30 minutes" calendar:self.calendar]; + XCTAssertEqual(result.durationMinutes, 30); +} + +- (void)testHasExplicitTimeForMorning +{ + EventQuickEntryResult *result = [self.parser parse:@"Coffee tomorrow morning" calendar:self.calendar]; + XCTAssertTrue(result.hasExplicitTime); + NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; + XCTAssertEqual(hour, 9); +} + +- (void)testHasExplicitTimeForAfternoon +{ + EventQuickEntryResult *result = [self.parser parse:@"Coffee tomorrow afternoon" calendar:self.calendar]; + XCTAssertTrue(result.hasExplicitTime); + NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; + XCTAssertEqual(hour, 15); +} + +- (void)testHasExplicitTimeForEvening +{ + EventQuickEntryResult *result = [self.parser parse:@"Call tomorrow evening" calendar:self.calendar]; + XCTAssertTrue(result.hasExplicitTime); + NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; + XCTAssertEqual(hour, 18); +} + +- (void)testRestoresLeadingMealWordSwallowedByDateMatch +{ + EventQuickEntryResult *result = [self.parser parse:@"Lunch tomorrow afternoon" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Lunch"); + XCTAssertNotNil(result.date); +} + +@end From 116a15c43589af3a79ea5d5aec26193326b5b384 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 12:42:01 -0400 Subject: [PATCH 02/16] Wire quick-entry field into the New Event popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a quick-entry text field above Title in the New Event form. Typing a phrase debounces into a parse via EventQuickEntryParser and applies the result onto the existing form controls, only overwriting a field when the parser actually found a value for it. Recognized spans are underlined and color-coded by type, with a hover tooltip explaining what was understood — using a custom tooltip window rather than AppKit's native tooltip mechanism, since native tooltips render at a level too low to appear above this app's own NSMainMenuWindowLevel window (see ItsycalWindow.m). The field is only shown when the app's active language resolves to English, since the parser's keyword/date matching is English-only. --- Itsycal/EventViewController.m | 268 +++++++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 3 deletions(-) diff --git a/Itsycal/EventViewController.m b/Itsycal/EventViewController.m index f3ab5c6e..93fd1263 100644 --- a/Itsycal/EventViewController.m +++ b/Itsycal/EventViewController.m @@ -8,10 +8,12 @@ #import "EventViewController.h" #import "EventCenter.h" +#import "EventQuickEntryParser.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 +41,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 +130,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 +139,15 @@ @implementation EventViewController NSScrollView *_notesScrollView; NSLayoutConstraint *_notesScrollViewHeightConstraint; CGFloat _notesHeightOfOneLine; + EventQuickEntryParser *_quickEntryParser; + NSArray *_quickEntryRecognizedSpans; + QuickEntryTooltipWindow *_quickEntryTooltipWindow; +} + +- (BOOL)quickEntrySupportedForCurrentLocale +{ + NSString *language = [NSBundle mainBundle].preferredLocalizations.firstObject; + return [language hasPrefix:@"en"]; } - (void)loadView @@ -130,6 +203,25 @@ - (void)loadView return btn; }; + // The quick-entry parser's keyword/date matching is English-only (see + // EventQuickEntryParser.m). Rather than show a field that silently fails + // to understand phrases in other languages, only create it when the + // app's active language resolves to English. + BOOL quickEntrySupported = [self quickEntrySupportedForCurrentLocale]; + if (quickEntrySupported) { + _quickEntry = txt(NSLocalizedString(@"Meeting with Bob for 30 min this Friday", @""), YES); + _quickEntry.delegate = self; + _quickEntryParser = [EventQuickEntryParser new]; + + // 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 +343,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 +437,7 @@ - (void)viewWillAppear // Initial values for form fields. _title.stringValue = @""; + _quickEntry.stringValue = @""; _location.stringValue = @""; _url.stringValue = @""; _notes.string = @""; @@ -440,16 +543,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 +905,7 @@ - (void)saveEvent:(id)sender [[NSAlert alertWithError:error] runModal]; } else { + [_quickEntryTooltipWindow hide]; [self.enclosingPopover close]; } } From e6fab9e5f80062c1e6feb5c7d8b1f3bd73de8daf Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:00:21 -0400 Subject: [PATCH 03/16] Add extensible language pack architecture, with English and Spanish packs Replaces the hardcoded English-only regexes in EventQuickEntryParser with an EventQuickEntryLanguagePack protocol, so a new language can be added without touching the parser's core logic. EventQuickEntryKeywordLanguagePack is a generic protocol implementation driven by an EventQuickEntryKeywords config (duration/location/recurrence words, dangling prepositions, meal words, explicit-time words, tooltip labels, a placeholder example) - most languages only need to supply words, not new matching logic. A language whose grammar doesn't fit this shape can implement the protocol directly instead. EventQuickEntryEnglishLanguagePack and EventQuickEntrySpanishLanguagePack prove the abstraction. The English pack's keywords are transcribed directly from the prior hardcoded regexes, and all 37 pre-existing tests pass completely unchanged against the refactored implementation - the primary regression guardrail for this change. The Spanish pack's keyword choices (dangling articles, meal words, explicit-time words) are grounded in direct NSDataDetector probing, not assumption, but are a best-effort translation, not reviewed by a native speaker. EventQuickEntryLanguagePackRegistry resolves a language code to a pack (or nil); EventViewController's locale gate now asks the registry instead of hardcoding an English-only check, and the quick-entry field's placeholder text is sourced from the active pack instead of being hardcoded English. 13 new tests: 3 for the registry, 10 for the Spanish pack. --- Itsycal/EventQuickEntryParser.h | 116 ++++- Itsycal/EventQuickEntryParser.m | 520 +++++++++++++++------- Itsycal/EventViewController.m | 18 +- ItsycalTests/EventQuickEntryParserTests.m | 104 +++++ 4 files changed, 592 insertions(+), 166 deletions(-) diff --git a/Itsycal/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h index 7401dfc8..53a0ecdb 100644 --- a/Itsycal/EventQuickEntryParser.h +++ b/Itsycal/EventQuickEntryParser.h @@ -23,7 +23,7 @@ typedef NS_ENUM(NSInteger, EventQuickEntryRecurrence) { // 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 eventually vary by locale. +// is the one part of a span meant to vary by language pack. typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { EventQuickEntrySpanKindDate, EventQuickEntrySpanKindDuration, @@ -48,7 +48,121 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { @property (nonatomic, copy) NSArray *recognizedSpans; @end +// 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 result:(EventQuickEntryResult *)result; +- (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; +@end + +// 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). +@property (nonatomic, copy) NSArray *hourUnitWords; +// Words for "minutes", e.g. @[@"minutes", @"minute", @"mins", @"min"] (en) / @[@"minutos", @"minuto"] (es). +@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; +// Example phrase shown as the quick-entry field's placeholder text. +@property (nonatomic, copy) NSString *placeholderExample; +@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. +// Subclass this and override -init to supply keywords for a new +// keyword-based language (see EventQuickEntryEnglishLanguagePack). +@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 + +@interface EventQuickEntryEnglishLanguagePack : EventQuickEntryKeywordLanguagePack +@end + +// Best-effort Spanish keyword set, verified against real NSDataDetector +// output but not reviewed by a native speaker — see the design doc. +@interface EventQuickEntrySpanishLanguagePack : EventQuickEntryKeywordLanguagePack +@end + +@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. Matches by prefix, so "en-US"/"en-GB"/etc. all resolve to the +// English pack. ++ (nullable id)packForLanguageCode:(NSString *)languageCode; +@end + @interface EventQuickEntryParser : NSObject +// Convenience initializer using EventQuickEntryEnglishLanguagePack. +- (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; diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index f19dca8e..42573911 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -27,39 +27,157 @@ - (instancetype)init @end -@implementation EventQuickEntryParser +@implementation EventQuickEntryKeywords +@end -- (EventQuickEntryResult *)parse:(NSString *)text calendar:(NSCalendar *)calendar +#pragma mark - EventQuickEntryKeywordLanguagePack + +@implementation EventQuickEntryKeywordLanguagePack { - EventQuickEntryResult *result = [EventQuickEntryResult new]; - NSString *original = text ?: @""; - NSMutableString *masked = [original mutableCopy]; - NSMutableArray *recognizedSpans = [NSMutableArray new]; + EventQuickEntryKeywords *_keywords; + NSRegularExpression *_timeRegex; + NSRegularExpression *_durationRegex; + NSRegularExpression *_mealWordRegex; // nil if no meal words configured + NSRegularExpression *_danglingPrefixRegex; // nil if no dangling words configured + NSRegularExpression *_locationRegex; + NSArray *_recurrenceRegexes; // ordered: every2Weeks, everyDay, everyWeek, everyMonth, everyYear + NSArray *_recurrenceValues; // parallel EventQuickEntryRecurrence values + NSArray *_recurrenceLabels; // parallel display labels +} - EventQuickEntrySpan *dateSpan = [self applyDateDetectionToMasked:masked original:original result:result calendar:calendar]; - if (dateSpan) [recognizedSpans addObject:dateSpan]; +- (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]; + } + return self; +} - EventQuickEntrySpan *durationSpan = [self applyDurationDetectionToMasked:masked result:result]; - if (durationSpan) [recognizedSpans addObject:durationSpan]; +#pragma mark Regex construction - EventQuickEntrySpan *locationSpan = [self applyLocationDetectionToMasked:masked result:result]; - if (locationSpan) [recognizedSpans addObject:locationSpan]; ++ (NSString *)alternationPatternForPhrases:(NSArray *)phrases +{ + NSMutableArray *escaped = [NSMutableArray new]; + for (NSString *phrase in phrases) { + [escaped addObject:[NSRegularExpression escapedPatternForString:phrase]]; + } + return [escaped componentsJoinedByString:@"|"]; +} - EventQuickEntrySpan *recurrenceSpan = [self applyRecurrenceDetectionToMasked:masked result:result]; - if (recurrenceSpan) [recognizedSpans addObject:recurrenceSpan]; +- (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. + NSString *wordsAlt = [[self class] alternationPatternForPhrases:keywords.explicitTimeWords]; + NSString *wordsBranch = wordsAlt.length > 0 ? [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]; +} - result.title = [self titleFromMaskedText:masked]; - result.recognizedSpans = recognizedSpans; +- (void)buildDurationRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + NSString *forWord = [NSRegularExpression escapedPatternForString:keywords.durationPrefixWord]; + NSString *halfHour = [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]; + + // Four alternatives, each with its own capture group so the matching + // branch can be identified without relying on language-specific + // substring checks (e.g. English "half"/"hour") on the matched text. + 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]; +} - return result; +- (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 +{ + NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; + // "@" is always recognized as a location prefix regardless of language. + // 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). + NSString *pattern = [NSString stringWithFormat:@"(?i)(?:\\b(?:%@)|@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@|@))?\\s*$", prefixAlt, prefixAlt]; + _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern 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 = @[@"every2Weeks", @"everyDay", @"everyWeek", @"everyMonth", @"everyYear"]; + NSArray *orderedValues = @[ + @(EventQuickEntryRecurrenceEvery2Weeks), + @(EventQuickEntryRecurrenceEveryDay), + @(EventQuickEntryRecurrenceEveryWeek), + @(EventQuickEntryRecurrenceEveryMonth), + @(EventQuickEntryRecurrenceEveryYear), + ]; + NSMutableArray *regexes = [NSMutableArray new]; + NSMutableArray *labels = [NSMutableArray new]; + for (NSString *key in orderedKeys) { + NSArray *phrases = keywords.recurrencePhrasesByFrequencyKey[key] ?: @[]; + NSString *alt = [[self class] alternationPatternForPhrases:phrases]; + NSString *pattern = [NSString stringWithFormat:@"(?i)\\b(?:%@)\\b", alt]; + [regexes addObject:[NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]]; + [labels addObject:keywords.recurrenceLabelsByFrequencyKey[key] ?: @""]; + } + _recurrenceRegexes = regexes; + _recurrenceValues = orderedValues; + _recurrenceLabels = labels; +} + +#pragma mark EventQuickEntryLanguagePack — placeholder + +- (NSString *)placeholderExample +{ + return _keywords.placeholderExample; } -#pragma mark - Date/time +#pragma mark Shared masking utility -- (EventQuickEntrySpan *)applyDateDetectionToMasked:(NSMutableString *)masked - original:(NSString *)original - result:(EventQuickEntryResult *)result - calendar:(NSCalendar *)calendar ++ (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked +{ + NSString *blank = [@"" stringByPaddingToLength:range.length withString:@" " startingAtIndex:0]; + [masked replaceCharactersInRange:range withString:blank]; +} + +#pragma mark EventQuickEntryLanguagePack — Date/time + +- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked + original:(NSString *)original + result:(EventQuickEntryResult *)result + calendar:(NSCalendar *)calendar { NSError *error = nil; NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error]; @@ -75,68 +193,59 @@ - (EventQuickEntrySpan *)applyDateDetectionToMasked:(NSMutableString *)masked 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" phrase (handled - // later in the pipeline) can still unconditionally override it. + // "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 blankRange:rangeToBlank inMasked:masked]; + [[self class] blankRange:rangeToBlank inMasked:masked]; EventQuickEntrySpan *span = [EventQuickEntrySpan new]; span.range = rangeToBlank; span.kind = EventQuickEntrySpanKindDate; - span.label = @"Date"; + span.label = _keywords.dateSpanLabel; span.displayValue = [self displayValueForDate:result.date hasExplicitTime:result.hasExplicitTime calendar:calendar]; return span; } // 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 ("Lunch", "Dinner") into its own match as a time-of-day reference, -// and it never includes a dangling leading preposition ("on Friday") that -// precedes the match. This adjusts for both before anything gets blanked. +// 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; - static NSRegularExpression *mealWordRegex; - static dispatch_once_t mealWordOnceToken; - dispatch_once(&mealWordOnceToken, ^{ - mealWordRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)^\\b(?:lunch|breakfast|dinner|brunch)\\b\\s*" - options:0 - error:nil]; - }); - NSTextCheckingResult *mealMatch = [mealWordRegex firstMatchInString:original options:0 range:match.range]; - if (mealMatch) { - NSUInteger newLocation = NSMaxRange(mealMatch.range); - coreRange = NSMakeRange(newLocation, NSMaxRange(match.range) - newLocation); + 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; - - static NSRegularExpression *danglingOnRegex; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - danglingOnRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\bon\\s+$" - options:0 - error:nil]; - }); - - NSTextCheckingResult *onMatch = [danglingOnRegex firstMatchInString:original - options:0 - range:NSMakeRange(0, coreRange.location)]; - if (onMatch) { - rangeToBlank = NSMakeRange(onMatch.range.location, NSMaxRange(coreRange) - onMatch.range.location); + 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]; @@ -144,92 +253,74 @@ - (NSString *)displayValueForDate:(NSDate *)date hasExplicitTime:(BOOL)hasExplic NSDateFormatter *timeFormatter = [NSDateFormatter new]; timeFormatter.calendar = calendar; + timeFormatter.locale = locale; timeFormatter.dateStyle = NSDateFormatterNoStyle; timeFormatter.timeStyle = NSDateFormatterShortStyle; NSString *timeString = [timeFormatter stringFromDate:date]; - return [NSString stringWithFormat:@"%@ at %@", dateString, timeString]; + return [NSString stringWithFormat:@"%@ %@ %@", dateString, _keywords.dateTimeConnector, timeString]; } - (BOOL)textContainsExplicitTime:(NSString *)text { - static NSRegularExpression *timeRegex; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - timeRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b\\d{1,2}(:\\d{2})?\\s*(am|pm|a\\.m\\.|p\\.m\\.)\\b|\\b\\d{1,2}:\\d{2}\\b|\\bnoon\\b|\\bmidnight\\b|\\bmorning\\b|\\bafternoon\\b|\\bevening\\b" - options:0 - error:nil]; - }); - return [timeRegex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)] != nil; + return [_timeRegex firstMatchInString:text options:0 range:NSMakeRange(0, text.length)] != nil; } -#pragma mark - Duration +#pragma mark EventQuickEntryLanguagePack — Duration -- (EventQuickEntrySpan *)applyDurationDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +- (nullable EventQuickEntrySpan *)durationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result { - static NSRegularExpression *durationRegex; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - durationRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\bfor\\s+half\\s+an\\s+hour\\b|\\bfor\\s+(?:a|an)\\s+hour\\b|\\bfor\\s+(\\d+)\\s*(?:hours|hour|hrs|hr)\\b|\\bfor\\s+(\\d+)\\s*(?:minutes|minute|mins|min)\\b" - options:0 - error:nil]; - }); - - NSTextCheckingResult *match = [durationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + NSTextCheckingResult *match = [_durationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; if (!match) return nil; - NSString *matchedText = [masked substringWithRange:match.range]; - NSString *lowerMatched = matchedText.lowercaseString; NSInteger minutes = 0; - if ([lowerMatched containsString:@"half"]) { - minutes = 30; - } else if ([lowerMatched hasSuffix:@"hour"]) { - minutes = 60; // "for a hour" / "for an hour" - } else if (match.numberOfRanges > 1 && [match rangeAtIndex:1].location != NSNotFound) { - minutes = [[masked substringWithRange:[match rangeAtIndex:1]] integerValue] * 60; + 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 = [[masked substringWithRange:[match rangeAtIndex:2]] integerValue]; + 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 blankRange:match.range inMasked:masked]; + [[self class] blankRange:match.range inMasked:masked]; EventQuickEntrySpan *span = [EventQuickEntrySpan new]; span.range = match.range; span.kind = EventQuickEntrySpanKindDuration; - span.label = @"Duration"; + 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 minutes", (long)remainder]; + return [NSString stringWithFormat:@"%ld %@", (long)remainder, minuteWordPlural]; } - NSString *hoursPart = (hours == 1) ? @"1 hour" : [NSString stringWithFormat:@"%ld hours", (long)hours]; + NSString *hoursPart = (hours == 1) + ? [NSString stringWithFormat:@"1 %@", hourWordSingular] + : [NSString stringWithFormat:@"%ld %@", (long)hours, hourWordPlural]; if (remainder == 0) { return hoursPart; } - return [NSString stringWithFormat:@"%@ %ld minutes", hoursPart, (long)remainder]; + return [NSString stringWithFormat:@"%@ %ld %@", hoursPart, (long)remainder, minuteWordPlural]; } -#pragma mark - Location +#pragma mark EventQuickEntryLanguagePack — Location -- (EventQuickEntrySpan *)applyLocationDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result { - static NSRegularExpression *locationRegex; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - locationRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)(?:\\b(?:at|in)|@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:at|in|@))?\\s*$" - options:0 - error:nil]; - }); - - NSTextCheckingResult *match = [locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + NSTextCheckingResult *match = [_locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; if (!match) return nil; NSRange placeRange = [match rangeAtIndex:1]; @@ -239,83 +330,206 @@ - (EventQuickEntrySpan *)applyLocationDetectionToMasked:(NSMutableString *)maske if (place.length == 0) return nil; result.location = place; - [self blankRange:match.range inMasked:masked]; + [[self class] blankRange:match.range inMasked:masked]; EventQuickEntrySpan *span = [EventQuickEntrySpan new]; span.range = match.range; span.kind = EventQuickEntrySpanKindLocation; - span.label = @"Location"; + span.label = _keywords.locationSpanLabel; span.displayValue = place; return span; } -#pragma mark - Recurrence +#pragma mark EventQuickEntryLanguagePack — Recurrence -- (EventQuickEntrySpan *)applyRecurrenceDetectionToMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +- (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result { - static NSArray *recurrenceRegexes; - static NSArray *recurrenceValues; - static NSArray *recurrenceLabels; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // "every other week"/"biweekly" is checked before the plain - // "every week" pattern so it isn't shadowed by it. - NSArray *patterns = @[ - @"(?i)\\bevery\\s+other\\s+week\\b|\\bbiweekly\\b", - @"(?i)\\bevery\\s+day\\b|\\bdaily\\b", - @"(?i)\\bevery\\s+week\\b|\\bweekly\\b", - @"(?i)\\bevery\\s+month\\b|\\bmonthly\\b", - @"(?i)\\bevery\\s+year\\b|\\byearly\\b|\\bannually\\b", - ]; - NSMutableArray *regexes = [NSMutableArray new]; - for (NSString *pattern in patterns) { - [regexes addObject:[NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]]; - } - recurrenceRegexes = regexes; - recurrenceValues = @[ - @(EventQuickEntryRecurrenceEvery2Weeks), - @(EventQuickEntryRecurrenceEveryDay), - @(EventQuickEntryRecurrenceEveryWeek), - @(EventQuickEntryRecurrenceEveryMonth), - @(EventQuickEntryRecurrenceEveryYear), - ]; - // Index-parallel with recurrenceValues; must match EventViewController's - // _repPopup item titles exactly (EventViewController.m). - recurrenceLabels = @[ - @"Every 2 Weeks", - @"Every Day", - @"Every Week", - @"Every Month", - @"Every Year", - ]; - }); - - for (NSInteger i = 0; i < (NSInteger)recurrenceRegexes.count; i++) { - NSTextCheckingResult *match = [recurrenceRegexes[i] firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + 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 blankRange:match.range inMasked:masked]; + 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 = @"Repeat"; - span.displayValue = recurrenceLabels[i]; + span.label = _keywords.repeatSpanLabel; + span.displayValue = _recurrenceLabels[i]; return span; } } return nil; } -#pragma mark - Title / helpers +@end + +#pragma mark - EventQuickEntryEnglishLanguagePack + +@implementation EventQuickEntryEnglishLanguagePack + +- (instancetype)init +{ + EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; + keywords.languageCode = @"en"; + keywords.durationPrefixWord = @"for"; + keywords.halfHourPhrase = @"half an hour"; + keywords.oneHourPhrases = @[@"a hour", @"an hour"]; + keywords.hourUnitWords = @[@"hours", @"hour", @"hrs", @"hr"]; + keywords.minuteUnitWords = @[@"minutes", @"minute", @"mins", @"min"]; + keywords.locationPrefixWords = @[@"at", @"in"]; + keywords.danglingPrefixWords = @[@"on"]; + keywords.mealWords = @[@"lunch", @"breakfast", @"dinner", @"brunch"]; + keywords.explicitTimeWords = @[@"noon", @"midnight", @"morning", @"afternoon", @"evening"]; + keywords.recurrencePhrasesByFrequencyKey = @{ + @"every2Weeks": @[@"every other week", @"biweekly"], + @"everyDay": @[@"every day", @"daily"], + @"everyWeek": @[@"every week", @"weekly"], + @"everyMonth": @[@"every month", @"monthly"], + @"everyYear": @[@"every year", @"yearly", @"annually"], + }; + keywords.recurrenceLabelsByFrequencyKey = @{ + @"every2Weeks": @"Every 2 Weeks", + @"everyDay": @"Every Day", + @"everyWeek": @"Every Week", + @"everyMonth": @"Every Month", + @"everyYear": @"Every Year", + }; + keywords.dateSpanLabel = @"Date"; + keywords.durationSpanLabel = @"Duration"; + keywords.locationSpanLabel = @"Location"; + keywords.repeatSpanLabel = @"Repeat"; + keywords.dateTimeConnector = @"at"; + keywords.placeholderExample = @"Meeting with Bob for 30 min this Friday"; + return [self initWithKeywords:keywords]; +} + +@end + +#pragma mark - EventQuickEntrySpanishLanguagePack + +@implementation EventQuickEntrySpanishLanguagePack + +- (instancetype)init +{ + EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; + keywords.languageCode = @"es"; + keywords.durationPrefixWord = @"durante"; + keywords.halfHourPhrase = @"media hora"; + keywords.oneHourPhrases = @[@"una hora"]; + keywords.hourUnitWords = @[@"horas", @"hora"]; + keywords.minuteUnitWords = @[@"minutos", @"minuto"]; + // "en" only — "a" is too common a short preposition in Spanish and + // risks false-positive location matches on leftover text. + keywords.locationPrefixWords = @[@"en"]; + // Verified via direct NSDataDetector probing: "el próximo lunes" and + // "el viernes a las 15:00" both leave a leading "el" stranded; "esta + // noche" leaves "esta" stranded. Other articles/demonstratives included + // by analogy, not individually verified. + keywords.danglingPrefixWords = @[@"el", @"la", @"los", @"las", @"esta", @"este"]; + keywords.mealWords = @[@"almuerzo", @"desayuno", @"cena"]; + // Deliberately excludes "mañana": it means both "tomorrow" and + // "morning" in Spanish, and probing showed NSDataDetector using it + // as a plain date word (not resolving a specific hour) far more often + // than as a time-of-day word — including it here would make + // hasExplicitTime incorrectly true for phrases that only mean + // "tomorrow" with no specific time. "tarde"/"mediodía"/"medianoche" + // are included by analogy with "noche" (individually verified via + // probing to resolve to a specific hour) but not each verified. + keywords.explicitTimeWords = @[@"mediodía", @"medianoche", @"tarde", @"noche"]; + // Phrases and labels match the app's existing es.lproj/Localizable.strings + // translations for the _repPopup items, so quick-entry's tooltip text + // is consistent with what the popup itself already shows. + keywords.recurrencePhrasesByFrequencyKey = @{ + @"every2Weeks": @[@"cada 2 semanas", @"cada dos semanas", @"quincenal"], + @"everyDay": @[@"todos los días", @"todos los dias", @"diariamente", @"diario"], + @"everyWeek": @[@"todas las semanas", @"semanalmente"], + @"everyMonth": @[@"todos los meses", @"mensualmente"], + @"everyYear": @[@"todos los años", @"todos los anos", @"anualmente"], + }; + keywords.recurrenceLabelsByFrequencyKey = @{ + @"every2Weeks": @"Cada 2 semanas", + @"everyDay": @"Todos los días", + @"everyWeek": @"Todas las semanas", + @"everyMonth": @"Todos los meses", + @"everyYear": @"Todos los años", + }; + keywords.dateSpanLabel = @"Fecha"; + keywords.durationSpanLabel = @"Duración"; + keywords.locationSpanLabel = @"Ubicación"; + keywords.repeatSpanLabel = @"Repetir"; + keywords.dateTimeConnector = @"a las"; + keywords.placeholderExample = @"Reunión con Bob durante 30 min este viernes"; + return [self initWithKeywords:keywords]; +} + +@end + +#pragma mark - EventQuickEntryLanguagePackRegistry + +@implementation EventQuickEntryLanguagePackRegistry + ++ (nullable id)packForLanguageCode:(NSString *)languageCode +{ + if ([languageCode hasPrefix:@"en"]) return [EventQuickEntryEnglishLanguagePack new]; + if ([languageCode hasPrefix:@"es"]) return [EventQuickEntrySpanishLanguagePack new]; + return nil; +} + +@end + +#pragma mark - EventQuickEntryParser + +@implementation EventQuickEntryParser +{ + id _languagePack; +} + +- (instancetype)init +{ + return [self initWithLanguagePack:[EventQuickEntryEnglishLanguagePack new]]; +} + +- (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 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; -- (NSString *)titleFromMaskedText:(NSString *)masked + return result; +} + ++ (NSString *)titleFromMaskedText:(NSString *)masked { NSString *collapsed = [self collapseWhitespace:masked]; return [collapsed stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; } -- (NSString *)collapseWhitespace:(NSString *)text ++ (NSString *)collapseWhitespace:(NSString *)text { static NSRegularExpression *whitespaceRegex; static dispatch_once_t onceToken; @@ -325,10 +539,4 @@ - (NSString *)collapseWhitespace:(NSString *)text return [whitespaceRegex stringByReplacingMatchesInString:text options:0 range:NSMakeRange(0, text.length) withTemplate:@" "]; } -- (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked -{ - NSString *blank = [@"" stringByPaddingToLength:range.length withString:@" " startingAtIndex:0]; - [masked replaceCharactersInRange:range withString:blank]; -} - @end diff --git a/Itsycal/EventViewController.m b/Itsycal/EventViewController.m index 93fd1263..773ba5c2 100644 --- a/Itsycal/EventViewController.m +++ b/Itsycal/EventViewController.m @@ -144,10 +144,10 @@ @implementation EventViewController QuickEntryTooltipWindow *_quickEntryTooltipWindow; } -- (BOOL)quickEntrySupportedForCurrentLocale +- (nullable id)quickEntryLanguagePackForCurrentLocale { NSString *language = [NSBundle mainBundle].preferredLocalizations.firstObject; - return [language hasPrefix:@"en"]; + return [EventQuickEntryLanguagePackRegistry packForLanguageCode:language ?: @""]; } - (void)loadView @@ -203,15 +203,15 @@ - (void)loadView return btn; }; - // The quick-entry parser's keyword/date matching is English-only (see - // EventQuickEntryParser.m). Rather than show a field that silently fails - // to understand phrases in other languages, only create it when the - // app's active language resolves to English. - BOOL quickEntrySupported = [self quickEntrySupportedForCurrentLocale]; + // 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(NSLocalizedString(@"Meeting with Bob for 30 min this Friday", @""), YES); + _quickEntry = txt(quickEntryLanguagePack.placeholderExample, YES); _quickEntry.delegate = self; - _quickEntryParser = [EventQuickEntryParser new]; + _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. diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m index 795c77d5..acb62345 100644 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -292,4 +292,108 @@ - (void)testRestoresLeadingMealWordSwallowedByDateMatch XCTAssertNotNil(result.date); } +#pragma mark - Language pack registry + +- (void)testRegistryReturnsEnglishPackForEnglishCode +{ + id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en"]; + XCTAssertTrue([pack isKindOfClass:[EventQuickEntryEnglishLanguagePack class]]); +} + +- (void)testRegistryReturnsSpanishPackForSpanishCode +{ + id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]; + XCTAssertTrue([pack isKindOfClass:[EventQuickEntrySpanishLanguagePack class]]); +} + +- (void)testRegistryReturnsNilForUnsupportedCode +{ + id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"de"]; + XCTAssertNil(pack); +} + +#pragma mark - Spanish language pack + +- (void)testSpanishDetectsDateWithoutSwallowingTitle +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión mañana" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Reunión"); + XCTAssertNotNil(result.date); +} + +- (void)testSpanishStripsDanglingElBeforeDate +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión el próximo lunes" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Reunión"); + XCTAssertNotNil(result.date); +} + +- (void)testSpanishStripsDanglingEstaBeforeDate +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Cena esta noche" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Cena"); + XCTAssertNotNil(result.date); + XCTAssertTrue(result.hasExplicitTime); +} + +- (void)testSpanishDetectsExplicitTimeWithAmPm +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Almuerzo mañana a las 3pm" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Almuerzo"); + XCTAssertTrue(result.hasExplicitTime); +} + +- (void)testSpanishDetectsDuration +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión durante 30 minutos" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Reunión"); + XCTAssertEqual(result.durationMinutes, 30); +} + +- (void)testSpanishDetectsHalfHourIdiom +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión durante media hora" calendar:self.calendar]; + XCTAssertEqual(result.durationMinutes, 30); +} + +- (void)testSpanishDetectsLocation +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Almuerzo en Cafe Luna" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Almuerzo"); + XCTAssertEqualObjects(result.location, @"Cafe Luna"); +} + +- (void)testSpanishDetectsRecurrenceEveryDay +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; + XCTAssertEqualObjects(result.title, @"Reunión"); + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); +} + +- (void)testSpanishRecurrenceSpanDisplayValueMatchesExistingTranslation +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; + XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); + EventQuickEntrySpan *span = result.recognizedSpans.firstObject; + XCTAssertEqualObjects(span.label, @"Repetir"); + XCTAssertEqualObjects(span.displayValue, @"Todos los días"); +} + +- (void)testSpanishNoRecurrencePhraseLeavesRecurrenceNone +{ + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryResult *result = [parser parse:@"Comprar víveres" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); + XCTAssertNil(result.location); +} + @end From 970183911c4129b75b7b5d39c0e6a5f1dafef2a5 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:24:52 -0400 Subject: [PATCH 04/16] Move language pack data into .lproj resource files, matching the project's existing localization convention Replaces the hardcoded EventQuickEntryEnglishLanguagePack/EventQuickEntrySpanishLanguagePack Objective-C classes with EventQuickEntryKeywords.strings files in Base.lproj (English, the project's development-language folder) and es.lproj - the same localized-resource-variant mechanism (PBXVariantGroup) already used for Localizable.strings/InfoPlist.strings/MainMenu.xib, not a new one. Array-valued keywords are pipe-delimited single entries; the two recurrence dictionaries are flattened into recurrencePhrases. / recurrenceLabel. entries. EventQuickEntryLanguagePackRegistry now resolves a language by looking up this resource via -[NSBundle URLForResource:withExtension:subdirectory:localization:] instead of a hardcoded if/else over class names. This was the actual point of the whole exercise: adding a new keyword-based language now means adding one .lproj/EventQuickEntryKeywords.strings file - the same workflow a translator already uses for this project - not writing or compiling any Objective-C. Required adding a Resources build phase to the ItsycalTests target, which had none before (it only needed Sources/Frameworks). All 51 tests pass against the real bundle-resource-loading path, not a mock. --- Itsycal.xcodeproj/project.pbxproj | 24 +++ .../EventQuickEntryKeywords.strings | 43 +++++ Itsycal/EventQuickEntryParser.h | 43 +++-- Itsycal/EventQuickEntryParser.m | 173 +++++++----------- .../es.lproj/EventQuickEntryKeywords.strings | 68 +++++++ ItsycalTests/EventQuickEntryParserTests.m | 33 ++-- 6 files changed, 251 insertions(+), 133 deletions(-) create mode 100644 Itsycal/Base.lproj/EventQuickEntryKeywords.strings create mode 100644 Itsycal/es.lproj/EventQuickEntryKeywords.strings diff --git a/Itsycal.xcodeproj/project.pbxproj b/Itsycal.xcodeproj/project.pbxproj index 172ff510..3478237c 100644 --- a/Itsycal.xcodeproj/project.pbxproj +++ b/Itsycal.xcodeproj/project.pbxproj @@ -54,6 +54,8 @@ B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0B2E0000F00000AAA100000 /* XCTest.framework */; }; + B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; + B0B2E0001500000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -180,6 +182,8 @@ 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 = ""; }; B0B2E0000F00000AAA100000 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 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 */ @@ -286,6 +290,7 @@ 92658E611A9DBD8E002AE86C /* EventViewController.m */, B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */, B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */, + B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */, 9261EB2E1EEF447500DCFFC4 /* Themer.h */, 9261EB2F1EEF447500DCFFC4 /* Themer.m */, 92B28CCD210BD17500485AE2 /* Sizer.h */, @@ -384,6 +389,7 @@ buildPhases = ( B0B2E0000600000AAA100000 /* Sources */, B0B2E0000700000AAA100000 /* Frameworks */, + B0B2E0001600000AAA100000 /* Resources */, ); buildRules = ( ); @@ -450,6 +456,15 @@ 922B6BC41A87EDBE00DEA9CD /* InfoPlist.strings in Resources */, 922B6BBE1A87EDBD00DEA9CD /* Localizable.strings in Resources */, 9242A56A1A82A136005AA4B3 /* MainMenu.xib in Resources */, + B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B0B2E0001600000AAA100000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B0B2E0001500000AAA100000 /* EventQuickEntryKeywords.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -569,6 +584,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..8ce9d05d --- /dev/null +++ b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings @@ -0,0 +1,43 @@ +/* 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"; + +/* 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/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h index 53a0ecdb..a5372d85 100644 --- a/Itsycal/EventQuickEntryParser.h +++ b/Itsycal/EventQuickEntryParser.h @@ -127,13 +127,28 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { @property (nonatomic, copy) NSString *dateTimeConnector; // 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. -// Subclass this and override -init to supply keywords for a new -// keyword-based language (see EventQuickEntryEnglishLanguagePack). +// 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 plist — 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 @@ -143,24 +158,22 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { + (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked; @end -@interface EventQuickEntryEnglishLanguagePack : EventQuickEntryKeywordLanguagePack -@end - -// Best-effort Spanish keyword set, verified against real NSDataDetector -// output but not reviewed by a native speaker — see the design doc. -@interface EventQuickEntrySpanishLanguagePack : EventQuickEntryKeywordLanguagePack -@end - @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. Matches by prefix, so "en-US"/"en-GB"/etc. all resolve to the -// English pack. +// -[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 @interface EventQuickEntryParser : NSObject -// Convenience initializer using EventQuickEntryEnglishLanguagePack. +// 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. diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index 42573911..ac06494c 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -28,6 +28,55 @@ - (instancetype)init @end @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.placeholderExample = strings[@"placeholderExample"]; + return keywords; +} + @end #pragma mark - EventQuickEntryKeywordLanguagePack @@ -135,7 +184,7 @@ - (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 = @[@"every2Weeks", @"everyDay", @"everyWeek", @"everyMonth", @"everyYear"]; + NSArray *orderedKeys = [EventQuickEntryKeywords recurrenceFrequencyKeys]; NSArray *orderedValues = @[ @(EventQuickEntryRecurrenceEvery2Weeks), @(EventQuickEntryRecurrenceEveryDay), @@ -363,116 +412,28 @@ - (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)mask @end -#pragma mark - EventQuickEntryEnglishLanguagePack - -@implementation EventQuickEntryEnglishLanguagePack - -- (instancetype)init -{ - EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; - keywords.languageCode = @"en"; - keywords.durationPrefixWord = @"for"; - keywords.halfHourPhrase = @"half an hour"; - keywords.oneHourPhrases = @[@"a hour", @"an hour"]; - keywords.hourUnitWords = @[@"hours", @"hour", @"hrs", @"hr"]; - keywords.minuteUnitWords = @[@"minutes", @"minute", @"mins", @"min"]; - keywords.locationPrefixWords = @[@"at", @"in"]; - keywords.danglingPrefixWords = @[@"on"]; - keywords.mealWords = @[@"lunch", @"breakfast", @"dinner", @"brunch"]; - keywords.explicitTimeWords = @[@"noon", @"midnight", @"morning", @"afternoon", @"evening"]; - keywords.recurrencePhrasesByFrequencyKey = @{ - @"every2Weeks": @[@"every other week", @"biweekly"], - @"everyDay": @[@"every day", @"daily"], - @"everyWeek": @[@"every week", @"weekly"], - @"everyMonth": @[@"every month", @"monthly"], - @"everyYear": @[@"every year", @"yearly", @"annually"], - }; - keywords.recurrenceLabelsByFrequencyKey = @{ - @"every2Weeks": @"Every 2 Weeks", - @"everyDay": @"Every Day", - @"everyWeek": @"Every Week", - @"everyMonth": @"Every Month", - @"everyYear": @"Every Year", - }; - keywords.dateSpanLabel = @"Date"; - keywords.durationSpanLabel = @"Duration"; - keywords.locationSpanLabel = @"Location"; - keywords.repeatSpanLabel = @"Repeat"; - keywords.dateTimeConnector = @"at"; - keywords.placeholderExample = @"Meeting with Bob for 30 min this Friday"; - return [self initWithKeywords:keywords]; -} - -@end - -#pragma mark - EventQuickEntrySpanishLanguagePack - -@implementation EventQuickEntrySpanishLanguagePack - -- (instancetype)init -{ - EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; - keywords.languageCode = @"es"; - keywords.durationPrefixWord = @"durante"; - keywords.halfHourPhrase = @"media hora"; - keywords.oneHourPhrases = @[@"una hora"]; - keywords.hourUnitWords = @[@"horas", @"hora"]; - keywords.minuteUnitWords = @[@"minutos", @"minuto"]; - // "en" only — "a" is too common a short preposition in Spanish and - // risks false-positive location matches on leftover text. - keywords.locationPrefixWords = @[@"en"]; - // Verified via direct NSDataDetector probing: "el próximo lunes" and - // "el viernes a las 15:00" both leave a leading "el" stranded; "esta - // noche" leaves "esta" stranded. Other articles/demonstratives included - // by analogy, not individually verified. - keywords.danglingPrefixWords = @[@"el", @"la", @"los", @"las", @"esta", @"este"]; - keywords.mealWords = @[@"almuerzo", @"desayuno", @"cena"]; - // Deliberately excludes "mañana": it means both "tomorrow" and - // "morning" in Spanish, and probing showed NSDataDetector using it - // as a plain date word (not resolving a specific hour) far more often - // than as a time-of-day word — including it here would make - // hasExplicitTime incorrectly true for phrases that only mean - // "tomorrow" with no specific time. "tarde"/"mediodía"/"medianoche" - // are included by analogy with "noche" (individually verified via - // probing to resolve to a specific hour) but not each verified. - keywords.explicitTimeWords = @[@"mediodía", @"medianoche", @"tarde", @"noche"]; - // Phrases and labels match the app's existing es.lproj/Localizable.strings - // translations for the _repPopup items, so quick-entry's tooltip text - // is consistent with what the popup itself already shows. - keywords.recurrencePhrasesByFrequencyKey = @{ - @"every2Weeks": @[@"cada 2 semanas", @"cada dos semanas", @"quincenal"], - @"everyDay": @[@"todos los días", @"todos los dias", @"diariamente", @"diario"], - @"everyWeek": @[@"todas las semanas", @"semanalmente"], - @"everyMonth": @[@"todos los meses", @"mensualmente"], - @"everyYear": @[@"todos los años", @"todos los anos", @"anualmente"], - }; - keywords.recurrenceLabelsByFrequencyKey = @{ - @"every2Weeks": @"Cada 2 semanas", - @"everyDay": @"Todos los días", - @"everyWeek": @"Todas las semanas", - @"everyMonth": @"Todos los meses", - @"everyYear": @"Todos los años", - }; - keywords.dateSpanLabel = @"Fecha"; - keywords.durationSpanLabel = @"Duración"; - keywords.locationSpanLabel = @"Ubicación"; - keywords.repeatSpanLabel = @"Repetir"; - keywords.dateTimeConnector = @"a las"; - keywords.placeholderExample = @"Reunión con Bob durante 30 min este viernes"; - return [self initWithKeywords:keywords]; -} - -@end - #pragma mark - EventQuickEntryLanguagePackRegistry @implementation EventQuickEntryLanguagePackRegistry + (nullable id)packForLanguageCode:(NSString *)languageCode { - if ([languageCode hasPrefix:@"en"]) return [EventQuickEntryEnglishLanguagePack new]; - if ([languageCode hasPrefix:@"es"]) return [EventQuickEntrySpanishLanguagePack new]; - return nil; + 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 @@ -486,7 +447,7 @@ @implementation EventQuickEntryParser - (instancetype)init { - return [self initWithLanguagePack:[EventQuickEntryEnglishLanguagePack new]]; + return [self initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en"]]; } - (instancetype)initWithLanguagePack:(id)languagePack diff --git a/Itsycal/es.lproj/EventQuickEntryKeywords.strings b/Itsycal/es.lproj/EventQuickEntryKeywords.strings new file mode 100644 index 00000000..3ae6f2d2 --- /dev/null +++ b/Itsycal/es.lproj/EventQuickEntryKeywords.strings @@ -0,0 +1,68 @@ +/* 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. */ + +"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"; + +/* 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"; diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m index acb62345..adaf2ea3 100644 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -297,13 +297,22 @@ - (void)testRestoresLeadingMealWordSwallowedByDateMatch - (void)testRegistryReturnsEnglishPackForEnglishCode { id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en"]; - XCTAssertTrue([pack isKindOfClass:[EventQuickEntryEnglishLanguagePack class]]); + XCTAssertNotNil(pack); + XCTAssertEqualObjects(pack.placeholderExample, @"Meeting with Bob for 30 min this Friday"); +} + +- (void)testRegistryReturnsEnglishPackForRegionalEnglishCode +{ + id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en-US"]; + XCTAssertNotNil(pack); + XCTAssertEqualObjects(pack.placeholderExample, @"Meeting with Bob for 30 min this Friday"); } - (void)testRegistryReturnsSpanishPackForSpanishCode { id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]; - XCTAssertTrue([pack isKindOfClass:[EventQuickEntrySpanishLanguagePack class]]); + XCTAssertNotNil(pack); + XCTAssertEqualObjects(pack.placeholderExample, @"Reunión con Bob durante 30 min este viernes"); } - (void)testRegistryReturnsNilForUnsupportedCode @@ -316,7 +325,7 @@ - (void)testRegistryReturnsNilForUnsupportedCode - (void)testSpanishDetectsDateWithoutSwallowingTitle { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión mañana" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Reunión"); XCTAssertNotNil(result.date); @@ -324,7 +333,7 @@ - (void)testSpanishDetectsDateWithoutSwallowingTitle - (void)testSpanishStripsDanglingElBeforeDate { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión el próximo lunes" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Reunión"); XCTAssertNotNil(result.date); @@ -332,7 +341,7 @@ - (void)testSpanishStripsDanglingElBeforeDate - (void)testSpanishStripsDanglingEstaBeforeDate { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Cena esta noche" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Cena"); XCTAssertNotNil(result.date); @@ -341,7 +350,7 @@ - (void)testSpanishStripsDanglingEstaBeforeDate - (void)testSpanishDetectsExplicitTimeWithAmPm { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Almuerzo mañana a las 3pm" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Almuerzo"); XCTAssertTrue(result.hasExplicitTime); @@ -349,7 +358,7 @@ - (void)testSpanishDetectsExplicitTimeWithAmPm - (void)testSpanishDetectsDuration { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión durante 30 minutos" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Reunión"); XCTAssertEqual(result.durationMinutes, 30); @@ -357,14 +366,14 @@ - (void)testSpanishDetectsDuration - (void)testSpanishDetectsHalfHourIdiom { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión durante media hora" calendar:self.calendar]; XCTAssertEqual(result.durationMinutes, 30); } - (void)testSpanishDetectsLocation { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Almuerzo en Cafe Luna" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Almuerzo"); XCTAssertEqualObjects(result.location, @"Cafe Luna"); @@ -372,7 +381,7 @@ - (void)testSpanishDetectsLocation - (void)testSpanishDetectsRecurrenceEveryDay { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; XCTAssertEqualObjects(result.title, @"Reunión"); XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); @@ -380,7 +389,7 @@ - (void)testSpanishDetectsRecurrenceEveryDay - (void)testSpanishRecurrenceSpanDisplayValueMatchesExistingTranslation { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); EventQuickEntrySpan *span = result.recognizedSpans.firstObject; @@ -390,7 +399,7 @@ - (void)testSpanishRecurrenceSpanDisplayValueMatchesExistingTranslation - (void)testSpanishNoRecurrencePhraseLeavesRecurrenceNone { - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntrySpanishLanguagePack new]]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; EventQuickEntryResult *result = [parser parse:@"Comprar víveres" calendar:self.calendar]; XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); XCTAssertNil(result.location); From afb6aaf057aa68d1f01af6f28b822ce0bb165b88 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:39:34 -0400 Subject: [PATCH 05/16] Fix empty-keyword-array false positives; add missing doc comments An empty array-valued keyword (e.g. oneHourPhrases left blank by a future contributor's incomplete .strings file) previously turned into an empty regex alternation group (?:), which matches the empty string at *any* position rather than nowhere. Verified empirically before fixing: an empty oneHourPhrases made any phrase containing "for " match as a 60-minute duration; an empty recurrence phrase list for any frequency made every quick-entry phrase match that recurrence, even "Buy groceries". +alternationPatternForPhrases: now returns nil (not an empty string) for an empty list, and every call site explicitly handles that: the time/location regexes omit the affected branch entirely, the duration regex substitutes "(?!)" (never matches) to preserve its capture-group numbering, and the recurrence builder skips that frequency's regex, keeping its three parallel arrays in sync by construction instead of leaving index gaps. Also fixes a stale comment left over from the plist to .strings migration, and documents that hourUnitWords/minuteUnitWords array order is load-bearing for display grammar (index 0 = plural, index 1 = singular), which wasn't previously stated anywhere. 3 new regression tests construct a deliberately incomplete EventQuickEntryKeywords directly to lock in the fix. --- Itsycal/EventQuickEntryParser.h | 9 ++- Itsycal/EventQuickEntryParser.m | 75 +++++++++++++++++------ ItsycalTests/EventQuickEntryParserTests.m | 67 ++++++++++++++++++++ 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/Itsycal/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h index a5372d85..185dfaf4 100644 --- a/Itsycal/EventQuickEntryParser.h +++ b/Itsycal/EventQuickEntryParser.h @@ -92,8 +92,15 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { // 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. @@ -147,7 +154,7 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { // 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 plist — see +// 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; diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index ac06494c..90134032 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -111,8 +111,19 @@ - (instancetype)initWithKeywords:(EventQuickEntryKeywords *)keywords #pragma mark Regex construction -+ (NSString *)alternationPatternForPhrases:(NSArray *)phrases +// 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]]; @@ -123,24 +134,38 @@ + (NSString *)alternationPatternForPhrases:(NSArray *)phrases - (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. + // 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.length > 0 ? [NSString stringWithFormat:@"|\\b(?:%@)\\b", wordsAlt] : @""; + 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]; - NSString *halfHour = [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]; - - // Four alternatives, each with its own capture group so the matching - // branch can be identified without relying on language-specific - // substring checks (e.g. English "half"/"hour") on the matched text. + + // 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]; @@ -171,12 +196,14 @@ - (void)buildDanglingPrefixRegexFromKeywords:(EventQuickEntryKeywords *)keywords - (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). NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; - // "@" is always recognized as a location prefix regardless of language. - // 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). - NSString *pattern = [NSString stringWithFormat:@"(?i)(?:\\b(?:%@)|@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@|@))?\\s*$", prefixAlt, prefixAlt]; + NSString *prefixGroup = prefixAlt ? [NSString stringWithFormat:@"\\b(?:%@)|@", prefixAlt] : @"@"; + NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?\\s*$", prefixGroup, prefixGroup]; _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; } @@ -193,16 +220,26 @@ - (void)buildRecurrenceRegexesFromKeywords:(EventQuickEntryKeywords *)keywords @(EventQuickEntryRecurrenceEveryYear), ]; NSMutableArray *regexes = [NSMutableArray new]; + NSMutableArray *values = [NSMutableArray new]; NSMutableArray *labels = [NSMutableArray new]; - for (NSString *key in orderedKeys) { - NSArray *phrases = keywords.recurrencePhrasesByFrequencyKey[key] ?: @[]; + 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 = orderedValues; + _recurrenceValues = values; _recurrenceLabels = labels; } diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m index adaf2ea3..767eb92d 100644 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -405,4 +405,71 @@ - (void)testSpanishNoRecurrencePhraseLeavesRecurrenceNone XCTAssertNil(result.location); } +#pragma mark - Partial/incomplete keyword configs (regression coverage for the +#pragma mark empty-alternation-group hazard on +alternationPatternForPhrases:) + +- (EventQuickEntryKeywords *)minimalKeywordsWithOneHourPhrases:(NSArray *)oneHourPhrases + recurrencePhrasesByFrequencyKey:(NSDictionary *> *)recurrencePhrases +{ + EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; + keywords.languageCode = @"en"; + keywords.durationPrefixWord = @"for"; + keywords.halfHourPhrase = @"half an hour"; + keywords.oneHourPhrases = oneHourPhrases; + keywords.hourUnitWords = @[@"hours", @"hour"]; + keywords.minuteUnitWords = @[@"minutes", @"minute"]; + keywords.locationPrefixWords = @[@"at", @"in"]; + keywords.danglingPrefixWords = @[@"on"]; + keywords.mealWords = @[]; + keywords.explicitTimeWords = @[]; + keywords.recurrencePhrasesByFrequencyKey = recurrencePhrases; + NSMutableDictionary *labels = [NSMutableDictionary new]; + for (NSString *key in recurrencePhrases) labels[key] = key; + keywords.recurrenceLabelsByFrequencyKey = labels; + keywords.dateSpanLabel = @"Date"; + keywords.durationSpanLabel = @"Duration"; + keywords.locationSpanLabel = @"Location"; + keywords.repeatSpanLabel = @"Repeat"; + keywords.dateTimeConnector = @"at"; + keywords.placeholderExample = @""; + return keywords; +} + +- (void)testEmptyOneHourPhrasesDoesNotFalsePositiveMatchEveryForPhrase +{ + EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[] recurrencePhrasesByFrequencyKey:@{}]; + EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; + + EventQuickEntryResult *result = [parser parse:@"Standup for the whole team" calendar:self.calendar]; + XCTAssertEqual(result.durationMinutes, 0); +} + +- (void)testConfiguredOneHourPhraseStillWorksAlongsideEmptyOnes +{ + EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[@"an hour"] recurrencePhrasesByFrequencyKey:@{}]; + EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; + + EventQuickEntryResult *result = [parser parse:@"Workshop for an hour" calendar:self.calendar]; + XCTAssertEqual(result.durationMinutes, 60); +} + +- (void)testEmptyRecurrencePhrasesForOneFrequencyDoesNotFalsePositiveMatchEverything +{ + // Only "everyDay" is configured; simulates a partially-filled-in + // language pack that hasn't supplied phrases for the other four + // frequencies yet. + EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[@"an hour"] + recurrencePhrasesByFrequencyKey:@{@"everyDay": @[@"every day"]}]; + EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; + EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; + + EventQuickEntryResult *result = [parser parse:@"Team standup" calendar:self.calendar]; + XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); + + EventQuickEntryResult *dailyResult = [parser parse:@"Standup every day" calendar:self.calendar]; + XCTAssertEqual(dailyResult.recurrence, EventQuickEntryRecurrenceEveryDay); +} + @end From d40c9c99825cda48e774ab1cc84f7e60a5e97fff Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:44:43 -0400 Subject: [PATCH 06/16] Add file headers matching the project's convention --- Itsycal/EventQuickEntryParser.h | 2 ++ Itsycal/EventQuickEntryParser.m | 2 ++ ItsycalTests/EventQuickEntryParserTests.m | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Itsycal/EventQuickEntryParser.h b/Itsycal/EventQuickEntryParser.h index 185dfaf4..b950d2fa 100644 --- a/Itsycal/EventQuickEntryParser.h +++ b/Itsycal/EventQuickEntryParser.h @@ -2,6 +2,8 @@ // EventQuickEntryParser.h // Itsycal // +// Created by Scott Goci on 7/27/26. +// #import diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index 90134032..d351bcdc 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -2,6 +2,8 @@ // EventQuickEntryParser.m // Itsycal // +// Created by Scott Goci on 7/27/26. +// #import "EventQuickEntryParser.h" diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m index 767eb92d..5a736074 100644 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -2,6 +2,8 @@ // EventQuickEntryParserTests.m // ItsycalTests // +// Created by Scott Goci on 7/27/26. +// #import #import "EventQuickEntryParser.h" From 959806beb8b5a180ae297c4f4ca2a9f7b4d8793d Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:53:24 -0400 Subject: [PATCH 07/16] Split EventQuickEntryParser into one file per class, matching existing project conventions EventQuickEntryLanguagePack, EventQuickEntryKeywords, EventQuickEntryKeywordLanguagePack, and EventQuickEntryLanguagePackRegistry all previously lived inside EventQuickEntryParser.h/.m. Split into their own files, following the project's actual precedent rather than my own judgment call: - MoCalCell/MoCalGrid/MoCalendar/MoCalToolTipWC/MoCalResizeHandle and PrefsVC/PrefsGeneralVC/PrefsAppearanceVC/PrefsAboutVC are each separate file pairs, grouped under a virtual (name-only, no path) Xcode group - not crammed into one file, even though each set is a single subsystem. - MoCalTooltipProvider.h exists with no .m - a pure protocol declaration, exactly EventQuickEntryLanguagePack's shape. Result: EventQuickEntryParser.h/.m now hold only EventQuickEntrySpan/EventQuickEntryResult/EventQuickEntryParser (the orchestrator); EventQuickEntryLanguagePack.h is protocol-only; EventQuickEntryKeywords.h/.m holds the config class plus the generic EventQuickEntryKeywordLanguagePack it drives; EventQuickEntryLanguagePackRegistry.h/.m holds the lookup. All five files (plus the existing EventQuickEntryKeywords.strings resource) are grouped under a new virtual "EventQuickEntry" Xcode group, mirroring "MoCalendar"/"Prefs". No behavior change. 54/54 tests pass; both Keywords.m and LanguagePackRegistry.m compile into both the app and test targets, same dual-compilation pattern EventQuickEntryParser.m already used. --- Itsycal.xcodeproj/project.pbxproj | 32 +- Itsycal/EventQuickEntryKeywords.h | 102 ++++ Itsycal/EventQuickEntryKeywords.m | 431 +++++++++++++++++ Itsycal/EventQuickEntryLanguagePack.h | 43 ++ Itsycal/EventQuickEntryLanguagePackRegistry.h | 27 ++ Itsycal/EventQuickEntryLanguagePackRegistry.m | 33 ++ Itsycal/EventQuickEntryParser.h | 132 +---- Itsycal/EventQuickEntryParser.m | 451 +----------------- Itsycal/EventViewController.m | 1 + ItsycalTests/EventQuickEntryParserTests.m | 2 + 10 files changed, 670 insertions(+), 584 deletions(-) create mode 100644 Itsycal/EventQuickEntryKeywords.h create mode 100644 Itsycal/EventQuickEntryKeywords.m create mode 100644 Itsycal/EventQuickEntryLanguagePack.h create mode 100644 Itsycal/EventQuickEntryLanguagePackRegistry.h create mode 100644 Itsycal/EventQuickEntryLanguagePackRegistry.m diff --git a/Itsycal.xcodeproj/project.pbxproj b/Itsycal.xcodeproj/project.pbxproj index 3478237c..1614a136 100644 --- a/Itsycal.xcodeproj/project.pbxproj +++ b/Itsycal.xcodeproj/project.pbxproj @@ -56,6 +56,10 @@ B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0B2E0000F00000AAA100000 /* XCTest.framework */; }; B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; B0B2E0001500000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; + B0B2E0001E00000AAA100000 /* EventQuickEntryKeywords.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */; }; + B0B2E0001F00000AAA100000 /* EventQuickEntryKeywords.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */; }; + B0B2E0002000000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */; }; + B0B2E0002100000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -182,6 +186,11 @@ 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 = ""; }; B0B2E0000F00000AAA100000 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 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 */ @@ -288,9 +297,7 @@ 925B471A1A93CD2A00E0329A /* TooltipViewController.m */, 92658E601A9DBD8E002AE86C /* EventViewController.h */, 92658E611A9DBD8E002AE86C /* EventViewController.m */, - B0B2E0000B00000AAA100000 /* EventQuickEntryParser.h */, - B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */, - B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */, + B0B2E0002200000AAA100000 /* EventQuickEntry */, 9261EB2E1EEF447500DCFFC4 /* Themer.h */, 9261EB2F1EEF447500DCFFC4 /* Themer.m */, 92B28CCD210BD17500485AE2 /* Sizer.h */, @@ -360,6 +367,21 @@ path = ItsycalTests; 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 */ @@ -500,6 +522,8 @@ 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 */, @@ -538,6 +562,8 @@ files = ( B0B2E0000A00000AAA100000 /* EventQuickEntryParserTests.m in Sources */, B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */, + B0B2E0001F00000AAA100000 /* EventQuickEntryKeywords.m in Sources */, + B0B2E0002100000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Itsycal/EventQuickEntryKeywords.h b/Itsycal/EventQuickEntryKeywords.h new file mode 100644 index 00000000..50e40099 --- /dev/null +++ b/Itsycal/EventQuickEntryKeywords.h @@ -0,0 +1,102 @@ +// +// 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; +// 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..dbe6664d --- /dev/null +++ b/Itsycal/EventQuickEntryKeywords.m @@ -0,0 +1,431 @@ +// +// 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.placeholderExample = strings[@"placeholderExample"]; + return keywords; +} + +@end + +#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 *_locationRegex; + 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]; + } + return self; +} + +#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). + NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; + NSString *prefixGroup = prefixAlt ? [NSString stringWithFormat:@"\\b(?:%@)|@", prefixAlt] : @"@"; + NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?\\s*$", prefixGroup, prefixGroup]; + _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern 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; +} + +#pragma mark EventQuickEntryLanguagePack — placeholder + +- (NSString *)placeholderExample +{ + return _keywords.placeholderExample; +} + +#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 EventQuickEntryLanguagePack — Date/time + +- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked + original:(NSString *)original + result:(EventQuickEntryResult *)result + calendar:(NSCalendar *)calendar +{ + 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; +} + +// 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 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 EventQuickEntryLanguagePack — Location + +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +{ + NSTextCheckingResult *match = [_locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; + if (!match) return nil; + + NSRange placeRange = [match rangeAtIndex:1]; + if (placeRange.location == NSNotFound) return nil; + + NSString *place = [[masked substringWithRange:placeRange] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (place.length == 0) return nil; + + result.location = place; + [[self class] blankRange:match.range inMasked:masked]; + + EventQuickEntrySpan *span = [EventQuickEntrySpan new]; + span.range = match.range; + span.kind = EventQuickEntrySpanKindLocation; + span.label = _keywords.locationSpanLabel; + span.displayValue = place; + return span; +} + +#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..da9ba9f7 --- /dev/null +++ b/Itsycal/EventQuickEntryLanguagePack.h @@ -0,0 +1,43 @@ +// +// 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 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 index b950d2fa..175cf230 100644 --- a/Itsycal/EventQuickEntryParser.h +++ b/Itsycal/EventQuickEntryParser.h @@ -6,6 +6,7 @@ // #import +#import "EventQuickEntryLanguagePack.h" NS_ASSUME_NONNULL_BEGIN @@ -50,137 +51,6 @@ typedef NS_ENUM(NSInteger, EventQuickEntrySpanKind) { @property (nonatomic, copy) NSArray *recognizedSpans; @end -// 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 result:(EventQuickEntryResult *)result; -- (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; -@end - -// 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; -// 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 - -@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 - @interface EventQuickEntryParser : NSObject // Convenience initializer using the English language pack. - (instancetype)init; diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index d351bcdc..953b8d5c 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -6,6 +6,7 @@ // #import "EventQuickEntryParser.h" +#import "EventQuickEntryLanguagePackRegistry.h" @implementation EventQuickEntrySpan @end @@ -29,456 +30,6 @@ - (instancetype)init @end -@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.placeholderExample = strings[@"placeholderExample"]; - return keywords; -} - -@end - -#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 *_locationRegex; - 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]; - } - return self; -} - -#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). - NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; - NSString *prefixGroup = prefixAlt ? [NSString stringWithFormat:@"\\b(?:%@)|@", prefixAlt] : @"@"; - NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?\\s*$", prefixGroup, prefixGroup]; - _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern 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; -} - -#pragma mark EventQuickEntryLanguagePack — placeholder - -- (NSString *)placeholderExample -{ - return _keywords.placeholderExample; -} - -#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 EventQuickEntryLanguagePack — Date/time - -- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked - original:(NSString *)original - result:(EventQuickEntryResult *)result - calendar:(NSCalendar *)calendar -{ - 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; -} - -// 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 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 EventQuickEntryLanguagePack — Location - -- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result -{ - NSTextCheckingResult *match = [_locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; - if (!match) return nil; - - NSRange placeRange = [match rangeAtIndex:1]; - if (placeRange.location == NSNotFound) return nil; - - NSString *place = [[masked substringWithRange:placeRange] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - if (place.length == 0) return nil; - - result.location = place; - [[self class] blankRange:match.range inMasked:masked]; - - EventQuickEntrySpan *span = [EventQuickEntrySpan new]; - span.range = match.range; - span.kind = EventQuickEntrySpanKindLocation; - span.label = _keywords.locationSpanLabel; - span.displayValue = place; - return span; -} - -#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 - -#pragma mark - EventQuickEntryLanguagePackRegistry - -@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 - -#pragma mark - EventQuickEntryParser - @implementation EventQuickEntryParser { id _languagePack; diff --git a/Itsycal/EventViewController.m b/Itsycal/EventViewController.m index 773ba5c2..44152143 100644 --- a/Itsycal/EventViewController.m +++ b/Itsycal/EventViewController.m @@ -9,6 +9,7 @@ #import "EventViewController.h" #import "EventCenter.h" #import "EventQuickEntryParser.h" +#import "EventQuickEntryLanguagePackRegistry.h" #import "MoThemeView.h" #import "MoVFLHelper.h" #import "NSMenuItem+NoImages.h" diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m index 5a736074..3f5614c2 100644 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ b/ItsycalTests/EventQuickEntryParserTests.m @@ -7,6 +7,8 @@ #import #import "EventQuickEntryParser.h" +#import "EventQuickEntryKeywords.h" +#import "EventQuickEntryLanguagePackRegistry.h" @interface EventQuickEntryParserTests : XCTestCase @property (nonatomic) EventQuickEntryParser *parser; From 4fb129ca97a3e5e5ef1d2333f12138f5e0fe1a51 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 13:59:29 -0400 Subject: [PATCH 08/16] Remove ItsycalTests target for this PR This project has no existing test infrastructure. Adding an XCTest target is a separate decision the maintainer should get to make deliberately, not something to bundle into a feature PR - so this branch drops the ItsycalTests target and its test file entirely. The feature code (EventQuickEntryParser, the language pack architecture, EventViewController's quick-entry wiring) is unaffected; the test target never contained anything besides its own test file and duplicate Sources/Resources entries for files also compiled into the app target directly. The full branch with all 54 tests remains on natural-language-support for local reference. --- Itsycal.xcodeproj/project.pbxproj | 117 ------ ItsycalTests/EventQuickEntryParserTests.m | 479 ---------------------- 2 files changed, 596 deletions(-) delete mode 100644 ItsycalTests/EventQuickEntryParserTests.m diff --git a/Itsycal.xcodeproj/project.pbxproj b/Itsycal.xcodeproj/project.pbxproj index 1614a136..af471e2b 100644 --- a/Itsycal.xcodeproj/project.pbxproj +++ b/Itsycal.xcodeproj/project.pbxproj @@ -50,16 +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 */; }; - B0B2E0000A00000AAA100000 /* EventQuickEntryParserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */; }; B0B2E0000D00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; - B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0000C00000AAA100000 /* EventQuickEntryParser.m */; }; - B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B0B2E0000F00000AAA100000 /* XCTest.framework */; }; B0B2E0001400000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; - B0B2E0001500000AAA100000 /* EventQuickEntryKeywords.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0B2E0001100000AAA100000 /* EventQuickEntryKeywords.strings */; }; B0B2E0001E00000AAA100000 /* EventQuickEntryKeywords.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */; }; - B0B2E0001F00000AAA100000 /* EventQuickEntryKeywords.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001B00000AAA100000 /* EventQuickEntryKeywords.m */; }; B0B2E0002000000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */; }; - B0B2E0002100000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */ = {isa = PBXBuildFile; fileRef = B0B2E0001D00000AAA100000 /* EventQuickEntryLanguagePackRegistry.m */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -181,11 +175,8 @@ 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 = ""; }; - B0B2E0000200000AAA100000 /* ItsycalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ItsycalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventQuickEntryParserTests.m; 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 = ""; }; - B0B2E0000F00000AAA100000 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 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 = ""; }; @@ -205,14 +196,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B0B2E0000700000AAA100000 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - B0B2E0001000000AAA100000 /* XCTest.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -246,7 +229,6 @@ isa = PBXGroup; children = ( 9242A55E1A82A136005AA4B3 /* Itsycal */, - B0B2E0000800000AAA100000 /* ItsycalTests */, 9242A55D1A82A136005AA4B3 /* Products */, 92CF00001A82A136005AA4B3 /* Local.xcconfig */, ); @@ -256,7 +238,6 @@ isa = PBXGroup; children = ( 9242A55C1A82A136005AA4B3 /* Itsycal.app */, - B0B2E0000200000AAA100000 /* ItsycalTests.xctest */, ); name = Products; sourceTree = ""; @@ -359,14 +340,6 @@ name = MoCode; sourceTree = ""; }; - B0B2E0000800000AAA100000 /* ItsycalTests */ = { - isa = PBXGroup; - children = ( - B0B2E0000900000AAA100000 /* EventQuickEntryParserTests.m */, - ); - path = ItsycalTests; - sourceTree = ""; - }; B0B2E0002200000AAA100000 /* EventQuickEntry */ = { isa = PBXGroup; children = ( @@ -405,23 +378,6 @@ productReference = 9242A55C1A82A136005AA4B3 /* Itsycal.app */; productType = "com.apple.product-type.application"; }; - B0B2E0000100000AAA100000 /* ItsycalTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = B0B2E0000300000AAA100000 /* Build configuration list for PBXNativeTarget "ItsycalTests" */; - buildPhases = ( - B0B2E0000600000AAA100000 /* Sources */, - B0B2E0000700000AAA100000 /* Frameworks */, - B0B2E0001600000AAA100000 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = ItsycalTests; - productName = ItsycalTests; - productReference = B0B2E0000200000AAA100000 /* ItsycalTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -462,7 +418,6 @@ projectRoot = ""; targets = ( 9242A55B1A82A136005AA4B3 /* Itsycal */, - B0B2E0000100000AAA100000 /* ItsycalTests */, ); }; /* End PBXProject section */ @@ -482,14 +437,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B0B2E0001600000AAA100000 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B0B2E0001500000AAA100000 /* EventQuickEntryKeywords.strings in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -556,17 +503,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B0B2E0000600000AAA100000 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B0B2E0000A00000AAA100000 /* EventQuickEntryParserTests.m in Sources */, - B0B2E0000E00000AAA100000 /* EventQuickEntryParser.m in Sources */, - B0B2E0001F00000AAA100000 /* EventQuickEntryKeywords.m in Sources */, - B0B2E0002100000AAA100000 /* EventQuickEntryLanguagePackRegistry.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -785,50 +721,6 @@ }; name = Release; }; - B0B2E0000400000AAA100000 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GCC_C_LANGUAGE_STANDARD = gnu99; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 11.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.mowglii.ItsycalTests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - }; - name = Debug; - }; - B0B2E0000500000AAA100000 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GCC_C_LANGUAGE_STANDARD = gnu99; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 11.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.mowglii.ItsycalTests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -850,15 +742,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B0B2E0000300000AAA100000 /* Build configuration list for PBXNativeTarget "ItsycalTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B0B2E0000400000AAA100000 /* Debug */, - B0B2E0000500000AAA100000 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = 9242A5541A82A136005AA4B3 /* Project object */; diff --git a/ItsycalTests/EventQuickEntryParserTests.m b/ItsycalTests/EventQuickEntryParserTests.m deleted file mode 100644 index 3f5614c2..00000000 --- a/ItsycalTests/EventQuickEntryParserTests.m +++ /dev/null @@ -1,479 +0,0 @@ -// -// EventQuickEntryParserTests.m -// ItsycalTests -// -// Created by Scott Goci on 7/27/26. -// - -#import -#import "EventQuickEntryParser.h" -#import "EventQuickEntryKeywords.h" -#import "EventQuickEntryLanguagePackRegistry.h" - -@interface EventQuickEntryParserTests : XCTestCase -@property (nonatomic) EventQuickEntryParser *parser; -@property (nonatomic) NSCalendar *calendar; -@end - -@implementation EventQuickEntryParserTests - -- (void)setUp -{ - self.parser = [EventQuickEntryParser new]; - self.calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; -} - -- (void)testPlainTextBecomesTitleWithNoOtherFieldsSet -{ - EventQuickEntryResult *result = [self.parser parse:@"Team standup" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Team standup"); - XCTAssertNil(result.date); - XCTAssertEqual(result.durationMinutes, 0); - XCTAssertNil(result.location); - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); - XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)0); -} - -- (void)testDetectsDateOnlyPhraseWithoutExplicitTime -{ - EventQuickEntryResult *result = [self.parser parse:@"Dentist tomorrow" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Dentist"); - XCTAssertNotNil(result.date); - XCTAssertFalse(result.hasExplicitTime); - XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); -} - -- (void)testDetectsDateWithExplicitTime -{ - EventQuickEntryResult *result = [self.parser parse:@"Call mom tomorrow at 3pm" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Call mom"); - XCTAssertNotNil(result.date); - XCTAssertTrue(result.hasExplicitTime); -} - -- (void)testDanglingOnPrepositionBeforeDateIsConsumed -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting with Michele on Friday at 6PM for 10 minutes" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Meeting with Michele"); - XCTAssertNotNil(result.date); - XCTAssertTrue(result.hasExplicitTime); - XCTAssertEqual(result.durationMinutes, 10); -} - -- (void)testNoDatePhraseLeavesDateNil -{ - EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; - XCTAssertNil(result.date); - XCTAssertFalse(result.hasExplicitTime); -} - -- (void)testDetectsDurationInMinutes -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting for 30 minutes" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Meeting"); - XCTAssertEqual(result.durationMinutes, 30); -} - -- (void)testDetectsDurationInHours -{ - EventQuickEntryResult *result = [self.parser parse:@"Workshop for 2 hours" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Workshop"); - XCTAssertEqual(result.durationMinutes, 120); -} - -- (void)testDetectsHalfAnHour -{ - EventQuickEntryResult *result = [self.parser parse:@"Sync for half an hour" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Sync"); - XCTAssertEqual(result.durationMinutes, 30); -} - -- (void)testDetectsAnHour -{ - EventQuickEntryResult *result = [self.parser parse:@"1:1 for an hour" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"1:1"); - XCTAssertEqual(result.durationMinutes, 60); -} - -- (void)testDurationAndDateTogether -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting with Michele Goci for 30 minutes this Friday" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Meeting with Michele Goci"); - XCTAssertEqual(result.durationMinutes, 30); - XCTAssertNotNil(result.date); -} - -- (void)testDetectsLocationWithAt -{ - EventQuickEntryResult *result = [self.parser parse:@"Lunch at Cafe Luna" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Lunch"); - XCTAssertEqualObjects(result.location, @"Cafe Luna"); -} - -- (void)testDetectsLocationWithIn -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup in Conference Room B" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Standup"); - XCTAssertEqualObjects(result.location, @"Conference Room B"); -} - -- (void)testDisambiguatesAtTimeFromAtLocation -{ - EventQuickEntryResult *result = [self.parser parse:@"Lunch with Sam at Cafe Luna at noon" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Lunch with Sam"); - XCTAssertEqualObjects(result.location, @"Cafe Luna"); - XCTAssertNotNil(result.date); - XCTAssertTrue(result.hasExplicitTime); -} - -- (void)testNoLocationPhraseLeavesLocationNil -{ - EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; - XCTAssertNil(result.location); -} - -- (void)testDetectsEveryDay -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup every day" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Standup"); - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); -} - -- (void)testDetectsDaily -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup daily" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); -} - -- (void)testDetectsEveryWeek -{ - EventQuickEntryResult *result = [self.parser parse:@"1:1 every week" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"1:1"); - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryWeek); -} - -- (void)testDetectsEveryOtherWeek -{ - EventQuickEntryResult *result = [self.parser parse:@"Sprint planning every other week" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Sprint planning"); - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEvery2Weeks); -} - -- (void)testDetectsBiweekly -{ - EventQuickEntryResult *result = [self.parser parse:@"Sprint planning biweekly" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEvery2Weeks); -} - -- (void)testDetectsEveryMonth -{ - EventQuickEntryResult *result = [self.parser parse:@"Rent due every month" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryMonth); -} - -- (void)testDetectsEveryYear -{ - EventQuickEntryResult *result = [self.parser parse:@"Anniversary every year" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryYear); -} - -- (void)testNoRecurrencePhraseLeavesRecurrenceNone -{ - EventQuickEntryResult *result = [self.parser parse:@"Buy groceries" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); -} - -- (void)testDetectsLocationWithAtSign -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup @ Cafe Luna" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Standup"); - XCTAssertEqualObjects(result.location, @"Cafe Luna"); -} - -- (void)testAtSignWithoutSpaceIsNotRecognizedAsLocation -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup @Cafe Luna" calendar:self.calendar]; - XCTAssertNil(result.location); - XCTAssertEqualObjects(result.title, @"Standup @Cafe Luna"); -} - -- (void)testDateSpanHasLabelAndDisplayValueWithoutExplicitTime -{ - EventQuickEntryResult *result = [self.parser parse:@"Dentist tomorrow" calendar:self.calendar]; - XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Date"); - XCTAssertFalse([span.displayValue containsString:@":"]); // no time-of-day rendered -} - -- (void)testDateSpanHasDisplayValueWithExplicitTime -{ - EventQuickEntryResult *result = [self.parser parse:@"Call mom tomorrow at 3pm" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Date"); - XCTAssertTrue([span.displayValue containsString:@"at"]); -} - -- (void)testDurationSpanDisplayValueMinutesOnly -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting for 30 minutes" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Duration"); - XCTAssertEqualObjects(span.displayValue, @"30 minutes"); -} - -- (void)testDurationSpanDisplayValueHoursOnly -{ - EventQuickEntryResult *result = [self.parser parse:@"Workshop for 2 hours" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.displayValue, @"2 hours"); -} - -- (void)testDurationSpanDisplayValueHoursAndMinutes -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting for 90 minutes" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.displayValue, @"1 hour 30 minutes"); -} - -- (void)testLocationSpanDisplayValueIsThePlace -{ - EventQuickEntryResult *result = [self.parser parse:@"Lunch at Cafe Luna" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Location"); - XCTAssertEqualObjects(span.displayValue, @"Cafe Luna"); -} - -- (void)testRecurrenceSpanDisplayValueMatchesRepPopupTitle -{ - EventQuickEntryResult *result = [self.parser parse:@"Standup every day" calendar:self.calendar]; - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Repeat"); - XCTAssertEqualObjects(span.displayValue, @"Every Day"); -} - -- (void)testUsesImpliedDurationFromTimeRange -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting 3-4pm Friday" calendar:self.calendar]; - XCTAssertEqual(result.durationMinutes, 60); - XCTAssertTrue(result.hasExplicitTime); -} - -- (void)testExplicitForPhraseWinsOverImpliedDuration -{ - EventQuickEntryResult *result = [self.parser parse:@"Meeting 3-4pm for 30 minutes" calendar:self.calendar]; - XCTAssertEqual(result.durationMinutes, 30); -} - -- (void)testHasExplicitTimeForMorning -{ - EventQuickEntryResult *result = [self.parser parse:@"Coffee tomorrow morning" calendar:self.calendar]; - XCTAssertTrue(result.hasExplicitTime); - NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; - XCTAssertEqual(hour, 9); -} - -- (void)testHasExplicitTimeForAfternoon -{ - EventQuickEntryResult *result = [self.parser parse:@"Coffee tomorrow afternoon" calendar:self.calendar]; - XCTAssertTrue(result.hasExplicitTime); - NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; - XCTAssertEqual(hour, 15); -} - -- (void)testHasExplicitTimeForEvening -{ - EventQuickEntryResult *result = [self.parser parse:@"Call tomorrow evening" calendar:self.calendar]; - XCTAssertTrue(result.hasExplicitTime); - NSInteger hour = [self.calendar component:NSCalendarUnitHour fromDate:result.date]; - XCTAssertEqual(hour, 18); -} - -- (void)testRestoresLeadingMealWordSwallowedByDateMatch -{ - EventQuickEntryResult *result = [self.parser parse:@"Lunch tomorrow afternoon" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Lunch"); - XCTAssertNotNil(result.date); -} - -#pragma mark - Language pack registry - -- (void)testRegistryReturnsEnglishPackForEnglishCode -{ - id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en"]; - XCTAssertNotNil(pack); - XCTAssertEqualObjects(pack.placeholderExample, @"Meeting with Bob for 30 min this Friday"); -} - -- (void)testRegistryReturnsEnglishPackForRegionalEnglishCode -{ - id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"en-US"]; - XCTAssertNotNil(pack); - XCTAssertEqualObjects(pack.placeholderExample, @"Meeting with Bob for 30 min this Friday"); -} - -- (void)testRegistryReturnsSpanishPackForSpanishCode -{ - id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]; - XCTAssertNotNil(pack); - XCTAssertEqualObjects(pack.placeholderExample, @"Reunión con Bob durante 30 min este viernes"); -} - -- (void)testRegistryReturnsNilForUnsupportedCode -{ - id pack = [EventQuickEntryLanguagePackRegistry packForLanguageCode:@"de"]; - XCTAssertNil(pack); -} - -#pragma mark - Spanish language pack - -- (void)testSpanishDetectsDateWithoutSwallowingTitle -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión mañana" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Reunión"); - XCTAssertNotNil(result.date); -} - -- (void)testSpanishStripsDanglingElBeforeDate -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión el próximo lunes" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Reunión"); - XCTAssertNotNil(result.date); -} - -- (void)testSpanishStripsDanglingEstaBeforeDate -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Cena esta noche" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Cena"); - XCTAssertNotNil(result.date); - XCTAssertTrue(result.hasExplicitTime); -} - -- (void)testSpanishDetectsExplicitTimeWithAmPm -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Almuerzo mañana a las 3pm" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Almuerzo"); - XCTAssertTrue(result.hasExplicitTime); -} - -- (void)testSpanishDetectsDuration -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión durante 30 minutos" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Reunión"); - XCTAssertEqual(result.durationMinutes, 30); -} - -- (void)testSpanishDetectsHalfHourIdiom -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión durante media hora" calendar:self.calendar]; - XCTAssertEqual(result.durationMinutes, 30); -} - -- (void)testSpanishDetectsLocation -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Almuerzo en Cafe Luna" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Almuerzo"); - XCTAssertEqualObjects(result.location, @"Cafe Luna"); -} - -- (void)testSpanishDetectsRecurrenceEveryDay -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; - XCTAssertEqualObjects(result.title, @"Reunión"); - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceEveryDay); -} - -- (void)testSpanishRecurrenceSpanDisplayValueMatchesExistingTranslation -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Reunión todos los días" calendar:self.calendar]; - XCTAssertEqual(result.recognizedSpans.count, (NSUInteger)1); - EventQuickEntrySpan *span = result.recognizedSpans.firstObject; - XCTAssertEqualObjects(span.label, @"Repetir"); - XCTAssertEqualObjects(span.displayValue, @"Todos los días"); -} - -- (void)testSpanishNoRecurrencePhraseLeavesRecurrenceNone -{ - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:[EventQuickEntryLanguagePackRegistry packForLanguageCode:@"es"]]; - EventQuickEntryResult *result = [parser parse:@"Comprar víveres" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); - XCTAssertNil(result.location); -} - -#pragma mark - Partial/incomplete keyword configs (regression coverage for the -#pragma mark empty-alternation-group hazard on +alternationPatternForPhrases:) - -- (EventQuickEntryKeywords *)minimalKeywordsWithOneHourPhrases:(NSArray *)oneHourPhrases - recurrencePhrasesByFrequencyKey:(NSDictionary *> *)recurrencePhrases -{ - EventQuickEntryKeywords *keywords = [EventQuickEntryKeywords new]; - keywords.languageCode = @"en"; - keywords.durationPrefixWord = @"for"; - keywords.halfHourPhrase = @"half an hour"; - keywords.oneHourPhrases = oneHourPhrases; - keywords.hourUnitWords = @[@"hours", @"hour"]; - keywords.minuteUnitWords = @[@"minutes", @"minute"]; - keywords.locationPrefixWords = @[@"at", @"in"]; - keywords.danglingPrefixWords = @[@"on"]; - keywords.mealWords = @[]; - keywords.explicitTimeWords = @[]; - keywords.recurrencePhrasesByFrequencyKey = recurrencePhrases; - NSMutableDictionary *labels = [NSMutableDictionary new]; - for (NSString *key in recurrencePhrases) labels[key] = key; - keywords.recurrenceLabelsByFrequencyKey = labels; - keywords.dateSpanLabel = @"Date"; - keywords.durationSpanLabel = @"Duration"; - keywords.locationSpanLabel = @"Location"; - keywords.repeatSpanLabel = @"Repeat"; - keywords.dateTimeConnector = @"at"; - keywords.placeholderExample = @""; - return keywords; -} - -- (void)testEmptyOneHourPhrasesDoesNotFalsePositiveMatchEveryForPhrase -{ - EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[] recurrencePhrasesByFrequencyKey:@{}]; - EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; - - EventQuickEntryResult *result = [parser parse:@"Standup for the whole team" calendar:self.calendar]; - XCTAssertEqual(result.durationMinutes, 0); -} - -- (void)testConfiguredOneHourPhraseStillWorksAlongsideEmptyOnes -{ - EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[@"an hour"] recurrencePhrasesByFrequencyKey:@{}]; - EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; - - EventQuickEntryResult *result = [parser parse:@"Workshop for an hour" calendar:self.calendar]; - XCTAssertEqual(result.durationMinutes, 60); -} - -- (void)testEmptyRecurrencePhrasesForOneFrequencyDoesNotFalsePositiveMatchEverything -{ - // Only "everyDay" is configured; simulates a partially-filled-in - // language pack that hasn't supplied phrases for the other four - // frequencies yet. - EventQuickEntryKeywords *keywords = [self minimalKeywordsWithOneHourPhrases:@[@"an hour"] - recurrencePhrasesByFrequencyKey:@{@"everyDay": @[@"every day"]}]; - EventQuickEntryKeywordLanguagePack *pack = [[EventQuickEntryKeywordLanguagePack alloc] initWithKeywords:keywords]; - EventQuickEntryParser *parser = [[EventQuickEntryParser alloc] initWithLanguagePack:pack]; - - EventQuickEntryResult *result = [parser parse:@"Team standup" calendar:self.calendar]; - XCTAssertEqual(result.recurrence, EventQuickEntryRecurrenceNone); - - EventQuickEntryResult *dailyResult = [parser parse:@"Standup every day" calendar:self.calendar]; - XCTAssertEqual(dailyResult.recurrence, EventQuickEntryRecurrenceEveryDay); -} - -@end From 87ac4439c966803cad5c059c9bed06fb32011a15 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:08:33 -0400 Subject: [PATCH 09/16] Add blank lines between commented properties in EventQuickEntryKeywords.h Matches the existing spacing convention in EventCenter.h. --- Itsycal/EventQuickEntryKeywords.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Itsycal/EventQuickEntryKeywords.h b/Itsycal/EventQuickEntryKeywords.h index 50e40099..577d72fb 100644 --- a/Itsycal/EventQuickEntryKeywords.h +++ b/Itsycal/EventQuickEntryKeywords.h @@ -16,56 +16,71 @@ NS_ASSUME_NONNULL_BEGIN // 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; + // Example phrase shown as the quick-entry field's placeholder text. @property (nonatomic, copy) NSString *placeholderExample; @@ -77,11 +92,13 @@ NS_ASSUME_NONNULL_BEGIN // / "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 @@ -91,12 +108,15 @@ NS_ASSUME_NONNULL_BEGIN // 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 From b64f0b9a17f694ca12c6ef15bacb726817a3ec1f Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:13:26 -0400 Subject: [PATCH 10/16] Match mowglii's pragma mark and trivial-class conventions - Collapse EventQuickEntrySpan's implementation to one line, matching EventCenter.m's CalendarInfo/EventInfo pattern for property-only classes. - Convert EventQuickEntryKeywords.m's section marks to the two-line '#pragma mark -' / '#pragma mark Name' form used throughout AgendaViewController.m, ItsycalWindow.m, MoCalToolTipWC.m, and PrefsGeneralVC.m. --- Itsycal/EventQuickEntryKeywords.m | 10 +++++++++- Itsycal/EventQuickEntryParser.m | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index dbe6664d..161b93a4 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -60,7 +60,8 @@ + (nullable instancetype)keywordsWithContentsOfStringsFileAtPath:(NSString *)pat @end -#pragma mark - EventQuickEntryKeywordLanguagePack +#pragma mark - +#pragma mark EventQuickEntryKeywordLanguagePack @implementation EventQuickEntryKeywordLanguagePack { @@ -90,6 +91,7 @@ - (instancetype)initWithKeywords:(EventQuickEntryKeywords *)keywords return self; } +#pragma mark - #pragma mark Regex construction // Returns nil (not an empty string) when `phrases` is empty. This matters: @@ -224,6 +226,7 @@ - (void)buildRecurrenceRegexesFromKeywords:(EventQuickEntryKeywords *)keywords _recurrenceLabels = labels; } +#pragma mark - #pragma mark EventQuickEntryLanguagePack — placeholder - (NSString *)placeholderExample @@ -231,6 +234,7 @@ - (NSString *)placeholderExample return _keywords.placeholderExample; } +#pragma mark - #pragma mark Shared masking utility + (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked @@ -239,6 +243,7 @@ + (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked [masked replaceCharactersInRange:range withString:blank]; } +#pragma mark - #pragma mark EventQuickEntryLanguagePack — Date/time - (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked @@ -333,6 +338,7 @@ - (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 @@ -383,6 +389,7 @@ - (NSString *)displayValueForDurationMinutes:(NSInteger)minutes return [NSString stringWithFormat:@"%@ %ld %@", hoursPart, (long)remainder, minuteWordPlural]; } +#pragma mark - #pragma mark EventQuickEntryLanguagePack — Location - (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result @@ -407,6 +414,7 @@ - (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked return span; } +#pragma mark - #pragma mark EventQuickEntryLanguagePack — Recurrence - (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index 953b8d5c..1d3b877d 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -8,8 +8,7 @@ #import "EventQuickEntryParser.h" #import "EventQuickEntryLanguagePackRegistry.h" -@implementation EventQuickEntrySpan -@end +@implementation EventQuickEntrySpan @end @implementation EventQuickEntryResult From 578bea4fb1ceb653cd9e14d7d6632d28d00ae5cf Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:18:13 -0400 Subject: [PATCH 11/16] Un-wrap dateSpanInMasked: signature to match project's one-line convention mowglii never wraps method declarations across multiple lines regardless of length (confirmed no colon-aligned multi-line signatures anywhere in the existing codebase, including 100+ char ones). --- Itsycal/EventQuickEntryKeywords.m | 5 +---- Itsycal/EventQuickEntryLanguagePack.h | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index 161b93a4..91152641 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -246,10 +246,7 @@ + (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked #pragma mark - #pragma mark EventQuickEntryLanguagePack — Date/time -- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked - original:(NSString *)original - result:(EventQuickEntryResult *)result - calendar:(NSCalendar *)calendar +- (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result calendar:(NSCalendar *)calendar { NSError *error = nil; NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error]; diff --git a/Itsycal/EventQuickEntryLanguagePack.h b/Itsycal/EventQuickEntryLanguagePack.h index da9ba9f7..960f58ed 100644 --- a/Itsycal/EventQuickEntryLanguagePack.h +++ b/Itsycal/EventQuickEntryLanguagePack.h @@ -31,10 +31,7 @@ NS_ASSUME_NONNULL_BEGIN // 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 *)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 result:(EventQuickEntryResult *)result; - (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; From eb9217fd2f67710b990b3b1802d71d396eaf5d0b Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:24:47 -0400 Subject: [PATCH 12/16] Fix location span swallowing trailing blanked spans into its range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The location regex ended in a consuming \s*$, which folded any trailing whitespace into the match — including the equal-length spaces left by an already-blanked duration span, since duration is parsed before location. That inflated the location span's range far past its actual text, overlapping the duration span and rendering as a single merged underline instead of two separate ones. Switched to a zero-width lookahead (?=\s*$) so the same 'nothing but whitespace follows' requirement holds without consuming those characters into the match. --- Itsycal/EventQuickEntryKeywords.m | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index 91152641..539b1533 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -184,9 +184,18 @@ - (void)buildLocationRegexFromKeywords:(EventQuickEntryKeywords *)keywords // 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). + // + // The trailing "(?=\s*$)" is a zero-width lookahead, not a consuming + // match: it requires nothing but whitespace after the location, without + // folding that whitespace into the match itself. That distinction + // matters because location is matched after date/duration have already + // blanked their own text to spaces — a consuming "\s*$" would swallow + // any such blanked region trailing the location (e.g. "At Cafe Luna for + // 15 minutes", once "for 15 minutes" is blanked), inflating this span's + // range far past the actual location text. NSString *prefixAlt = [[self class] alternationPatternForPhrases:keywords.locationPrefixWords]; NSString *prefixGroup = prefixAlt ? [NSString stringWithFormat:@"\\b(?:%@)|@", prefixAlt] : @"@"; - NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?\\s*$", prefixGroup, prefixGroup]; + NSString *pattern = [NSString stringWithFormat:@"(?i)(?:%@)\\s+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?(?=\\s*$)", prefixGroup, prefixGroup]; _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; } From d0fc736f87272a32fedc0047ba11002ac1b20d46 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:41:55 -0400 Subject: [PATCH 13/16] Fix location swallowing trailing text it never actually recognized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (a zero-width lookahead requiring nothing but whitespace to the end of the string) handled a location followed only by an already-blanked span, but broke again once any genuinely unrecognized text trailed it too — e.g. 'Meeting with Bob at Cafe Luna for 15 minutes at 2pm next week', where NSDataDetector only recognizes '2pm' and leaves 'next week' unblanked. The regex had no way to distinguish 'more location text' from a blanked span's leftover spaces using `masked` alone, so it kept expanding to try to reach the string's end and pulled in everything up to and including that trailing text. Location matching now walks forward manually from the prefix word, comparing `masked` against `original` (added as a new parameter to the protocol method, mirroring dateSpanInMasked's existing `original` parameter) to find exactly where an earlier detector's blanking begins — the one distinction a regex can't express on its own. A trailing dangling preposition immediately before that boundary (e.g. 'Cafe Luna at' before a blanked 'noon') is still trimmed out of the location value and folded into the blanked range, preserving the existing disambiguation behavior. --- Itsycal/EventQuickEntryKeywords.m | 114 +++++++++++++++++++------- Itsycal/EventQuickEntryLanguagePack.h | 2 +- Itsycal/EventQuickEntryParser.m | 2 +- 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index 539b1533..c061872c 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -70,7 +70,8 @@ @implementation EventQuickEntryKeywordLanguagePack NSRegularExpression *_durationRegex; NSRegularExpression *_mealWordRegex; // nil if no meal words configured NSRegularExpression *_danglingPrefixRegex; // nil if no dangling words configured - NSRegularExpression *_locationRegex; + NSRegularExpression *_locationPrefixRegex; + NSRegularExpression *_trailingLocationPrefixRegex; NSArray *_recurrenceRegexes; // ordered: every2Weeks, everyDay, everyWeek, everyMonth, everyYear NSArray *_recurrenceValues; // parallel EventQuickEntryRecurrence values NSArray *_recurrenceLabels; // parallel display labels @@ -185,18 +186,24 @@ - (void)buildLocationRegexFromKeywords:(EventQuickEntryKeywords *)keywords // boundary doesn't apply to "@" itself (a non-word character can't be // preceded by \b the way "@ Cafe Luna" needs it to). // - // The trailing "(?=\s*$)" is a zero-width lookahead, not a consuming - // match: it requires nothing but whitespace after the location, without - // folding that whitespace into the match itself. That distinction - // matters because location is matched after date/duration have already - // blanked their own text to spaces — a consuming "\s*$" would swallow - // any such blanked region trailing the location (e.g. "At Cafe Luna for - // 15 minutes", once "for 15 minutes" is blanked), inflating this span's - // range far past the actual location text. + // 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+([A-Za-z0-9][^,]*?)(?:\\s+(?:%@))?(?=\\s*$)", prefixGroup, prefixGroup]; - _locationRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; + 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 @@ -398,26 +405,77 @@ - (NSString *)displayValueForDurationMinutes:(NSInteger)minutes #pragma mark - #pragma mark EventQuickEntryLanguagePack — Location -- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result { - NSTextCheckingResult *match = [_locationRegex firstMatchInString:masked options:0 range:NSMakeRange(0, masked.length)]; - if (!match) return nil; - - NSRange placeRange = [match rangeAtIndex:1]; - if (placeRange.location == NSNotFound) return nil; - - NSString *place = [[masked substringWithRange:placeRange] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - if (place.length == 0) return nil; + 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; +} - result.location = place; - [[self class] blankRange:match.range inMasked:masked]; +// 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; +} - EventQuickEntrySpan *span = [EventQuickEntrySpan new]; - span.range = match.range; - span.kind = EventQuickEntrySpanKindLocation; - span.label = _keywords.locationSpanLabel; - span.displayValue = place; - return span; +// `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 - diff --git a/Itsycal/EventQuickEntryLanguagePack.h b/Itsycal/EventQuickEntryLanguagePack.h index 960f58ed..94f14fe9 100644 --- a/Itsycal/EventQuickEntryLanguagePack.h +++ b/Itsycal/EventQuickEntryLanguagePack.h @@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN @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 result:(EventQuickEntryResult *)result; +- (nullable EventQuickEntrySpan *)locationSpanInMasked:(NSMutableString *)masked original:(NSString *)original result:(EventQuickEntryResult *)result; - (nullable EventQuickEntrySpan *)recurrenceSpanInMasked:(NSMutableString *)masked result:(EventQuickEntryResult *)result; @end diff --git a/Itsycal/EventQuickEntryParser.m b/Itsycal/EventQuickEntryParser.m index 1d3b877d..d6b9bc94 100644 --- a/Itsycal/EventQuickEntryParser.m +++ b/Itsycal/EventQuickEntryParser.m @@ -61,7 +61,7 @@ - (EventQuickEntryResult *)parse:(NSString *)text calendar:(NSCalendar *)calenda EventQuickEntrySpan *durationSpan = [_languagePack durationSpanInMasked:masked result:result]; if (durationSpan) [recognizedSpans addObject:durationSpan]; - EventQuickEntrySpan *locationSpan = [_languagePack locationSpanInMasked:masked result:result]; + EventQuickEntrySpan *locationSpan = [_languagePack locationSpanInMasked:masked original:original result:result]; if (locationSpan) [recognizedSpans addObject:locationSpan]; EventQuickEntrySpan *recurrenceSpan = [_languagePack recurrenceSpanInMasked:masked result:result]; From 2b0e807ba2e7a4873b42c7e2a79c50de1d100d57 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 14:54:21 -0400 Subject: [PATCH 14/16] Add custom detection for relative-period phrases NSDataDetector misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed NSDataDetector directly: it resolves day-of-week-based phrases ('next Tuesday') and day-count phrases ('in 3 days', 'tomorrow') fine, but returns no match whatsoever for bare relative-period phrases like 'next week' or 'next month', even standalone — confirmed this is why 'Meeting ... at 2pm next week' only ever picked up '2pm' and silently dropped 'next week'. Adds a small keyword-driven detector (nextWeekPhrase/nextMonthPhrase/ relativeWeeksPrefixWord/weekUnitWords) that runs before NSDataDetector gets a chance, covering 'next week', 'next month', and 'in N weeks'. Since these phrases carry no time of their own, it separately looks for an explicit time phrase immediately before or after (e.g. 'next week at 2pm' or 'at 2pm next week') and combines them, resolving the isolated time phrase via NSDataDetector directly since that part it already handles correctly on its own. Left as a known, documented limitation: phrases further from this mechanism's coverage (e.g. 'in 2 months', 'next year', '3 weeks from now') still fall through to NSDataDetector's existing gaps. --- .../EventQuickEntryKeywords.strings | 6 + Itsycal/EventQuickEntryKeywords.h | 13 ++ Itsycal/EventQuickEntryKeywords.m | 138 ++++++++++++++++++ .../es.lproj/EventQuickEntryKeywords.strings | 13 +- 4 files changed, 169 insertions(+), 1 deletion(-) diff --git a/Itsycal/Base.lproj/EventQuickEntryKeywords.strings b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings index 8ce9d05d..504288a7 100644 --- a/Itsycal/Base.lproj/EventQuickEntryKeywords.strings +++ b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings @@ -19,6 +19,12 @@ "explicitTimeWords" = "noon|midnight|morning|afternoon|evening"; "dateTimeConnector" = "at"; +/* Relative-period phrases NSDataDetector doesn't recognize on its own */ +"nextWeekPhrase" = "next week"; +"nextMonthPhrase" = "next month"; +"relativeWeeksPrefixWord" = "in"; +"weekUnitWords" = "weeks|week"; + /* Recurrence phrases, keyed by frequency */ "recurrencePhrases.every2Weeks" = "every other week|biweekly"; "recurrencePhrases.everyDay" = "every day|daily"; diff --git a/Itsycal/EventQuickEntryKeywords.h b/Itsycal/EventQuickEntryKeywords.h index 577d72fb..3f4ec9dc 100644 --- a/Itsycal/EventQuickEntryKeywords.h +++ b/Itsycal/EventQuickEntryKeywords.h @@ -81,6 +81,19 @@ NS_ASSUME_NONNULL_BEGIN // 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" or "next month", even standalone. These four properties +// drive a small custom detector that runs before NSDataDetector to cover +// that gap; leaving any of them empty simply disables that phrase (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 *relativeWeeksPrefixWord; // word before a count, e.g. "in" (en) +@property (nonatomic, copy) NSArray *weekUnitWords; // e.g. @[@"weeks", @"week"] (en) + // Example phrase shown as the quick-entry field's placeholder text. @property (nonatomic, copy) NSString *placeholderExample; diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index c061872c..ebc150ef 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -54,6 +54,10 @@ + (nullable instancetype)keywordsWithContentsOfStringsFileAtPath:(NSString *)pat keywords.locationSpanLabel = strings[@"locationSpanLabel"]; keywords.repeatSpanLabel = strings[@"repeatSpanLabel"]; keywords.dateTimeConnector = strings[@"dateTimeConnector"]; + keywords.nextWeekPhrase = strings[@"nextWeekPhrase"]; + keywords.nextMonthPhrase = strings[@"nextMonthPhrase"]; + keywords.relativeWeeksPrefixWord = strings[@"relativeWeeksPrefixWord"]; + keywords.weekUnitWords = [self arrayForDelimitedString:strings[@"weekUnitWords"]]; keywords.placeholderExample = strings[@"placeholderExample"]; return keywords; } @@ -72,6 +76,7 @@ @implementation EventQuickEntryKeywordLanguagePack 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 @@ -88,6 +93,7 @@ - (instancetype)initWithKeywords:(EventQuickEntryKeywords *)keywords [self buildDanglingPrefixRegexFromKeywords:keywords]; [self buildLocationRegexFromKeywords:keywords]; [self buildRecurrenceRegexesFromKeywords:keywords]; + [self buildRelativePeriodRegexFromKeywords:keywords]; } return self; } @@ -242,6 +248,30 @@ - (void)buildRecurrenceRegexesFromKeywords:(EventQuickEntryKeywords *)keywords _recurrenceLabels = labels; } +- (void)buildRelativePeriodRegexFromKeywords:(EventQuickEntryKeywords *)keywords +{ + // Three independently-optional alternatives, each keyed by a fixed + // capture group index (1=next week, 2=next month, 3=count of weeks) — + // -relativePeriodSpanInMasked:original:result:calendar: reads those + // indices directly, so a missing phrase gets "(?!)" rather than being + // omitted, keeping the other groups' numbering fixed. Same reasoning + // as buildDurationRegexFromKeywords: above. + NSString *nextWeek = keywords.nextWeekPhrase.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.nextWeekPhrase] + : @"(?!)"; + NSString *nextMonth = keywords.nextMonthPhrase.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.nextMonthPhrase] + : @"(?!)"; + NSString *weeksPrefixWord = keywords.relativeWeeksPrefixWord.length > 0 + ? [NSRegularExpression escapedPatternForString:keywords.relativeWeeksPrefixWord] + : @"(?!)"; + NSString *weekUnitAlt = [[self class] alternationPatternForPhrases:keywords.weekUnitWords] ?: @"(?!)"; + + NSString *pattern = [NSString stringWithFormat:@"(?i)\\b(%@)\\b|\\b(%@)\\b|\\b%@\\s+(\\d+)\\s*(?:%@)\\b", + nextWeek, nextMonth, weeksPrefixWord, weekUnitAlt]; + _relativePeriodRegex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; +} + #pragma mark - #pragma mark EventQuickEntryLanguagePack — placeholder @@ -264,6 +294,13 @@ + (void)blankRange:(NSRange)range inMasked:(NSMutableString *)masked - (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; @@ -296,6 +333,107 @@ - (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked ori 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", or +// "in N weeks", 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 (match.numberOfRanges > 1 && [match rangeAtIndex:1].location != NSNotFound) { + offset.weekOfYear = 1; + } else if (match.numberOfRanges > 2 && [match rangeAtIndex:2].location != NSNotFound) { + offset.month = 1; + } else if (match.numberOfRanges > 3 && [match rangeAtIndex:3].location != NSNotFound) { + offset.weekOfYear = [[masked substringWithRange:[match rangeAtIndex:3]] integerValue]; + } 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; +} + +// 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; +} + +- (BOOL)isShortConnectorGapInOriginal:(NSString *)original from:(NSUInteger)start to:(NSUInteger)end +{ + NSString *gapText = [original substringWithRange:NSMakeRange(start, end - start)]; + NSString *gap = [gapText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (gap.length == 0) return YES; + return gap.length <= 6 && [gap rangeOfCharacterFromSet:[NSCharacterSet whitespaceCharacterSet]].location == NSNotFound; +} + // 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 diff --git a/Itsycal/es.lproj/EventQuickEntryKeywords.strings b/Itsycal/es.lproj/EventQuickEntryKeywords.strings index 3ae6f2d2..cff184fb 100644 --- a/Itsycal/es.lproj/EventQuickEntryKeywords.strings +++ b/Itsycal/es.lproj/EventQuickEntryKeywords.strings @@ -24,7 +24,12 @@ 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. */ + already shows. + + nextWeekPhrase/nextMonthPhrase/relativeWeeksPrefixWord/weekUnitWords 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. */ "languageCode" = "es"; @@ -44,6 +49,12 @@ "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"; +"relativeWeeksPrefixWord" = "en"; +"weekUnitWords" = "semanas|semana"; + /* 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"; From e4654c76535717a1beeaa8045d5068fc9e279af8 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 15:01:52 -0400 Subject: [PATCH 15/16] Extend relative-period detection to years and the 'from now' suffix form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the previous next-week/next-month/in-N-weeks detector to all three units symmetrically: 'next week/month/year', 'in N weeks/months/years', and the equivalent 'N weeks/months/years from now' suffix form. Renamed relativeWeeksPrefixWord to relativePeriodPrefixWord since it's now shared across all three units, and added relativePeriodFromNowSuffix alongside it. Also recognizes spelled-out counts ('two weeks from now', not just '2 weeks from now') via a new numberWords keyword list, whose position (not the word itself) carries the value — digit strings are still always recognized regardless of what's configured there. --- .../EventQuickEntryKeywords.strings | 7 +- Itsycal/EventQuickEntryKeywords.h | 29 +++-- Itsycal/EventQuickEntryKeywords.m | 109 ++++++++++++++---- .../es.lproj/EventQuickEntryKeywords.strings | 20 +++- 4 files changed, 126 insertions(+), 39 deletions(-) diff --git a/Itsycal/Base.lproj/EventQuickEntryKeywords.strings b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings index 504288a7..cf0153a5 100644 --- a/Itsycal/Base.lproj/EventQuickEntryKeywords.strings +++ b/Itsycal/Base.lproj/EventQuickEntryKeywords.strings @@ -22,8 +22,13 @@ /* Relative-period phrases NSDataDetector doesn't recognize on its own */ "nextWeekPhrase" = "next week"; "nextMonthPhrase" = "next month"; -"relativeWeeksPrefixWord" = "in"; +"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"; diff --git a/Itsycal/EventQuickEntryKeywords.h b/Itsycal/EventQuickEntryKeywords.h index 3f4ec9dc..e991decf 100644 --- a/Itsycal/EventQuickEntryKeywords.h +++ b/Itsycal/EventQuickEntryKeywords.h @@ -85,14 +85,27 @@ NS_ASSUME_NONNULL_BEGIN // 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" or "next month", even standalone. These four properties -// drive a small custom detector that runs before NSDataDetector to cover -// that gap; leaving any of them empty simply disables that phrase (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 *relativeWeeksPrefixWord; // word before a count, e.g. "in" (en) -@property (nonatomic, copy) NSArray *weekUnitWords; // e.g. @[@"weeks", @"week"] (en) +// "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; diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index ebc150ef..cbd942ce 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -56,8 +56,13 @@ + (nullable instancetype)keywordsWithContentsOfStringsFileAtPath:(NSString *)pat keywords.dateTimeConnector = strings[@"dateTimeConnector"]; keywords.nextWeekPhrase = strings[@"nextWeekPhrase"]; keywords.nextMonthPhrase = strings[@"nextMonthPhrase"]; - keywords.relativeWeeksPrefixWord = strings[@"relativeWeeksPrefixWord"]; + 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; } @@ -250,28 +255,46 @@ - (void)buildRecurrenceRegexesFromKeywords:(EventQuickEntryKeywords *)keywords - (void)buildRelativePeriodRegexFromKeywords:(EventQuickEntryKeywords *)keywords { - // Three independently-optional alternatives, each keyed by a fixed - // capture group index (1=next week, 2=next month, 3=count of weeks) — - // -relativePeriodSpanInMasked:original:result:calendar: reads those - // indices directly, so a missing phrase gets "(?!)" rather than being - // omitted, keeping the other groups' numbering fixed. Same reasoning - // as buildDurationRegexFromKeywords: above. - NSString *nextWeek = keywords.nextWeekPhrase.length > 0 - ? [NSRegularExpression escapedPatternForString:keywords.nextWeekPhrase] - : @"(?!)"; - NSString *nextMonth = keywords.nextMonthPhrase.length > 0 - ? [NSRegularExpression escapedPatternForString:keywords.nextMonthPhrase] - : @"(?!)"; - NSString *weeksPrefixWord = keywords.relativeWeeksPrefixWord.length > 0 - ? [NSRegularExpression escapedPatternForString:keywords.relativeWeeksPrefixWord] - : @"(?!)"; - NSString *weekUnitAlt = [[self class] alternationPatternForPhrases:keywords.weekUnitWords] ?: @"(?!)"; + // 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)\\b(%@)\\b|\\b(%@)\\b|\\b%@\\s+(\\d+)\\s*(?:%@)\\b", - nextWeek, nextMonth, weeksPrefixWord, weekUnitAlt]; + 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 @@ -336,8 +359,9 @@ - (nullable EventQuickEntrySpan *)dateSpanInMasked:(NSMutableString *)masked ori // 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", or -// "in N weeks", even standalone. Computes today's date (at noon, matching +// 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 @@ -349,12 +373,24 @@ - (nullable EventQuickEntrySpan *)relativePeriodSpanInMasked:(NSMutableString *) if (!match) return nil; NSDateComponents *offset = [NSDateComponents new]; - if (match.numberOfRanges > 1 && [match rangeAtIndex:1].location != NSNotFound) { + if ([self group:1 presentInMatch:match]) { offset.weekOfYear = 1; - } else if (match.numberOfRanges > 2 && [match rangeAtIndex:2].location != NSNotFound) { + } else if ([self group:2 presentInMatch:match]) { offset.month = 1; - } else if (match.numberOfRanges > 3 && [match rangeAtIndex:3].location != NSNotFound) { - offset.weekOfYear = [[masked substringWithRange:[match rangeAtIndex:3]] integerValue]; + } 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; } @@ -379,6 +415,29 @@ - (nullable EventQuickEntrySpan *)relativePeriodSpanInMasked:(NSMutableString *) 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 diff --git a/Itsycal/es.lproj/EventQuickEntryKeywords.strings b/Itsycal/es.lproj/EventQuickEntryKeywords.strings index cff184fb..c26bb2d6 100644 --- a/Itsycal/es.lproj/EventQuickEntryKeywords.strings +++ b/Itsycal/es.lproj/EventQuickEntryKeywords.strings @@ -26,10 +26,15 @@ quick-entry's tooltip text stays consistent with what the popup itself already shows. - nextWeekPhrase/nextMonthPhrase/relativeWeeksPrefixWord/weekUnitWords 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. */ + 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"; @@ -52,8 +57,13 @@ /* Relative-period phrases NSDataDetector doesn't recognize on its own */ "nextWeekPhrase" = "la próxima semana"; "nextMonthPhrase" = "el próximo mes"; -"relativeWeeksPrefixWord" = "en"; +"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"; From 0315c4522c4ff57a93e646df9c14abf0f7a4c4a3 Mon Sep 17 00:00:00 2001 From: Scott Goci Date: Mon, 27 Jul 2026 15:09:31 -0400 Subject: [PATCH 16/16] Fix relative-period time connector rejecting multi-word connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap check between a relative-period phrase and a nearby explicit time required a single word with no internal whitespace, which fit English 'at' but rejected Spanish's two-word 'a las' — so the time never combined and leaked straight into the title instead. The length cap alone is sufficient to bound false positives; the single-word requirement wasn't needed. --- Itsycal/EventQuickEntryKeywords.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Itsycal/EventQuickEntryKeywords.m b/Itsycal/EventQuickEntryKeywords.m index cbd942ce..c381b1a9 100644 --- a/Itsycal/EventQuickEntryKeywords.m +++ b/Itsycal/EventQuickEntryKeywords.m @@ -485,12 +485,14 @@ - (nullable NSTextCheckingResult *)timeMatchNearRange:(NSRange)range inOriginal: 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]]; - if (gap.length == 0) return YES; - return gap.length <= 6 && [gap rangeOfCharacterFromSet:[NSCharacterSet whitespaceCharacterSet]].location == NSNotFound; + return gap.length <= 6; } // NSDataDetector's own match range doesn't always line up with what should