Skip to content

Commit daf50c7

Browse files
cyberpapiiiclaude
andcommitted
feat: add fuzzy search and expanded time parsing
- Add fuzzy parameter for typo-tolerant matching using Levenshtein distance - Multi-word search: OR (any word) by default, AND (all words) with match_all - Natural language time parsing: "2 weeks ago", "last tuesday", "this month" - Allows finding messages despite common texting typos (volcno → volcano) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 11b18dd commit daf50c7

File tree

2 files changed

+181
-11
lines changed

2 files changed

+181
-11
lines changed

swift/Sources/iMessageMax/Database/AppleTime.swift

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,65 @@ enum AppleTime {
7676
let now = Date()
7777

7878
switch lower {
79+
// Simple keywords
7980
case "yesterday":
8081
return calendar.date(byAdding: .day, value: -1, to: now)
82+
case "today":
83+
return calendar.startOfDay(for: now)
84+
85+
// This period
86+
case "this week":
87+
return calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))
88+
case "this month":
89+
return calendar.date(from: calendar.dateComponents([.year, .month], from: now))
90+
case "this year":
91+
return calendar.date(from: calendar.dateComponents([.year], from: now))
92+
93+
// Last period
8194
case "last week":
8295
return calendar.date(byAdding: .weekOfYear, value: -1, to: now)
8396
case "last month":
8497
return calendar.date(byAdding: .month, value: -1, to: now)
85-
case "today":
86-
return calendar.startOfDay(for: now)
98+
case "last year":
99+
return calendar.date(byAdding: .year, value: -1, to: now)
100+
87101
default:
88-
return nil
102+
break
103+
}
104+
105+
// "N days/weeks/months ago" pattern
106+
let agoPattern = #"^(\d+)\s+(day|days|week|weeks|month|months)\s+ago$"#
107+
if let regex = try? NSRegularExpression(pattern: agoPattern, options: .caseInsensitive),
108+
let match = regex.firstMatch(in: lower, range: NSRange(lower.startIndex..., in: lower)),
109+
let numRange = Range(match.range(at: 1), in: lower),
110+
let unitRange = Range(match.range(at: 2), in: lower),
111+
let num = Int(lower[numRange]) {
112+
let unit = String(lower[unitRange]).lowercased()
113+
switch unit {
114+
case "day", "days":
115+
return calendar.date(byAdding: .day, value: -num, to: now)
116+
case "week", "weeks":
117+
return calendar.date(byAdding: .weekOfYear, value: -num, to: now)
118+
case "month", "months":
119+
return calendar.date(byAdding: .month, value: -num, to: now)
120+
default:
121+
break
122+
}
89123
}
124+
125+
// "last tuesday", "last friday" etc.
126+
let weekdays = ["sunday": 1, "monday": 2, "tuesday": 3, "wednesday": 4,
127+
"thursday": 5, "friday": 6, "saturday": 7]
128+
if lower.hasPrefix("last ") {
129+
let dayName = String(lower.dropFirst(5))
130+
if let targetWeekday = weekdays[dayName] {
131+
let currentWeekday = calendar.component(.weekday, from: now)
132+
var daysBack = currentWeekday - targetWeekday
133+
if daysBack <= 0 { daysBack += 7 }
134+
return calendar.date(byAdding: .day, value: -daysBack, to: now)
135+
}
136+
}
137+
138+
return nil
90139
}
91140
}

