// // KBSuggestionEngine.m // CustomKeyboard // #import "KBSuggestionEngine.h" #import "KBConfig.h" @interface KBSuggestionEngine () @property (nonatomic, copy) NSArray *words; @property (nonatomic, strong) NSMutableDictionary *selectionCounts; @property (nonatomic, strong) NSSet *priorityWords; @end @implementation KBSuggestionEngine + (instancetype)shared { static KBSuggestionEngine *engine; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ engine = [[KBSuggestionEngine alloc] init]; }); return engine; } - (instancetype)init { if (self = [super init]) { _selectionCounts = [NSMutableDictionary dictionary]; NSArray *defaults = [self.class kb_defaultWords]; _priorityWords = [NSSet setWithArray:defaults]; _words = [self kb_loadWords]; } return self; } - (NSArray *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit { if (prefix.length == 0 || limit == 0) { return @[]; } NSString *lower = prefix.lowercaseString; NSMutableArray *matches = [NSMutableArray array]; for (NSString *word in self.words) { if ([word hasPrefix:lower]) { [matches addObject:word]; if (matches.count >= limit * 3) { // Avoid scanning too many matches for long lists. break; } } } if (matches.count == 0) { return @[]; } [matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { NSInteger ca = self.selectionCounts[a].integerValue; NSInteger cb = self.selectionCounts[b].integerValue; if (ca != cb) { return (cb > ca) ? NSOrderedAscending : NSOrderedDescending; } BOOL pa = [self.priorityWords containsObject:a]; BOOL pb = [self.priorityWords containsObject:b]; if (pa != pb) { return pa ? NSOrderedAscending : NSOrderedDescending; } return [a compare:b]; }]; if (matches.count > limit) { return [matches subarrayWithRange:NSMakeRange(0, limit)]; } return matches.copy; } - (void)recordSelection:(NSString *)word { if (word.length == 0) { return; } NSString *key = word.lowercaseString; NSInteger count = self.selectionCounts[key].integerValue + 1; self.selectionCounts[key] = @(count); } #pragma mark - Defaults - (NSArray *)kb_loadWords { NSMutableOrderedSet *set = [[NSMutableOrderedSet alloc] init]; [set addObjectsFromArray:[self.class kb_defaultWords]]; NSArray *paths = [self kb_wordListPaths]; for (NSString *path in paths) { if (path.length == 0) { continue; } NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; if (content.length == 0) { continue; } NSArray *lines = [content componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; for (NSString *line in lines) { NSString *word = [self kb_sanitizedWordFromLine:line]; if (word.length == 0) { continue; } [set addObject:word]; } } NSArray *result = set.array ?: @[]; return result; } - (NSArray *)kb_wordListPaths { NSMutableArray *paths = [NSMutableArray array]; // 1) App Group override (allows server-downloaded large list). NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (containerURL.path.length > 0) { NSString *groupPath = [[containerURL path] stringByAppendingPathComponent:@"kb_words.txt"]; [paths addObject:groupPath]; } // 2) Bundle fallback. NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"kb_words" ofType:@"txt"]; if (bundlePath.length > 0) { [paths addObject:bundlePath]; } return paths; } - (NSString *)kb_sanitizedWordFromLine:(NSString *)line { NSString *trimmed = [[line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString]; if (trimmed.length == 0) { return @""; } static NSCharacterSet *letters = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ letters = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz"]; }); for (NSUInteger i = 0; i < trimmed.length; i++) { if (![letters characterIsMember:[trimmed characterAtIndex:i]]) { return @""; } } return trimmed; } + (NSArray *)kb_defaultWords { return @[ @"a", @"an", @"and", @"are", @"as", @"at", @"app", @"ap", @"apple", @"apply", @"april", @"application", @"about", @"above", @"after", @"again", @"against", @"all", @"am", @"among", @"amount", @"any", @"around", @"be", @"because", @"been", @"before", @"being", @"below", @"best", @"between", @"both", @"but", @"by", @"can", @"could", @"come", @"common", @"case", @"do", @"does", @"down", @"day", @"each", @"early", @"end", @"even", @"every", @"for", @"from", @"first", @"found", @"free", @"get", @"good", @"great", @"go", @"have", @"has", @"had", @"help", @"how", @"in", @"is", @"it", @"if", @"into", @"just", @"keep", @"kind", @"know", @"like", @"look", @"long", @"last", @"make", @"more", @"most", @"my", @"new", @"no", @"not", @"now", @"of", @"on", @"one", @"or", @"other", @"our", @"out", @"people", @"place", @"please", @"quick", @"quite", @"right", @"read", @"real", @"see", @"say", @"some", @"such", @"so", @"the", @"to", @"this", @"that", @"them", @"then", @"there", @"they", @"these", @"time", @"use", @"up", @"under", @"very", @"we", @"with", @"what", @"when", @"where", @"who", @"why", @"will", @"would", @"you", @"your" ]; } @end