| 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` class | Defines the `EntryInfoExtractor` class. |