← Back to index

EntryInfoExtractor

SpotsShare
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import NaturalLanguageFramework importsImports Foundation, NaturalLanguage.
struct ExtractedEntryInfo { let showName: String? let dateTime: Date? }`ExtractedEntryInfo` structDefines the `ExtractedEntryInfo` struct.
class EntryInfoExtractor { /// Extract show name and date/time from a ParsedEmail static func extract(from email: ParsedEmail) -> ExtractedEntryInfo { let showName = extractShowName(from: email) let dateTime = extractDateTime(from: email) return ExtractedEntryInfo(showName: showName, dateTime: dateTime) } // MARK: - Show Name Extraction /// Try to extract the show/event name from the email subject and body private static func extractShowName(from email: ParsedEmail) -> String? { let subject = email.subject.trimmingCharacters(in: .whitespacesAndNewlines) guard !subject.isEmpty else { return nil } // Try to extract show name from common entry confirmation subject patterns let patterns: [(pattern: String, group: Int)] = [ // "Your entries for Hamilton" / "Your entries to Hamilton" ("(?:your\\s+)?entries?\\s+(?:for|to)\\s+(.+)", 1), // "Confirmation: Hamilton" / "Order Confirmation: Hamilton" ("(?:order\\s+)?confirm(?:ation|ed)[:\\s-]+(.+)", 1), // "Hamilton - Entry Confirmation" ("(.+?)\\s*[-–—]\\s*(?:entry|order|booking|reservation)\\s*confirm", 1), // "Booking confirmed: Hamilton" ("booking\\s+confirmed[:\\s-]+(.+)", 1), // "You're going to Hamilton!" / "You're going to see Hamilton" ("you(?:'re|\\s+are)\\s+going\\s+to\\s+(?:see\\s+)?(.+?)\\s*[!.]?$", 1), // "Hamilton entries" / "Hamilton - Entries" ("(.+?)\\s*[-–—]?\\s*entries?\\s*$", 1), // "Receipt for Hamilton" ("receipt\\s+for\\s+(.+)", 1), // "Your reservation: Hamilton" ("(?:your\\s+)?reservation[:\\s-]+(.+)", 1), ] for (pattern, group) in patterns { if let match = matchRegex(pattern: pattern, in: subject, group: group) { let cleaned = cleanShowName(match) if !cleaned.isEmpty && cleaned.count >= 2 { return cleaned } } } // If no pattern matched, try using NLTagger to find proper nouns / named entities if let nlpName = extractNameUsingNLP(from: subject) { return nlpName } // Last resort: use the subject itself, cleaned up let cleaned = cleanShowName(subject) return cleaned.isEmpty ? nil : cleaned } /// Use NLTagger to find named entities (potential show names) in text private static func extractNameUsingNLP(from text: String) -> String? { let tagger = NLTagger(tagSchemes: [.nameType]) tagger.string = text var candidates: [(String, NLTag)] = [] tagger.enumerateTags(in: text.startIndex..<text.endIndex, unit: .word, scheme: .nameType, options: [.omitWhitespace, .omitPunctuation, .joinNames]) { tag, range in if let tag = tag, (tag == .personalName || tag == .organizationName || tag == .placeName) { let name = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines) if name.count >= 2 { candidates.append((name, tag)) } } return true } // Return the longest named entity found return candidates.max(by: { $0.0.count < $1.0.count })?.0 } /// Clean up a potential show name private static func cleanShowName(_ name: String) -> String { var result = name // Remove common prefixes/suffixes let prefixes = ["fwd:", "fw:", "re:", "your", "the"] for prefix in prefixes { if result.lowercased().hasPrefix(prefix) { result = String(result.dropFirst(prefix.count)) } } // Remove entry/order-related suffixes let suffixes = ["entry", "entries", "confirmation", "confirmed", "receipt", "order"] for suffix in suffixes { if result.lowercased().hasSuffix(suffix) { result = String(result.dropLast(suffix.count)) } } // Clean up result = result.trimmingCharacters(in: .whitespacesAndNewlines) result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-–—:!|")) result = result.trimmingCharacters(in: .whitespacesAndNewlines) return result } // MARK: - Date/Time Extraction /// Extract the most likely event date/time from the email private static func extractDateTime(from email: ParsedEmail) -> Date? { // First try to find dates in the body using NSDataDetector let bodyDates = detectDates(in: email.bodyText) // Also check the subject let subjectDates = detectDates(in: email.subject) // Combine all found dates var allDates = bodyDates + subjectDates // Also try parsing the email Date header as a reference let emailSentDate = parseEmailDate(email.dateHeader) // Filter: keep only future dates (or dates after the email was sent) let referenceDate = emailSentDate ?? Date() let futureDates = allDates.filter { date in // The event date should be on or after the email was sent // and at least a few hours from now (not dates in the past) return date > referenceDate.addingTimeInterval(-86400) // allow 1 day before send date } // Prefer future dates; if none, use all dates let candidates = futureDates.isEmpty ? allDates : futureDates // Sort by date and return the earliest future date (most likely the event) if let earliest = candidates.sorted().first { return earliest } // Try to parse dates from common textual patterns in the body return extractDateFromPatterns(in: email.bodyText) ?? extractDateFromPatterns(in: email.subject) } /// Use NSDataDetector to find dates in text private static func detectDates(in text: String) -> [Date] { guard !text.isEmpty else { return [] } // Limit text length for performance in share extension let searchText = String(text.prefix(5000)) guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) else { return [] } let range = NSRange(searchText.startIndex..., in: searchText) let matches = detector.matches(in: searchText, range: range) return matches.compactMap { $0.date } } /// Parse common email Date header formats private static func parseEmailDate(_ dateString: String) -> Date? { guard !dateString.isEmpty else { return nil } // RFC 2822 format let formatters: [DateFormatter] = { let formats = [ "EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss z", "dd MMM yyyy HH:mm:ss Z", "EEE, d MMM yyyy HH:mm:ss Z", ] return formats.map { format in let f = DateFormatter() f.dateFormat = format f.locale = Locale(identifier: "en_US_POSIX") return f } }() for formatter in formatters { if let date = formatter.date(from: dateString) { return date } } // Try ISO 8601 let iso = ISO8601DateFormatter() return iso.date(from: dateString) } /// Try to match common date patterns like "March 15, 2026 at 7:30 PM" private static func extractDateFromPatterns(in text: String) -> Date? { let patterns = [ // "March 15, 2026 at 7:30 PM" "((?:January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\s+(?:at\\s+)?\\d{1,2}:\\d{2}\\s*(?:AM|PM|am|pm))", // "3/15/2026 7:30 PM" "(\\d{1,2}/\\d{1,2}/\\d{2,4}\\s+\\d{1,2}:\\d{2}\\s*(?:AM|PM|am|pm))", // "March 15, 2026" "((?:January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4})", // "15 March 2026" "(\\d{1,2}\\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{4})", ] let dateFormats = [ "MMMM d, yyyy 'at' h:mm a", "MMMM d, yyyy h:mm a", "MMMM d yyyy 'at' h:mm a", "MMMM d yyyy h:mm a", "M/d/yyyy h:mm a", "M/d/yy h:mm a", "MMMM d, yyyy", "MMMM d yyyy", "d MMMM yyyy", ] for pattern in patterns { if let match = matchRegex(pattern: pattern, in: text, group: 1) { for format in dateFormats { let formatter = DateFormatter() formatter.dateFormat = format formatter.locale = Locale(identifier: "en_US_POSIX") if let date = formatter.date(from: match) { return date } } } } return nil } // MARK: - Helpers private static func matchRegex(pattern: String, in text: String, group: Int) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let range = Range(match.range(at: group), in: text) else { return nil } return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines) } }`EntryInfoExtractor` classDefines the `EntryInfoExtractor` class.