swift/Sources/iMessageMax/Tools/Search.swift

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,17 @@ enum SearchTool {
145145
]),
146146
"since": .object([
147147
"type": "string",
148-
"description": "Time bound (ISO, relative like \"24h\", or natural like \"yesterday\")"
148+
"description": "Time bound (ISO, relative like \"24h\"/\"7d\", or natural like \"yesterday\"/\"last tuesday\"/\"2 weeks ago\")"
149+
]),
150+
"match_all": .object([
151+
"type": "boolean",
152+
"description": "If true, require ALL words to match. If false (default), match ANY word.",
153+
"default": .bool(false)
154+
]),
155+
"fuzzy": .object([
156+
"type": "boolean",
157+
"description": "Enable typo-tolerant matching (allows 1-2 character differences). Useful for finding messages with typos.",
158+
"default": .bool(false)
149159
]),
150160
"before": .object([
151161
"type": "string",
@@ -192,9 +202,21 @@ enum SearchTool {
192202
description: """
193203
Full-text search across messages with advanced filtering.
194204
205+
Search features:
206+
- Multi-word: "costa rica trip" matches ANY word by default
207+
- match_all: true requires ALL words to be present
208+
- fuzzy: true handles typos (costarcia → costa rica)
209+
210+
Time filters (since/before):
211+
- Relative: "24h", "7d", "2w", "3m"
212+
- Natural: "yesterday", "last tuesday", "2 weeks ago", "this month"
213+
- ISO: "2024-01-15T10:30:00Z"
214+
195215
Examples:
196-
- search(query: "dinner plans") - find messages containing "dinner plans"
197-
- search(from_person: "me", since: "7d") - my messages from last week
216+
- search(query: "costa rica trip") - find any of these words
217+
- search(query: "costa rica", match_all: true) - must have both words
218+
- search(query: "volcno", fuzzy: true) - finds "volcano" despite typo
219+
- search(from_person: "me", since: "last monday") - my messages since Monday
198220
- search(has: "link", in_chat: "chat123") - links in a specific chat
199221
- search(unanswered: true) - questions I sent without replies
200222
""",
@@ -220,6 +242,8 @@ enum SearchTool {
220242
let includeContext = arguments?["include_context"]?.boolValue ?? false
221243
let unanswered = arguments?["unanswered"]?.boolValue ?? false
222244
let unansweredHours = arguments?["unanswered_hours"]?.intValue ?? 24
245+
let matchAll = arguments?["match_all"]?.boolValue ?? false
246+
let fuzzy = arguments?["fuzzy"]?.boolValue ?? false
223247

224248
let result = await execute(
225249
query: query,
@@ -235,6 +259,8 @@ enum SearchTool {
235259
includeContext: includeContext,
236260
unanswered: unanswered,
237261
unansweredHours: unansweredHours,
262+
matchAll: matchAll,
263+
fuzzy: fuzzy,
238264
db: db,
239265
resolver: resolver
240266
)
@@ -266,6 +292,8 @@ enum SearchTool {
266292
includeContext: Bool = false,
267293
unanswered: Bool = false,
268294
unansweredHours: Int = 24,
295+
matchAll: Bool = false,
296+
fuzzy: Bool = false,
269297
db: Database = Database(),
270298
resolver: ContactResolver
271299
) async -> Result<String, SearchError> {
@@ -326,11 +354,35 @@ enum SearchTool {
326354
}
327355

328356
// Filter by search query in Swift (since we can't search attributedBody in SQL)
329-
if hasQuery, let searchTerm = query?.trimmingCharacters(in: .whitespaces).lowercased() {
330-
rows = rows.filter { row in
331-
let extractedText = getMessageText(text: row.text, attributedBody: row.attributedBody)
332-
guard let text = extractedText?.lowercased() else { return false }
333-
return text.contains(searchTerm)
357+
// Supports multi-word search: OR (any word) by default, AND (all words) with matchAll=true
358+
// With fuzzy=true, also matches words within 1-2 edits (handles typos)
359+
if hasQuery, let searchQuery = query?.trimmingCharacters(in: .whitespaces).lowercased(), !searchQuery.isEmpty {
360+
// Split query into words (minimum 2 chars each to avoid noise)
361+
let searchWords = searchQuery.split(separator: " ")
362+
.map { String($0).lowercased() }
363+
.filter { $0.count >= 2 }
364+
365+
if !searchWords.isEmpty {
366+
rows = rows.filter { row in
367+
let extractedText = getMessageText(text: row.text, attributedBody: row.attributedBody)
368+
guard let text = extractedText?.lowercased() else { return false }
369+
370+
// Split message text into words for fuzzy matching
371+
let textWords = text.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
372+
.map { String($0).lowercased() }
373+
374+
if matchAll {
375+
// AND logic: all search words must be present
376+
return searchWords.allSatisfy { searchWord in
377+
wordMatches(searchWord: searchWord, in: text, textWords: textWords, fuzzy: fuzzy)
378+
}
379+
} else {
380+
// OR logic: any word matches
381+
return searchWords.contains { searchWord in
382+
wordMatches(searchWord: searchWord, in: text, textWords: textWords, fuzzy: fuzzy)
383+
}
384+
}
385+
}
334386
}
335387
}
336388

@@ -969,6 +1021,75 @@ enum SearchTool {
9691021
return "\(baseName)\(suffix)"
9701022
}
9711023

1024+
/// Check if a search word matches anywhere in the text
1025+
/// - Parameters:
1026+
/// - searchWord: The word to search for
1027+
/// - text: The full message text (for exact contains match)
1028+
/// - textWords: Individual words from the message (for fuzzy matching)
1029+
/// - fuzzy: Whether to use fuzzy/typo-tolerant matching
1030+
/// - Returns: True if the word matches
1031+
private static func wordMatches(searchWord: String, in text: String, textWords: [String], fuzzy: Bool) -> Bool {
1032+
// Always try exact substring match first (fast)
1033+
if text.contains(searchWord) {
1034+
return true
1035+
}
1036+
1037+
// If fuzzy matching enabled, check for close matches
1038+
if fuzzy {
1039+
// Calculate max allowed distance based on word length
1040+
// Short words (3-4 chars): 1 edit, longer words: 2 edits
1041+
let maxDistance = searchWord.count <= 4 ? 1 : 2
1042+
1043+
for textWord in textWords {
1044+
// Skip words that are way too different in length
1045+
if abs(textWord.count - searchWord.count) > maxDistance {
1046+
continue
1047+
}
1048+
// Check Levenshtein distance
1049+
if levenshteinDistance(searchWord, textWord) <= maxDistance {
1050+
return true
1051+
}
1052+
}
1053+
}
1054+
1055+
return false
1056+
}
1057+
1058+
/// Calculate Levenshtein edit distance between two strings
1059+
/// Returns the minimum number of single-character edits (insertions, deletions, substitutions)
1060+
/// needed to transform one string into another
1061+
private static func levenshteinDistance(_ s1: String, _ s2: String) -> Int {
1062+
let m = s1.count
1063+
let n = s2.count
1064+
1065+
// Quick checks for empty strings
1066+
if m == 0 { return n }
1067+
if n == 0 { return m }
1068+
1069+
// Convert to arrays for indexing
1070+
let chars1 = Array(s1)
1071+
let chars2 = Array(s2)
1072+
1073+
// Use two rows instead of full matrix for memory efficiency
1074+
var prevRow = Array(0...n)
1075+
var currRow = Array(repeating: 0, count: n + 1)
1076+
1077+
for i in 1...m {
1078+
currRow[0] = i
1079+
for j in 1...n {
1080+
let cost = chars1[i - 1] == chars2[j - 1] ? 0 : 1
1081+
currRow[j] = min(
1082+
prevRow[j] + 1, // deletion
1083+
currRow[j - 1] + 1, // insertion
1084+
prevRow[j - 1] + cost // substitution
1085+
)
1086+
}
1087+
swap(&prevRow, &currRow)
1088+
}
1089+
1090+
return prevRow[n]
1091+
}
1092+
9721093
private static func getMessageText(text: String?, attributedBody: Data?) -> String? {
9731094
// First try plain text
9741095
if let text = text, !text.isEmpty {

0 commit comments

Comments
 (0)