| Code | What It Does | How It Does It |
| ▶ IMPORTS | | |
| import UIKit
import MapKit
import UniformTypeIdentifiers | Framework imports | Imports 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` class | Defines 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` let | Property `backdropView`. |
| ▶ LIFECYCLE | | |
| override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
extractAndCreateEntry()
} | `viewDidLoad()` function | Implements `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()` function | Implements `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()` function | Implements `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()` function | Implements `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 comment | Describes 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()` function | Implements `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 comment | Describes 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()` function | Implements `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 comment | Describes 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 comment | Describes 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: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: " ", with: " ")
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: """, with: "\"")
.replacingOccurrences(of: "'", 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 comment | Describes 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()` function | Implements `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 comment | Describes the following declaration. |
| ▶ DISMISS | | |
| private func done() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
} | `done()` function | Implements `done`. |