← Back to index

ShareViewController

SpotsShare
CodeWhat It DoesHow It Does It
▶ IMPORTS
import UIKit import MapKit import UniformTypeIdentifiersFramework importsImports UIKit, MapKit, UniformTypeIdentifiers.
class ShareViewController: UIViewController { // IMPORTANT: This must match the App Group configured in both targets private let appGroupID = "group.com.spots.shared"`ShareViewController` classDefines the `ShareViewController` class. Conforms to UIViewController.
▶ UI ELEMENTS
private let backdropView = UIView() private let cardView = UIView() private let iconView = UIImageView() private let spinner = UIActivityIndicatorView(style: .large) private let titleLabel = UILabel() private let detailLabel = UILabel() private let openButton = UIButton(type: .system) private let doneButton = UIButton(type: .system)`backdropView` letProperty `backdropView`.
▶ LIFECYCLE
override func viewDidLoad() { super.viewDidLoad() setupUI() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) extractAndCreateEntry() }`viewDidLoad()` functionImplements `viewDidLoad`.
▶ UI SETUP
private func setupUI() { // Dimmed backdrop backdropView.backgroundColor = UIColor.black.withAlphaComponent(0.45) backdropView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(backdropView) // Card cardView.backgroundColor = .secondarySystemBackground cardView.layer.cornerRadius = 20 cardView.layer.shadowColor = UIColor.black.cgColor cardView.layer.shadowOpacity = 0.18 cardView.layer.shadowRadius = 12 cardView.layer.shadowOffset = CGSize(width: 0, height: 4) cardView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(cardView) // Content stack (fill alignment so buttons stretch full width) let stack = UIStackView() stack.axis = .vertical stack.alignment = .fill stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false cardView.addSubview(stack) // Icon (centered via its own container) let symConfig = UIImage.SymbolConfiguration(pointSize: 44, weight: .thin) iconView.image = UIImage(systemName: "fork.knife", withConfiguration: symConfig) iconView.tintColor = .systemBlue iconView.contentMode = .scaleAspectFit iconView.isHidden = true iconView.translatesAutoresizingMaskIntoConstraints = false iconView.heightAnchor.constraint(equalToConstant: 56).isActive = true stack.addArrangedSubview(iconView) // Spinner spinner.color = .systemBlue spinner.hidesWhenStopped = false spinner.startAnimating() stack.addArrangedSubview(spinner) // Title titleLabel.text = "Importing…" titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textAlignment = .center titleLabel.numberOfLines = 0 stack.addArrangedSubview(titleLabel) // Detail / status detailLabel.text = "Sending email to Claude API…" detailLabel.font = .systemFont(ofSize: 14) detailLabel.textColor = .secondaryLabel detailLabel.textAlignment = .center detailLabel.numberOfLines = 0 stack.addArrangedSubview(detailLabel) // Spacer between text and buttons stack.setCustomSpacing(8, after: detailLabel) // Primary button — opens Spots via responder chain, then dismisses openButton.setTitle("Open Spots", for: .normal) openButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) openButton.backgroundColor = .systemBlue openButton.setTitleColor(.white, for: .normal) openButton.layer.cornerRadius = 12 openButton.heightAnchor.constraint(equalToConstant: 44).isActive = true openButton.isHidden = true openButton.addTarget(self, action: #selector(openSpotsTapped), for: .touchUpInside) stack.addArrangedSubview(openButton) // Secondary button — just dismisses back to Mail doneButton.setTitle("Done", for: .normal) doneButton.titleLabel?.font = .systemFont(ofSize: 16) doneButton.setTitleColor(.systemBlue, for: .normal) doneButton.isHidden = true doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside) stack.addArrangedSubview(doneButton) // Layout NSLayoutConstraint.activate([ backdropView.topAnchor.constraint(equalTo: view.topAnchor), backdropView.bottomAnchor.constraint(equalTo: view.bottomAnchor), backdropView.leadingAnchor.constraint(equalTo: view.leadingAnchor), backdropView.trailingAnchor.constraint(equalTo: view.trailingAnchor), cardView.centerXAnchor.constraint(equalTo: view.centerXAnchor), cardView.centerYAnchor.constraint(equalTo: view.centerYAnchor), cardView.widthAnchor.constraint(equalToConstant: 300), stack.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 28), stack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -24), stack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 20), stack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -20), ]) }`setupUI()` functionImplements `setupUI`.
▶ STATE UPDATES
private func showSuccess(showName: String, dateString: String) { spinner.stopAnimating() spinner.isHidden = true iconView.isHidden = false let symConfig = UIImage.SymbolConfiguration(pointSize: 44, weight: .thin) iconView.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: symConfig) iconView.tintColor = .systemGreen titleLabel.text = showName.isEmpty ? "Saved" : showName detailLabel.text = dateString.isEmpty ? "Opening Spots…" : "\(dateString)\n\nOpening Spots…" // Auto-open Spots after a brief moment so the user sees the confirmation DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { self.openSpotsTapped() } } private func showError(_ message: String) { spinner.stopAnimating() spinner.isHidden = true iconView.isHidden = false let symConfig = UIImage.SymbolConfiguration(pointSize: 44, weight: .thin) iconView.image = UIImage(systemName: "exclamationmark.triangle.fill", withConfiguration: symConfig) iconView.tintColor = .systemOrange titleLabel.text = "Saved with Defaults" detailLabel.text = "\(message)\n\nOpen Spots to correct the details." openButton.isHidden = false doneButton.isHidden = false }`showSuccess()` functionImplements `showSuccess`.
▶ ACTIONS
@objc private func openSpotsTapped() { guard let url = URL(string: "spots://import") else { done() return } // Walk the responder chain to reach UIApplication, then open the URL. // This is the established technique for share extensions to launch // their containing app. On iOS 18+, openURL(_:) was changed to // force-return false, so we use open(_:options:completionHandler:) instead. var responder: UIResponder? = self while let r = responder { if let application = r as? UIApplication { application.open(url, options: [:], completionHandler: nil) break } responder = r.next } done() } @objc private func doneTapped() { done() }`openSpotsTapped()` functionImplements `openSpotsTapped`.
▶ SHARED DEBUG LOG
// Writes to the app group so the main app can print it on launch. private func debugLog(_ message: String) { let line = "[\(Date())] \(message)\n" // Also NSLog so it appears in device syslog if Xcode is attached to the extension NSLog("🍽️ SpotsShare: %@", message) guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { return } let logURL = containerURL.appendingPathComponent("extension_debug.txt") if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: logURL.path), let handle = try? FileHandle(forWritingTo: logURL) { handle.seekToEndOfFile() handle.write(data) handle.closeFile() } else { try? data.write(to: logURL) } } }Documentation commentDescribes the following declaration.
▶ EXTRACT EMAIL DATA
private func extractAndCreateEntry() { guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { showError("No extension items found.") return } // Collect all available type identifiers for debugging var allTypeIDs: [String] = [] for item in extensionItems { for provider in item.attachments ?? [] { allTypeIDs.append(contentsOf: provider.registeredTypeIdentifiers) } } debugLog("extractAndCreateEntry: \(extensionItems.count) item(s), typeIDs: \(allTypeIDs.joined(separator: " | "))") // Update the status label immediately so it's visible while we work DispatchQueue.main.async { self.detailLabel.text = "Types: \(allTypeIDs.joined(separator: ", "))" } for item in extensionItems { guard let attachments = item.attachments else { continue } for provider in attachments { // MapKit map item — shared from Apple Maps (or any app using MKMapItem). // This must be checked FIRST because MKMapItem also conforms to public.data, // which would otherwise match the generic data fallback below. if provider.hasItemConformingToTypeIdentifier("com.apple.mapkit.map-item") { provider.loadObject(ofClass: MKMapItem.self) { [weak self] object, error in guard let self = self else { return } guard let mapItem = object as? MKMapItem else { DispatchQueue.main.async { self.showError("Could not load map item: \(error?.localizedDescription ?? "nil")") } return } let name = mapItem.name ?? "" let coord = mapItem.placemark.coordinate let address = mapItem.placemark.title ?? "" let phone = mapItem.phoneNumber ?? "" let website = mapItem.url?.absoluteString ?? "" var lines = ["Maps location shared from Apple Maps:"] if !name.isEmpty { lines.append("Name: \(name)") } if !address.isEmpty { lines.append("Address: \(address)") } if !phone.isEmpty { lines.append("Phone: \(phone)") } if !website.isEmpty { lines.append("Website: \(website)") } if let cat = mapItem.pointOfInterestCategory { let catName = cat.rawValue.replacingOccurrences(of: "MKPOICategory", with: "") lines.append("Category: \(catName)") } lines.append("Coordinates: \(coord.latitude),\(coord.longitude)") let pageText = lines.joined(separator: "\n") self.debugLog("MKMapItem handler: \(pageText)") // Build a canonical Apple Maps URL for the entry's confirmationLink let ll = "\(coord.latitude),\(coord.longitude)" let enc = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let mapURL = URL(string: "https://maps.apple.com/?q=\(enc)&ll=\(ll)") ?? URL(string: "https://maps.apple.com/")! DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = name.isEmpty ? "Getting details…" : name } self.processWithClaudeWebURL(pageText: pageText, url: mapURL, imageURL: nil) } return } // Email shared directly from Mail.app if provider.hasItemConformingToTypeIdentifier("com.apple.mail.email") { provider.loadDataRepresentation(forTypeIdentifier: "com.apple.mail.email") { [weak self] data, error in DispatchQueue.main.async { if let data = data { self?.processWithClaude(data: data) } else { self?.showError("com.apple.mail.email load failed: \(error?.localizedDescription ?? "nil data")") } } } return } // PDF file shared directly (e.g. from Files app) if provider.hasItemConformingToTypeIdentifier("com.adobe.pdf") { provider.loadDataRepresentation(forTypeIdentifier: "com.adobe.pdf") { [weak self] data, error in DispatchQueue.main.async { if let data = data { self?.processWithClaudePDF(data: data) } else { self?.showError("PDF load failed: \(error?.localizedDescription ?? "nil data")") } } } return } // File URL (.eml or .pdf file) if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { [weak self] item, error in var data: Data? var isPDF = false var loadError: String? if let url = item as? URL { isPDF = url.pathExtension.lowercased() == "pdf" let accessing = url.startAccessingSecurityScopedResource() do { data = try Data(contentsOf: url) } catch { loadError = error.localizedDescription } if accessing { url.stopAccessingSecurityScopedResource() } } else { loadError = "item was not a URL (got \(type(of: item)))" } DispatchQueue.main.async { if let data = data { if isPDF { self?.processWithClaudePDF(data: data) } else { self?.processWithClaude(data: data) } } else { self?.showError("fileURL load failed: \(loadError ?? "nil data")") } } } return } // URL — either a web URL (http/https) or a local file URL. // MUST be checked before UTType.data because every URL also conforms to public.data. if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { loadURLFromProvider(provider) return } // Plain text — Safari shares URLs as public.plain-text, not public.url. // Check if the text is a web URL and route accordingly. // NOTE: Some apps (e.g. Google Maps) share an EMPTY plain-text alongside a // real public.url. When that happens, fall back to the URL provider. if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] item, error in guard let self = self else { return } let text: String if let str = item as? String { text = str.trimmingCharacters(in: .whitespacesAndNewlines) } else if let data = item as? Data, let str = String(data: data, encoding: .utf8) { text = str.trimmingCharacters(in: .whitespacesAndNewlines) } else { DispatchQueue.main.async { self.showError("Could not read plain-text item.") } return } // Log everything so we can diagnose any provider-routing failure let hasURLProvider = attachments.contains { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) } let textIsWebURL = (text.hasPrefix("http://") || text.hasPrefix("https://")) && URL(string: text) != nil self.debugLog("plain-text: '\(text.prefix(120))' | isURL=\(textIsWebURL) | hasURLProvider=\(hasURLProvider)") // If the plain-text is not itself a standalone web URL, try other strategies // before falling back to treating it as an email body. if !textIsWebURL { // Strategy 1: prefer a public.url provider if one exists alongside this // plain-text provider (Google Maps / Safari page-title shares). if let urlProvider = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }) { self.debugLog("plain-text is not a URL — routing to public.url provider") // Pass the plain-text as supplemental context (e.g. "Check out Wayan") // so fetchAndProcessURL can use it if the page is a JS SPA. let hint = text.trimmingCharacters(in: .whitespacesAndNewlines) self.loadURLFromProvider(urlProvider, supplementalText: hint.isEmpty ? nil : hint) return } // Strategy 2: scan the text for an embedded URL using NSDataDetector. // Resy shares a sentence like "Check out X on Resy. http://resy.com/link?..." // with NO separate public.url provider, so we have to pull the URL out of // the sentence ourselves. if let detector = try? NSDataDetector( types: NSTextCheckingResult.CheckingType.link.rawValue), let match = detector.firstMatch( in: text, range: NSRange(text.startIndex..., in: text)), let embeddedURL = match.url, embeddedURL.scheme == "http" || embeddedURL.scheme == "https" { self.debugLog("plain-text contains embedded URL: \(embeddedURL.absoluteString)") // Pass the surrounding prose as supplemental context — the page at // the link may be a JS SPA (e.g. Resy) that returns no useful text. let surroundingText = text .replacingOccurrences(of: embeddedURL.absoluteString, with: "") .trimmingCharacters(in: .whitespacesAndNewlines) self.routeURL(embeddedURL, supplementalText: surroundingText.isEmpty ? nil : surroundingText) return } // No URL found anywhere — treat as email body self.debugLog("plain-text has no URL — treating as email text") } if (text.hasPrefix("http://") || text.hasPrefix("https://")), let url = URL(string: text) { let host = url.host ?? "" if host == "maps.apple.com" { self.debugLog("plain-text is Apple Maps URL — extracting directly") DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = url.absoluteString } let mapsText = self.extractAppleMapsText(from: url) self.processWithClaudeWebURL(pageText: mapsText, url: url, imageURL: nil) } else if host == "maps.app.goo.gl" { self.debugLog("plain-text is Google Maps short URL — resolving redirect") DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = url.absoluteString } self.resolveGoogleMapsShortURL(url) } else if host == "maps.google.com" || (host == "www.google.com" && url.path.hasPrefix("/maps")) { self.debugLog("plain-text is Google Maps URL — extracting directly") DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = url.absoluteString } let mapsText = self.extractGoogleMapsText(from: url) self.processWithClaudeWebURL(pageText: mapsText, url: url, imageURL: nil) } else { self.debugLog("plain-text is a web URL — routing to fetchAndProcessURL") DispatchQueue.main.async { self.titleLabel.text = "Reading page…" self.detailLabel.text = url.absoluteString } self.fetchAndProcessURL(url) } } else { // Plain text that isn't a URL — treat as email body self.debugLog("plain-text is not a URL — processing as email text") if let data = text.data(using: .utf8) { DispatchQueue.main.async { self.processWithClaude(data: data) } } else { DispatchQueue.main.async { self.showError("Could not encode plain text.") } } } } return } // Generic data — last resort fallback for any remaining data types if provider.hasItemConformingToTypeIdentifier(UTType.data.identifier) { provider.loadDataRepresentation(forTypeIdentifier: UTType.data.identifier) { [weak self] data, error in DispatchQueue.main.async { if let data = data { self?.processWithClaude(data: data) } else { self?.showError("data load failed: \(error?.localizedDescription ?? "nil data")") } } } return } } } // Nothing matched — show the type identifiers so we can add support for them let msg = allTypeIDs.isEmpty ? "No attachments found in share items." : "No supported type found.\nAvailable:\n\(allTypeIDs.joined(separator: "\n"))" showError(msg) }`extractAndCreateEntry()` functionImplements `extractAndCreateEntry`.
▶ CONTENT TYPE CLASSIFICATION (EXPERIMENT)
/// Classify a ClaudeExtractedInfo as Food, Show, or Place for console logging. private static func classifyExtractedInfo(_ info: ClaudeExtractedInfo) -> String { // Prefer the explicit category Claude returned in the prompt. let cat = info.contentCategory.trimmingCharacters(in: .whitespaces) if cat == "Food" || cat == "Place" || cat == "Show" || cat == "Shop" || cat == "Event" { return cat } // Fallback: a non-empty showName is a strong signal it's a ticketed performance. if !info.showName.isEmpty { return "Show" } // Last resort: keyword scan on the venue/cuisine name. let raw = info.cuisine.lowercased() let foodKeywords = ["restaurant", "cafe", "café", "bar", "bakery", "diner", "bistro", "pizzeria", "sushi", "brasserie", "tavern", "pub", "eatery", "food", "grill", "steakhouse", "trattoria", "ramen"] let showKeywords = ["theatre", "theater", "playhouse", "opera house", "auditorium", "cabaret", "broadway", "off-broadway"] let eventKeywords = ["concert", "music venue", "festival", "fair", "exhibition", "gallery", "arena", "amphitheater", "comedy club"] if foodKeywords.contains(where: { raw.contains($0) }) { return "Food" } if eventKeywords.contains(where: { raw.contains($0) }) { return "Event" } if showKeywords.contains(where: { raw.contains($0) }) { return "Show" } return "Place" }Documentation commentDescribes the following declaration.
▶ CLAUDE API PROCESSING
private func processWithClaude(data: Data) { let parsed = EmlParser.parse(data: data) // Show a summary of what we could extract from the raw email DispatchQueue.main.async { let subject = parsed.subject.isEmpty ? "(no subject)" : parsed.subject self.detailLabel.text = "Subject: \(subject)\nSending to Claude…" } ClaudeAPIService.extractEntryInfo(from: parsed) { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } switch result { case .success(let info): self.debugLog("🔍 Content type: \(Self.classifyExtractedInfo(info)) (showName: \"\(info.showName)\" | cuisine: \"\(info.cuisine)\")") self.saveAndDismiss(info: info, attachmentData: data, fileExtension: "eml", usedFallback: false) case .failure(let error): // Show the REAL error in the card so the user can report it let msg = "API error: \(error.localizedDescription)" print("⚠️ \(msg)") self.showError(msg) // Still save with fallback so the entry isn't lost self.fallbackAndSave(parsed: parsed, attachmentData: data) } } } } private func processWithClaudePDF(data: Data) { DispatchQueue.main.async { self.detailLabel.text = "Sending PDF to Claude…" } ClaudeAPIService.extractEntryInfo(fromPDFData: data) { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } switch result { case .success(let info): self.debugLog("🔍 Content type: \(Self.classifyExtractedInfo(info)) (showName: \"\(info.showName)\" | cuisine: \"\(info.cuisine)\")") self.saveAndDismiss(info: info, attachmentData: data, fileExtension: "pdf", usedFallback: false) case .failure(let error): let msg = "API error: \(error.localizedDescription)" print("⚠️ \(msg)") self.showError(msg) // Save the PDF with empty defaults so the entry isn't lost self.fallbackAndSavePDF(data: data) } } } }`processWithClaude()` functionImplements `processWithClaude`.
▶ MAPS URL EXTRACTION
/// Extract a clean text summary from an Apple Maps URL so Claude can classify it /// without scraping the JavaScript-heavy maps web page. private func extractAppleMapsText(from url: URL) -> String { let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) let items = comps?.queryItems ?? [] let name = items.first(where: { $0.name == "q" })?.value ?? "" let addr = items.first(where: { $0.name == "address" })?.value ?? "" let ll = items.first(where: { $0.name == "ll" })?.value ?? "" var lines = ["Maps location shared from Apple Maps:"] if !name.isEmpty { lines.append("Name: \(name)") } if !addr.isEmpty { lines.append("Address: \(addr)") } if !ll.isEmpty { lines.append("Coordinates: \(ll)") } debugLog("extractAppleMapsText: \(lines.joined(separator: " | "))") return lines.joined(separator: "\n") } /// Extract a clean text summary from a full Google Maps URL. private func extractGoogleMapsText(from url: URL) -> String { let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) var name = "" // Format 1: /maps/place/NAME/@lat,lng,zoom/... (most common from app sharing) if url.path.hasPrefix("/maps/place/") { let after = String(url.path.dropFirst("/maps/place/".count)) let raw = after.components(separatedBy: "/").first ?? "" name = raw.removingPercentEncoding?.replacingOccurrences(of: "+", with: " ") ?? raw } // Format 2: /maps/search/?api=1&query=NAME (search-based share) if name.isEmpty { name = comps?.queryItems?.first(where: { $0.name == "query" })?.value ?? "" } // Format 3: ?q=NAME (classic Google Maps) if name.isEmpty { name = comps?.queryItems?.first(where: { $0.name == "q" })?.value ?? "" } var lines = ["Maps location shared from Google Maps:"] if !name.isEmpty { lines.append("Name: \(name)") } debugLog("extractGoogleMapsText: \(lines.joined(separator: " | "))") return lines.joined(separator: "\n") } /// Load a URL from a public.url item provider, then route it. /// `supplementalText` is prose from an accompanying plain-text provider (e.g. "Check out Wayan") /// that can be used as context if the fetched page is a JS SPA. private func loadURLFromProvider(_ provider: NSItemProvider, supplementalText: String? = nil) { provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] item, error in guard let self = self else { return } self.debugLog("loadURLFromProvider: item type=\(type(of: item))") guard let url = item as? URL else { DispatchQueue.main.async { self.showError("Could not read URL from share item.") } return } self.debugLog("loadURLFromProvider: \(url.absoluteString)") if url.scheme == "http" || url.scheme == "https" { self.routeURL(url, supplementalText: supplementalText) } else if url.isFileURL { var data: Data? var loadError: String? let accessing = url.startAccessingSecurityScopedResource() do { data = try Data(contentsOf: url) } catch { loadError = error.localizedDescription } if accessing { url.stopAccessingSecurityScopedResource() } DispatchQueue.main.async { if let data = data { self.processWithClaude(data: data) } else { self.showError("url load failed: \(loadError ?? "nil data")") } } } else { DispatchQueue.main.async { self.showError("Unsupported URL scheme: \(url.scheme ?? "nil")") } } } } /// Route an http/https URL to the right handler based on its host. /// Called from loadURLFromProvider and from embedded-URL extraction in the plain-text path. /// `supplementalText` is prose from the share sheet (e.g. "Check out X on Resy.") that can /// be used as fallback content if the fetched page returns sparse/JS-rendered HTML. private func routeURL(_ url: URL, supplementalText: String? = nil) { // Upgrade http → https to satisfy App Transport Security let secureURL: URL if url.scheme == "http", var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { components.scheme = "https" secureURL = components.url ?? url debugLog("routeURL: upgraded http→https: \(secureURL.absoluteString)") } else { secureURL = url } let host = secureURL.host ?? "" if host == "maps.apple.com" { DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = secureURL.absoluteString } processWithClaudeWebURL(pageText: extractAppleMapsText(from: secureURL), url: secureURL, imageURL: nil) } else if host == "maps.app.goo.gl" { DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = secureURL.absoluteString } resolveGoogleMapsShortURL(secureURL) } else if host == "maps.google.com" || (host == "www.google.com" && secureURL.path.hasPrefix("/maps")) { DispatchQueue.main.async { self.titleLabel.text = "Reading location…" self.detailLabel.text = secureURL.absoluteString } processWithClaudeWebURL(pageText: extractGoogleMapsText(from: secureURL), url: secureURL, imageURL: nil) } else if (host == "resy.com" || host == "www.resy.com"), let venueId = URLComponents(url: secureURL, resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "venue_id" })?.value { // Resy link pages are JS SPAs — go straight to their API using the venue_id. DispatchQueue.main.async { self.titleLabel.text = "Reading restaurant…" self.detailLabel.text = secureURL.absoluteString } fetchResyVenueAPI(venueId: venueId, supplementalText: supplementalText) } else { DispatchQueue.main.async { self.titleLabel.text = "Reading page…" self.detailLabel.text = secureURL.absoluteString } fetchAndProcessURL(secureURL, supplementalText: supplementalText) } } /// Follow a Google Maps short URL redirect, then extract location text from the final URL. private func resolveGoogleMapsShortURL(_ shortURL: URL) { var request = URLRequest(url: shortURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) request.setValue( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent" ) URLSession(configuration: .ephemeral).dataTask(with: request) { [weak self] _, response, error in guard let self = self else { return } // URLSession follows all HTTP redirects automatically; response.url is the final URL. // Use the final URL for both text extraction AND as the URL passed to Claude, // so Claude can read the place name from the URL path even if page text is minimal. let finalURL = (response as? HTTPURLResponse)?.url ?? shortURL self.debugLog("resolveGoogleMapsShortURL: final URL = \(finalURL.absoluteString)") let text = self.extractGoogleMapsText(from: finalURL) self.debugLog("resolveGoogleMapsShortURL: extracted text = \(text)") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } // Pass finalURL (not shortURL) so Claude sees the full URL with the place name self.processWithClaudeWebURL(pageText: text, url: finalURL, imageURL: nil) }.resume() }Documentation commentDescribes the following declaration.
▶ WEB URL PROCESSING
/// Fetch Resy venue details from their API using the numeric venue_id. /// Resy restaurant pages are Angular SPAs that return no useful HTML to URLSession, /// so we skip the page fetch entirely and hit their API endpoint directly. /// Falls back to supplemental text (share-sheet prose) if the API is unavailable. private func fetchResyVenueAPI(venueId: String, supplementalText: String?) { debugLog("fetchResyVenueAPI: venue_id=\(venueId)") guard let apiURL = URL(string: "https://api.resy.com/2/venue?id=\(venueId)") else { debugLog("fetchResyVenueAPI: could not build API URL — using supplemental text") processWithClaudeWebURL( pageText: supplementalText.map { "SHARE TEXT:\n\($0)" } ?? "Resy restaurant (venue_id=\(venueId))", url: URL(string: "https://resy.com/")!, imageURL: nil) return } var request = URLRequest(url: apiURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) request.setValue( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("https://resy.com", forHTTPHeaderField: "Referer") request.setValue("https://resy.com", forHTTPHeaderField: "Origin") URLSession(configuration: .ephemeral).dataTask(with: request) { [weak self] data, response, error in guard let self = self else { return } let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 self.debugLog("fetchResyVenueAPI: HTTP \(statusCode)") guard let data = data, statusCode == 200, let body = String(data: data, encoding: .utf8), !body.isEmpty else { // API unavailable — fall back to share text only. self.debugLog("fetchResyVenueAPI: no data or non-200 — using supplemental text only") var parts: [String] = [] if let hint = supplementalText { parts.append("SHARE TEXT:\n\(hint)") } parts.append("NOTE: Resy restaurant, venue_id=\(venueId). Page requires JavaScript so no further details are available.") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL( pageText: parts.joined(separator: "\n\n"), url: URL(string: "https://resy.com/")!, imageURL: nil) return } self.debugLog("fetchResyVenueAPI: got \(body.count) chars — preview: \(body.prefix(120))") // Resy re-serves the SPA HTML instead of JSON. Route through the normal HTML pipeline // (meta tags, JSON-LD, og: tags, stripped body text) plus the supplemental share text. let trimmed = body.trimmingCharacters(in: .whitespaces).lowercased() let isHTML = trimmed.hasPrefix("<!doctype") || trimmed.hasPrefix("<html") if isHTML { self.debugLog("fetchResyVenueAPI: got HTML (SPA) — extracting meta/og tags") let metaText = Self.extractMetaTags(fromHTML: body) self.debugLog("fetchResyVenueAPI: meta tags: \(metaText.prefix(300))") // Resy's shell HTML has AngularJS template placeholders like {{metadata.title}}. // If that's all we have, fall back to searching Yelp by restaurant name. let hasRealMeta = !metaText.isEmpty && !metaText.contains("{{") if hasRealMeta { let jsonLD = Self.extractJSONLD(fromHTML: body) let scriptData = Self.extractScriptData(fromHTML: body) let strippedText = Self.stripHTML(body) var sections: [String] = [] if let hint = supplementalText { sections.append("SHARE TEXT:\n\(hint)") } if !jsonLD.isEmpty { sections.append("STRUCTURED DATA (JSON-LD):\n\(jsonLD)") } if !scriptData.isEmpty { sections.append("STRUCTURED DATA (page variables):\n\(scriptData)") } sections.append("META TAGS:\n\(metaText)") sections.append("PAGE TEXT:\n\(strippedText)") let pageText = sections.joined(separator: "\n\n") let imageURL = Self.extractImageURL(fromHTML: body, baseURL: apiURL) DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL(pageText: pageText, url: apiURL, imageURL: imageURL) } else { // All meta tags are unfilled templates — try to find the restaurant on Yelp. // Extract the restaurant name from "Check out X on Resy." share text. var restaurantName: String? = nil if let hint = supplementalText, let regex = try? NSRegularExpression( pattern: #"Check out (.+?) on Resy"#, options: .caseInsensitive), let match = regex.firstMatch( in: hint, range: NSRange(hint.startIndex..., in: hint)), match.numberOfRanges > 1, let nameRange = Range(match.range(at: 1), in: hint) { restaurantName = String(hint[nameRange]) } if let name = restaurantName { self.debugLog("fetchResyVenueAPI: no real meta — searching MapKit for: \(name)") DispatchQueue.main.async { self.detailLabel.text = "Looking up location…" } self.searchMapKitForVenue( query: name, originalURL: URL(string: "https://resy.com/")!, imageURL: nil, supplementalText: supplementalText) } else { self.debugLog("fetchResyVenueAPI: no name found — using supplemental text only") var parts: [String] = [] if let hint = supplementalText { parts.append("SHARE TEXT:\n\(hint)") } parts.append("NOTE: Resy restaurant, venue_id=\(venueId). Page requires JavaScript.") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL( pageText: parts.joined(separator: "\n\n"), url: URL(string: "https://resy.com/")!, imageURL: nil) } } } else { // Actual JSON — flatten to readable lines for Claude. self.debugLog("fetchResyVenueAPI: got JSON — flattening") let flat = Self.flattenResyVenueJSON(body) self.debugLog("fetchResyVenueAPI: flattened: \(flat.prefix(300))") var sections: [String] = [] if let hint = supplementalText { sections.append("SHARE TEXT:\n\(hint)") } sections.append("RESY VENUE DATA:\n\(flat)") let pageText = sections.joined(separator: "\n\n") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL(pageText: pageText, url: apiURL, imageURL: nil) } }.resume() } /// Convert a Resy venue API JSON response into clean "Key: value" lines that Claude /// can reliably map to address/cuisine/hours/phone fields. /// Falls back to the raw JSON string if parsing fails. private static func flattenResyVenueJSON(_ jsonStr: String) -> String { guard let data = jsonStr.data(using: .utf8), let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return jsonStr } // Resy wraps under a "venue" key; some endpoints return venue fields at root level. let v = (root["venue"] as? [String: Any]) ?? root var lines: [String] = [] // Name if let name = v["name"] as? String, !name.isEmpty { lines.append("Name: \(name)") } // Location let loc = (v["location"] as? [String: Any]) ?? [:] var addrParts: [String] = [] for key in ["address_1", "address1", "address"] { if let s = loc[key] as? String, !s.isEmpty { addrParts.append(s); break } } if let s = loc["address_2"] as? String, !s.isEmpty { addrParts.append(s) } let cityStateZip = [ loc["city"] as? String ?? "", loc["state"] as? String ?? "", loc["postal_code"] as? String ?? (loc["zip"] as? String ?? ""), ].filter { !$0.isEmpty } if !cityStateZip.isEmpty { addrParts.append(cityStateZip.joined(separator: ", ")) } if !addrParts.isEmpty { lines.append("Address: \(addrParts.joined(separator: ", "))") } for key in ["neighborhood", "neighborhood_name"] { if let s = loc[key] as? String, !s.isEmpty { lines.append("Neighborhood: \(s)"); break } } // Contact / phone let contact = (v["contact"] as? [String: Any]) ?? [:] for key in ["phone_number", "phone", "telephone"] { if let s = contact[key] as? String ?? v[key] as? String, !s.isEmpty { lines.append("Phone: \(s)"); break } } // Cuisine / type if let types = v["type"] as? [[String: Any]] { let names = types.compactMap { $0["name"] as? String }.joined(separator: ", ") if !names.isEmpty { lines.append("Cuisine: \(names)") } } else if let typeArr = v["type"] as? [String], !typeArr.isEmpty { lines.append("Cuisine: \(typeArr.joined(separator: ", "))") } else if let typeStr = v["type"] as? String, !typeStr.isEmpty { lines.append("Cuisine: \(typeStr)") } // Hours let hoursBlock = (v["hours"] as? [String: Any]) ?? [:] for key in ["hours_display", "display", "open_hours"] { if let s = hoursBlock[key] as? String, !s.isEmpty { lines.append("Hours: \(s)"); break } } // Price / rating for extra context if let priceId = v["price_range_id"] as? Int, priceId > 0 { lines.append("Price: \(String(repeating: "$", count: priceId))") } if let ratingBlock = v["rating"] as? [String: Any], let avg = ratingBlock["average"] as? Double { lines.append("Rating: \(avg)") } return lines.isEmpty ? jsonStr : lines.joined(separator: "\n") } /// Fetch a contact/about subpage and return its stripped text (up to 2000 chars). /// Used to supplement homepages that omit address info. private func fetchContactPageText(_ url: URL, completion: @escaping (String) -> Void) { var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") URLSession(configuration: .ephemeral).dataTask(with: req) { data, response, error in guard error == nil, let data, let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), (response as? HTTPURLResponse)?.statusCode == 200 else { completion("") return } let stripped = Self.stripHTML(html) self.debugLog("fetchContactPageText: got \(stripped.count) chars from \(url.absoluteString)") completion(String(stripped.prefix(2000))) }.resume() } /// Fetch a web page, strip HTML tags, send plain text to Claude for restaurant info. /// `supplementalText` is prepended when the fetched page returns sparse content (JS SPAs, etc.). private func fetchAndProcessURL(_ url: URL, supplementalText: String? = nil) { debugLog("fetchAndProcessURL: \(url.absoluteString)") var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") URLSession(configuration: .ephemeral).dataTask(with: request) { [weak self] data, response, error in guard let self = self else { return } if let error = error { DispatchQueue.main.async { self.showError("Failed to fetch URL: \(error.localizedDescription)") } return } guard let data = data, let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) else { DispatchQueue.main.async { self.showError("Could not read page content.") } return } // URLSession follows redirects automatically; capture the final URL so we pass // the real restaurant page URL (e.g. resy.com/cities/ny-ny/restaurants/foo) // to Claude rather than the short redirect link. let finalURL = (response as? HTTPURLResponse)?.url ?? url if finalURL.absoluteString != url.absoluteString { self.debugLog("fetchAndProcessURL: redirected to \(finalURL.absoluteString)") } // Extract structured data BEFORE stripping scripts. // JSON-LD handles sites like Atlas Obscura. // Script data handles CMS-driven sites like Orangetheory that embed // address/hours in regular JS variables (studioDetails, cmsAddress, etc.). let jsonLD = Self.extractJSONLD(fromHTML: html) let scriptData = Self.extractScriptData(fromHTML: html) self.debugLog("fetchAndProcessURL: JSON-LD: \(jsonLD.prefix(200))") self.debugLog("fetchAndProcessURL: scriptData: \(scriptData.prefix(200))") let strippedText = Self.stripHTML(html) var sections: [String] = [] if !jsonLD.isEmpty { sections.append("STRUCTURED DATA (JSON-LD):\n\(jsonLD)") } if !scriptData.isEmpty { sections.append("STRUCTURED DATA (page variables):\n\(scriptData)") } let imageURL = Self.extractImageURL(fromHTML: html, baseURL: finalURL) self.debugLog("fetchAndProcessURL: image candidate = \(imageURL?.absoluteString ?? "none")") // When the page is sparse (JS SPA / redirect landing page), try to look up the // business in Apple Maps (MKLocalSearch) so we get address + phone without JS. let sparseThreshold = 500 if strippedText.count < sparseThreshold && jsonLD.isEmpty && scriptData.isEmpty { // Build the best search query we have. // Priority: supplemental share text > URL slug from known platforms. var mapKitQuery: String? = nil // From supplemental text: "Check out Wayan" → "Wayan" if let hint = supplementalText { let cleaned = hint .replacingOccurrences(of: #"(?i)^check out\s+"#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s+on\s+\w+\.?\s*$"#, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) if !cleaned.isEmpty { mapKitQuery = cleaned } } // From URL slug on known restaurant-listing sites if mapKitQuery == nil { let host = finalURL.host ?? "" let isListingSite = host.contains("yelp.com") || host.contains("resy.com") || host.contains("opentable.com") || host.contains("exploretock.com") || host.contains("sevenrooms.com") if isListingSite { let slug = finalURL.pathComponents .filter { $0 != "/" && $0 != "biz" && $0 != "restaurants" && $0 != "cities" } .first ?? "" let query = slug.replacingOccurrences(of: "-", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) if !query.isEmpty { mapKitQuery = query } } } if let query = mapKitQuery { self.debugLog("fetchAndProcessURL: sparse SPA — searching MapKit for: \(query)") DispatchQueue.main.async { self.detailLabel.text = "Looking up location…" } self.searchMapKitForVenue(query: query, originalURL: finalURL, imageURL: imageURL, supplementalText: supplementalText) return } } // Normal path — enough HTML content to send to Claude directly. var sections2: [String] = [] if !jsonLD.isEmpty { sections2.append("STRUCTURED DATA (JSON-LD):\n\(jsonLD)") } if !scriptData.isEmpty { sections2.append("STRUCTURED DATA (page variables):\n\(scriptData)") } if strippedText.count < sparseThreshold { if let hint = supplementalText { self.debugLog("fetchAndProcessURL: sparse — supplemental text: \(hint.prefix(120))") sections2.insert("SHARE TEXT:\n\(hint)", at: 0) } } sections2.append("PAGE TEXT:\n\(strippedText)") self.debugLog("fetchAndProcessURL: stripped to \(strippedText.count) chars") self.debugLog("fetchAndProcessURL: preview: \(String(strippedText.prefix(300)))") // If this looks like a homepage with no address info, also fetch /contact. let urlPath = finalURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let isHomepage = urlPath.isEmpty let addressKeywords = ["Avenue", " Ave", "Street", " St ", "Boulevard", "Drive", " Rd ", "Floor", "Suite", "Ste.", "Level"] let hasAddressHint = addressKeywords.contains(where: { strippedText.contains($0) }) || !jsonLD.isEmpty || !scriptData.isEmpty let sendToClaudeWith: ([String]) -> Void = { [weak self] finalSections in guard let self else { return } let pageText = finalSections.count == 1 ? (finalSections.first ?? "") : finalSections.joined(separator: "\n\n") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL(pageText: pageText, url: finalURL, imageURL: imageURL) } if isHomepage && !hasAddressHint, var contactComponents = URLComponents(url: finalURL, resolvingAgainstBaseURL: false) { contactComponents.path = "/contact" contactComponents.query = nil if let contactURL = contactComponents.url { self.debugLog("fetchAndProcessURL: homepage with no address — fetching /contact") DispatchQueue.main.async { self.detailLabel.text = "Looking up address…" } self.fetchContactPageText(contactURL) { contactText in var enriched = sections2 if !contactText.isEmpty { enriched.append("CONTACT PAGE:\n\(contactText)") } sendToClaudeWith(enriched) } return } } sendToClaudeWith(sections2) }.resume() } /// Search Apple Maps (MKLocalSearch) for a business by name, then build Claude page text /// from the result. Used as a fallback when the fetched page is a JS SPA with no useful HTML. private func searchMapKitForVenue(query: String, originalURL: URL, imageURL: URL?, supplementalText: String?) { let request = MKLocalSearch.Request() request.naturalLanguageQuery = query MKLocalSearch(request: request).start { [weak self] response, error in guard let self = self else { return } if let error = error { self.debugLog("searchMapKitForVenue: error=\(error.localizedDescription)") } guard let mapItem = response?.mapItems.first else { self.debugLog("searchMapKitForVenue: no results — falling back to supplemental text") var parts: [String] = [] if let hint = supplementalText { parts.append("SHARE TEXT:\n\(hint)") } parts.append("SOURCE URL:\n\(originalURL.absoluteString)") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL( pageText: parts.joined(separator: "\n\n"), url: originalURL, imageURL: imageURL) return } let pm = mapItem.placemark var lines: [String] = [] if let name = mapItem.name, !name.isEmpty { lines.append("Name: \(name)") } var addrParts: [String] = [] if let n = pm.subThoroughfare, !n.isEmpty { addrParts.append(n) } if let s = pm.thoroughfare, !s.isEmpty { addrParts.append(s) } var cityLine = addrParts.joined(separator: " ") if let city = pm.locality, !city.isEmpty { cityLine += (cityLine.isEmpty ? "" : ", ") + city } if let state = pm.administrativeArea, !state.isEmpty { cityLine += ", \(state)" } if let zip = pm.postalCode, !zip.isEmpty { cityLine += " \(zip)" } if !cityLine.isEmpty { lines.append("Address: \(cityLine)") } if let nbhd = pm.subLocality, !nbhd.isEmpty { lines.append("Neighborhood: \(nbhd)") } if let phone = mapItem.phoneNumber, !phone.isEmpty { lines.append("Phone: \(phone)") } if let website = mapItem.url { lines.append("Website: \(website.absoluteString)") } self.debugLog("searchMapKitForVenue: found \(lines.prefix(3).joined(separator: " | "))") var sections: [String] = [] if let hint = supplementalText { sections.append("SHARE TEXT:\n\(hint)") } sections.append("APPLE MAPS LOOKUP:\n\(lines.joined(separator: "\n"))") DispatchQueue.main.async { self.detailLabel.text = "Sending to Claude…" } self.processWithClaudeWebURL( pageText: sections.joined(separator: "\n\n"), url: originalURL, imageURL: imageURL) } } /// Send stripped page text to Claude (and optionally download an image) then save a pending entry. private func processWithClaudeWebURL(pageText: String, url: URL, imageURL: URL?) { // Download the page image in parallel with the Claude call. let imageGroup = DispatchGroup() var imagePath = "" if let imageURL, let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID) { imageGroup.enter() var req = URLRequest(url: imageURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent") URLSession(configuration: .ephemeral).dataTask(with: req) { [weak self] data, _, error in defer { imageGroup.leave() } guard let data, data.count > 2048, error == nil else { self?.debugLog("Image download skipped: \(error?.localizedDescription ?? "too small or nil")") return } let dest = containerURL.appendingPathComponent("pending_image.dat") if (try? data.write(to: dest)) != nil { imagePath = dest.path self?.debugLog("Image saved: \(data.count) bytes") } }.resume() } ClaudeAPIService.extractWebPageInfo(fromWebPageText: pageText, url: url) { [weak self] result in // Wait up to 8 s for the image download before saving (still on background thread here). imageGroup.wait(timeout: .now() + 8) DispatchQueue.main.async { guard let self else { return } switch result { case .success(let info): self.saveWebPageEntry(info: info, sourceURL: url, imagePath: imagePath) case .failure(let error): self.showError("Claude error: \(error.localizedDescription)") } } } } /// Save a web page entry for any content type (Food, Place, or Show). private func saveWebPageEntry(info: ClaudeExtractedInfo, sourceURL: URL, imagePath: String) { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { showError("Could not access shared container.") return } let contentType = Self.classifyExtractedInfo(info) let entryModeStr = contentType == "Show" ? "show" : contentType == "Event" ? "event" : contentType == "Place" ? "place" : contentType == "Shop" ? "shop" : "food" debugLog("🔍 Web page content type: \(contentType)") // For shows with a date, use the extracted date and mark hasCustomDate = true. // For food/place (or shows without a date), use today at 7 pm as a placeholder. let hasDate = !info.isoDateTime.isEmpty && (contentType == "Show" || contentType == "Event") let entryDateTime: Date if hasDate { entryDateTime = parseISODateTime(info.isoDateTime) } else { var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) components.hour = 19; components.minute = 0 entryDateTime = Calendar.current.date(from: components) ?? Date() } let fileURL = containerURL.appendingPathComponent("pending_entry.json") let entryData: [String: Any] = [ "playName" : info.showName, "cuisine" : info.cuisine, "address" : info.address, "phone" : info.phone, "website" : info.website, "neighborhood" : info.neighborhood, "hours" : info.hours, "notes" : "", "confirmationLink" : sourceURL.absoluteString, "emlFilePath" : "", "pendingImagePath" : imagePath, "hasCustomDate" : hasDate, "dateTime" : entryDateTime.timeIntervalSince1970, "timestamp" : Date().timeIntervalSince1970, "entryMode" : entryModeStr ] if let jsonData = try? JSONSerialization.data(withJSONObject: entryData) { try? jsonData.write(to: fileURL) } // Build the subtitle line for the success card let subtitle: String if hasDate { let df = DateFormatter() df.dateStyle = .long df.timeStyle = .short subtitle = df.string(from: entryDateTime) } else { subtitle = [info.cuisine, info.neighborhood, info.address] .filter { !$0.isEmpty } .joined(separator: " · ") } let displayName = info.showName.isEmpty ? "\(contentType) Saved" : info.showName showSuccess(showName: displayName, dateString: subtitle) } /// Write parsed restaurant info (and optional image) into pending_entry.json and show the success card. private func saveURLEntry(info: RestaurantInfo, sourceURL: URL, imagePath: String, contentType: String = "Food") { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { showError("Could not access shared container.") return } // Default date: today at 7 pm (restaurants don't have a fixed "show time") var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) components.hour = 19; components.minute = 0 let defaultDate = Calendar.current.date(from: components) ?? Date() let entryModeStr = contentType == "Show" ? "show" : contentType == "Event" ? "event" : contentType == "Place" ? "place" : contentType == "Shop" ? "shop" : "food" let fileURL = containerURL.appendingPathComponent("pending_entry.json") let entryData: [String: Any] = [ "playName" : info.name, "cuisine" : info.cuisine, "address" : info.address, "neighborhood" : info.neighborhood, "hours" : info.hours, "notes" : "", "confirmationLink" : sourceURL.absoluteString, "emlFilePath" : "", "pendingImagePath" : imagePath, "hasCustomDate" : false, // user must set date manually via the Calendar section "dateTime" : defaultDate.timeIntervalSince1970, "timestamp" : Date().timeIntervalSince1970, "entryMode" : entryModeStr ] if let jsonData = try? JSONSerialization.data(withJSONObject: entryData) { try? jsonData.write(to: fileURL) } showSuccess(showName: info.name.isEmpty ? "Restaurant Saved" : info.name, dateString: [info.cuisine, info.neighborhood, info.address] .filter { !$0.isEmpty } .joined(separator: " · ")) }Documentation commentDescribes the following declaration.
▶ IMAGE EXTRACTION FROM HTML
/// Extract useful <meta> name/property/content pairs from HTML. /// Captures og:title, og:description, og:street-address, og:locality, og:region, /// og:postal-code, og:phone_number, description, and any other content-bearing tags. private static func extractMetaTags(fromHTML html: String) -> String { let patterns = [ // name/property before content #"<meta[^>]+(?:name|property)=["']([^"']+)["'][^>]+content=["']([^"']+)["']"#, // content before name/property #"<meta[^>]+content=["']([^"']+)["'][^>]+(?:name|property)=["']([^"']+)["']"#, ] var results: [String] = [] var seenKeys = Set<String>() for (idx, pattern) in patterns.enumerated() { guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue } let range = NSRange(html.startIndex..., in: html) for match in regex.matches(in: html, range: range) { guard match.numberOfRanges == 3, let r1 = Range(match.range(at: 1), in: html), let r2 = Range(match.range(at: 2), in: html) else { continue } // Pattern 0: group1=key, group2=value; Pattern 1: group1=value, group2=key let key = idx == 0 ? String(html[r1]) : String(html[r2]) let value = idx == 0 ? String(html[r2]) : String(html[r1]) let k = key.trimmingCharacters(in: .whitespaces).lowercased() let v = value.trimmingCharacters(in: .whitespaces) guard !v.isEmpty, !seenKeys.contains(k) else { continue } // Skip low-value tags (viewport, robots, charset noise) let skip = ["viewport", "robots", "referrer", "theme-color", "format-detection", "apple-mobile-web-app", "msapplication", "generator"] if skip.contains(where: { k.hasPrefix($0) }) { continue } seenKeys.insert(k) results.append("\(key): \(v)") } } return results.joined(separator: "\n") } /// Returns the best candidate image URL from a page's HTML. /// Prefers og:image / twitter:image meta tags, falls back to the first photo-like <img>. private static func extractImageURL(fromHTML html: String, baseURL: URL) -> URL? { // Meta tag patterns — handle both attribute orderings let metaPatterns = [ #"<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']"#, #"<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']"#, #"<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']"#, #"<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']"#, ] for pattern in metaPatterns { if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), match.numberOfRanges > 1, let range = Range(match.range(at: 1), in: html) { if let url = resolveURL(String(html[range]), baseURL: baseURL) { return url } } } // Fallback: first <img> pointing to a raster image file let imgPattern = #"<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|webp)(?:\?[^"']*)?)["']"# if let regex = try? NSRegularExpression(pattern: imgPattern, options: .caseInsensitive), let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), match.numberOfRanges > 1, let range = Range(match.range(at: 1), in: html) { if let url = resolveURL(String(html[range]), baseURL: baseURL) { return url } } return nil } private static func resolveURL(_ urlStr: String, baseURL: URL) -> URL? { let s = urlStr.trimmingCharacters(in: .whitespacesAndNewlines) if s.hasPrefix("http://") || s.hasPrefix("https://") { return URL(string: s) } if s.hasPrefix("//") { return URL(string: "https:" + s) } if s.hasPrefix("/"), let scheme = baseURL.scheme, let host = baseURL.host { return URL(string: "\(scheme)://\(host)\(s)") } return nil } /// Scan inline <script> blocks (not JSON-LD, not external src=) for lines that contain /// address/hours/name data keywords. Many CMS-driven sites (Orangetheory, gym chains, /// restaurant groups, etc.) store location data in JS variables rather than JSON-LD. /// Returns only the matching lines — not entire script blocks — to avoid flooding the /// Claude prompt with minified JavaScript. private static func extractScriptData(fromHTML html: String) -> String { let keywords = [ "physical-address", "physical-city", "physical-state", "physical-postal", "streetAddress", "addressLocality", "addressRegion", "postalCode", "cmsAddress", "cmsCity", "cmsState", "cmsPostalCode", "cmsStudioName", "cmsStudioHours", "studio-hours", "studioHours", "studioDetails", "openingHours", "businessHours", ] let pattern = #"<script(?![^>]*\bsrc\b)(?![^>]*application/ld\+json)[^>]*>([\s\S]*?)</script>"# guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return "" } var matchedLines: [String] = [] let range = NSRange(html.startIndex..., in: html) for match in regex.matches(in: html, range: range) { guard match.numberOfRanges > 1, let r = Range(match.range(at: 1), in: html) else { continue } let block = String(html[r]) for line in block.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed.count < 500 else { continue } if keywords.contains(where: { trimmed.lowercased().contains($0.lowercased()) }) { matchedLines.append(trimmed) } } } return String(matchedLines.joined(separator: "\n").prefix(1500)) } /// Extract all JSON-LD blocks from HTML before scripts are stripped. /// Many place sites (Atlas Obscura, Yelp, Google, etc.) embed structured /// address/hours data in <script type="application/ld+json"> tags that /// would otherwise be lost during HTML stripping. private static func extractJSONLD(fromHTML html: String) -> String { var results: [String] = [] let pattern = #"<script[^>]+type=["']application/ld\+json["'][^>]*>([\s\S]*?)</script>"# guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return "" } let range = NSRange(html.startIndex..., in: html) for match in regex.matches(in: html, range: range) { if match.numberOfRanges > 1, let r = Range(match.range(at: 1), in: html) { let block = String(html[r]).trimmingCharacters(in: .whitespacesAndNewlines) if !block.isEmpty { results.append(block) } } } return results.joined(separator: "\n") } /// Strip HTML tags and collapse whitespace — pure Swift, safe to call on any thread. private static func stripHTML(_ html: String) -> String { var text = html // 1. Remove entire block-level noise sections for tag in ["script", "style", "noscript", "head", "svg", "iframe"] { var searchRange = text.startIndex..<text.endIndex while let openRange = text.range(of: "<\(tag)", options: .caseInsensitive, range: searchRange) { let closeTag = "</\(tag)>" if let closeRange = text.range(of: closeTag, options: .caseInsensitive, range: openRange.upperBound..<text.endIndex) { text.removeSubrange(openRange.lowerBound..<closeRange.upperBound) searchRange = openRange.lowerBound..<text.endIndex } else { break } } } // 2. Convert block-level tags to newlines before stripping let blockTags = ["p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article"] for tag in blockTags { text = text.replacingOccurrences(of: "<\(tag)[^>]*>", with: "\n", options: .regularExpression) text = text.replacingOccurrences(of: "</\(tag)>", with: "\n", options: .caseInsensitive) } // 3. Strip all remaining HTML tags var result = "" var inTag = false for ch in text { if ch == "<" { inTag = true } else if ch == ">" { inTag = false } else if !inTag { result.append(ch) } } // 4. Decode common HTML entities result = result .replacingOccurrences(of: "&amp;", with: "&") .replacingOccurrences(of: "&lt;", with: "<") .replacingOccurrences(of: "&gt;", with: ">") .replacingOccurrences(of: "&nbsp;", with: " ") .replacingOccurrences(of: "&#39;", with: "'") .replacingOccurrences(of: "&quot;", with: "\"") .replacingOccurrences(of: "&#x27;", with: "'") // 5. Remove non-printable control characters (null bytes etc.) that break JSON result = result.unicodeScalars.filter { scalar in let v = scalar.value return v == 0x09 || v == 0x0A || v == 0x0D || (v >= 0x20 && v != 0x7F) }.reduce("") { $0 + String($1) } // 6. Collapse whitespace / blank lines let lines = result.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } return lines.joined(separator: "\n") } /// Save using on-device extraction WITHOUT calling showError again private func fallbackAndSave(parsed: ParsedEmail, attachmentData: Data) { let localInfo = EntryInfoExtractor.extract(from: parsed) var isoDateTime = "" if let dt = localInfo.dateTime { let iso = ISO8601DateFormatter() iso.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime] iso.timeZone = .current isoDateTime = iso.string(from: dt) } let fallbackInfo = ClaudeExtractedInfo(showName: localInfo.showName ?? "", isoDateTime: isoDateTime, cuisine: "", address: "", phone: "", website: "", neighborhood: "", hours: "", contentCategory: "") let entryDateTime = parseISODateTime(fallbackInfo.isoDateTime) guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { return } var emlFilePath = "" let emlURL = containerURL.appendingPathComponent("\(UUID().uuidString).eml") if let _ = try? attachmentData.write(to: emlURL) { emlFilePath = emlURL.path } let fileURL = containerURL.appendingPathComponent("pending_entry.json") let entryData: [String: Any] = [ "playName" : fallbackInfo.showName, "cuisine" : "", "address" : "", "emlFilePath": emlFilePath, "dateTime" : entryDateTime.timeIntervalSince1970, "timestamp" : Date().timeIntervalSince1970, "entryMode" : "food" ] if let jsonData = try? JSONSerialization.data(withJSONObject: entryData) { try? jsonData.write(to: fileURL) } } /// Save a PDF with empty defaults when Claude API is unavailable private func fallbackAndSavePDF(data: Data) { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { return } var pdfFilePath = "" let pdfURL = containerURL.appendingPathComponent("\(UUID().uuidString).pdf") if let _ = try? data.write(to: pdfURL) { pdfFilePath = pdfURL.path } var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) components.hour = 19; components.minute = 0 let defaultDate = Calendar.current.date(from: components) ?? Date() let fileURL = containerURL.appendingPathComponent("pending_entry.json") let entryData: [String: Any] = [ "playName" : "", "cuisine" : "", "address" : "", "neighborhood": "", "emlFilePath" : pdfFilePath, "dateTime" : defaultDate.timeIntervalSince1970, "timestamp" : Date().timeIntervalSince1970, "entryMode" : "food" ] if let jsonData = try? JSONSerialization.data(withJSONObject: entryData) { try? jsonData.write(to: fileURL) } }Documentation commentDescribes the following declaration.
▶ SAVE
private func saveAndDismiss(info: ClaudeExtractedInfo, attachmentData: Data, fileExtension: String, usedFallback: Bool) { let entryDateTime = parseISODateTime(info.isoDateTime) guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { showError("Could not access shared container.") return } // Save the attachment file (.eml or .pdf) var emlFilePath = "" let attachmentURL = containerURL.appendingPathComponent("\(UUID().uuidString).\(fileExtension)") do { try attachmentData.write(to: attachmentURL) emlFilePath = attachmentURL.path } catch { print("Error saving .\(fileExtension): \(error)") } // Save entry data for the main app let contentType = Self.classifyExtractedInfo(info) let entryModeStr = contentType == "Show" ? "show" : contentType == "Event" ? "event" : contentType == "Place" ? "place" : contentType == "Shop" ? "shop" : "food" let fileURL = containerURL.appendingPathComponent("pending_entry.json") let entryData: [String: Any] = [ "playName" : info.showName, "cuisine" : info.cuisine, "address" : info.address, "phone" : info.phone, "website" : info.website, "neighborhood" : info.neighborhood, "hours" : info.hours, "emlFilePath" : emlFilePath, "hasCustomDate" : true, // date was extracted from email/PDF by Claude "dateTime" : entryDateTime.timeIntervalSince1970, "timestamp" : Date().timeIntervalSince1970, "entryMode" : entryModeStr ] if let jsonData = try? JSONSerialization.data(withJSONObject: entryData) { try? jsonData.write(to: fileURL) } // Build human-readable date for the success card let dateFormatter = DateFormatter() dateFormatter.dateStyle = .long dateFormatter.timeStyle = .short let dateString = dateFormatter.string(from: entryDateTime) if usedFallback { showError("Claude API was unavailable.\nDetails may need correction.") } else { showSuccess(showName: info.showName, dateString: dateString) } }`saveAndDismiss()` functionImplements `saveAndDismiss`.
▶ DATE PARSING
/// Parse ISO 8601 string as local time (not UTC). private func parseISODateTime(_ isoString: String) -> Date { if !isoString.isEmpty { // Full datetime: "2026-03-15T14:00:00" let full = ISO8601DateFormatter() full.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime] full.timeZone = .current // treat as local, not UTC if let date = full.date(from: isoString) { return date } // Date only: "2026-03-15" let dateOnly = ISO8601DateFormatter() dateOnly.formatOptions = [.withFullDate] dateOnly.timeZone = .current if let date = dateOnly.date(from: isoString) { var c = Calendar.current.dateComponents([.year, .month, .day], from: date) c.hour = 19; c.minute = 0 return Calendar.current.date(from: c) ?? date } } // Default: today at 7 pm var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) c.hour = 19; c.minute = 0 return Calendar.current.date(from: c) ?? Date() }Documentation commentDescribes the following declaration.
▶ DISMISS
private func done() { extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } }`done()` functionImplements `done`.