You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

816 lines
33 KiB

import Foundation
import UIKit
// MARK: - vCard
struct VCardConverter {
/// vCard 2.13.0
static func convertVCard21To30(_ vcard21: String) -> String {
let lines = vcard21.components(separatedBy: .newlines)
var vcard30 = "BEGIN:VCARD\nVERSION:3.0\n"
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.isEmpty || trimmedLine.hasPrefix("BEGIN:") || trimmedLine.hasPrefix("VERSION:") || trimmedLine.hasPrefix("END:") {
continue
}
// N ()
if trimmedLine.hasPrefix("N:") {
let nameValue = String(trimmedLine.dropFirst(2))
let nameParts = nameValue.components(separatedBy: ";")
if nameParts.count >= 2 {
let lastName = nameParts[0]
let firstName = nameParts[1]
vcard30 += "N:\(lastName);\(firstName);;;\n"
vcard30 += "FN:\(firstName) \(lastName)\n"
}
}
// TEL ()
else if trimmedLine.hasPrefix("TEL") {
let telValue = String(trimmedLine.dropFirst(3))
if telValue.hasPrefix(";TYPE=") {
let typeStart = telValue.firstIndex(of: "=")!
let typeEnd = telValue.firstIndex(of: ":") ?? telValue.endIndex
let type = String(telValue[telValue.index(after: typeStart)..<typeEnd])
let number = String(telValue[telValue.index(after: typeEnd)...])
vcard30 += "TEL;TYPE=\(type):\(number)\n"
} else if telValue.hasPrefix(":") {
let number = String(telValue.dropFirst())
vcard30 += "TEL:\(number)\n"
}
}
// EMAIL ()
else if trimmedLine.hasPrefix("EMAIL") {
let emailValue = String(trimmedLine.dropFirst(5))
if emailValue.hasPrefix(";TYPE=") {
let typeStart = emailValue.firstIndex(of: "=")!
let typeEnd = emailValue.firstIndex(of: ":") ?? emailValue.endIndex
let type = String(emailValue[emailValue.index(after: typeStart)..<typeEnd])
let email = String(emailValue[emailValue.index(after: typeEnd)...])
vcard30 += "EMAIL;TYPE=\(type):\(email)\n"
} else if emailValue.hasPrefix(":") {
let email = String(emailValue.dropFirst())
vcard30 += "EMAIL:\(email)\n"
}
}
// ADR ()
else if trimmedLine.hasPrefix("ADR") {
let adrValue = String(trimmedLine.dropFirst(3))
if adrValue.hasPrefix(";TYPE=") {
let typeStart = adrValue.firstIndex(of: "=")!
let typeEnd = adrValue.firstIndex(of: ":") ?? adrValue.endIndex
let type = String(adrValue[adrValue.index(after: typeStart)..<typeEnd])
let address = String(adrValue[adrValue.index(after: typeEnd)...])
vcard30 += "ADR;TYPE=\(type):\(address)\n"
} else if adrValue.hasPrefix(":") {
let address = String(adrValue.dropFirst())
vcard30 += "ADR:\(address)\n"
}
}
//
else if trimmedLine.contains(":") {
let colonIndex = trimmedLine.firstIndex(of: ":")!
let fieldName = String(trimmedLine[..<colonIndex])
let fieldValue = String(trimmedLine[trimmedLine.index(after: colonIndex)...])
//
if !["N", "TEL", "EMAIL", "ADR"].contains(fieldName) {
vcard30 += "\(fieldName):\(fieldValue)\n"
}
}
}
vcard30 += "END:VCARD"
return vcard30
}
/// vCard
static func detectVCardVersion(_ vcard: String) -> String {
let lines = vcard.components(separatedBy: .newlines)
for line in lines {
if line.hasPrefix("VERSION:") {
return String(line.dropFirst(8))
}
}
return "3.0" //
}
/// vCard3.0
static func normalizeVCard(_ vcard: String) -> String {
let version = detectVCardVersion(vcard)
if version == "2.1" {
return convertVCard21To30(vcard)
}
return vcard
}
/// vCard
static func testVCardConversion() {
let vcard21 = """
BEGIN:VCARD
VERSION:2.1
N:Surname;Givenname;;;
FN:Givenname Surname
TEL;TYPE=WORK:123-456-7890
EMAIL;TYPE=PREF:test@example.com
END:VCARD
"""
let converted = convertVCard21To30(vcard21)
print("Original vCard 2.1:")
print(vcard21)
print("\nConverted to vCard 3.0:")
print(converted)
}
}
// MARK: -
class QRCodeParser {
// MARK: -
static func parseQRCode(_ content: String) -> ParsedQRData {
let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
// Wi-Fi
if trimmedContent.uppercased().hasPrefix("WIFI:") {
return parseWiFi(trimmedContent)
}
// Email
if trimmedContent.lowercased().hasPrefix("mailto:") {
return parseEmail(trimmedContent)
}
// Phone
if trimmedContent.lowercased().hasPrefix("tel:") {
return parsePhone(trimmedContent)
}
// SMS
if trimmedContent.uppercased().hasPrefix("SMSTO:") {
return parseSMS(trimmedContent)
}
// vCard
if trimmedContent.uppercased().hasPrefix("BEGIN:VCARD") {
return parseVCard(trimmedContent)
}
// MeCard
if trimmedContent.uppercased().hasPrefix("MECARD:") {
return parseMeCard(trimmedContent)
}
// Calendar
if trimmedContent.uppercased().hasPrefix("BEGIN:VEVENT") {
return parseCalendar(trimmedContent)
}
// Instagram
if trimmedContent.lowercased().hasPrefix("instagram://user?username=") {
return parseInstagram(trimmedContent)
}
// Facebook
if trimmedContent.lowercased().hasPrefix("fb://profile/") {
return parseFacebook(trimmedContent)
}
// Spotify
if trimmedContent.lowercased().hasPrefix("spotify:search:") {
return parseSpotify(trimmedContent)
}
// X (Twitter)
if trimmedContent.lowercased().hasPrefix("twitter://user?screen_name=") || trimmedContent.lowercased().contains("x.com") || trimmedContent.lowercased().contains("twitter.com") {
return parseTwitter(trimmedContent)
}
// WhatsApp
if trimmedContent.lowercased().hasPrefix("whatsapp://send?phone=") {
return parseWhatsApp(trimmedContent)
}
// Viber
if trimmedContent.lowercased().hasPrefix("viber://add?number=") {
return parseViber(trimmedContent)
}
// Snapchat
if trimmedContent.lowercased().hasPrefix("snapchat://") {
return parseSnapchat(trimmedContent)
}
// TikTok
if trimmedContent.lowercased().contains("tiktok.com") || trimmedContent.lowercased().contains("www.tiktok.com") {
return parseTikTok(trimmedContent)
}
// URL (URL)
if isValidURL(trimmedContent) {
return parseURL(trimmedContent)
}
// Location
if trimmedContent.lowercased().hasPrefix("geo:") {
return parseLocation(trimmedContent)
}
//
return ParsedQRData(
type: .text,
title: "text_information".localized,
subtitle: trimmedContent.count > 50 ? String(trimmedContent.prefix(50)) + "..." : trimmedContent,
icon: "text.quote"
)
}
// MARK: - Wi-Fi
private static func parseWiFi(_ content: String) -> ParsedQRData {
let wifiInfo = content.replacingOccurrences(of: "WIFI:", with: "", options: .caseInsensitive)
let components = wifiInfo.components(separatedBy: ";")
var ssid = ""
var password = ""
var encryption = "WPA"
for component in components {
if component.uppercased().hasPrefix("S:") {
ssid = String(component.dropFirst(2))
} else if component.uppercased().hasPrefix("P:") {
password = String(component.dropFirst(2))
} else if component.uppercased().hasPrefix("T:") {
encryption = String(component.dropFirst(2))
}
}
let title = "wifi_network".localized
let subtitle = String(format: "wifi_network_info".localized, ssid, encryption, password)
// WiFi
let wifiDetails = WiFiDetails(ssid: ssid, password: password, encryption: encryption)
let extraData = try? JSONEncoder().encode(wifiDetails)
return ParsedQRData(
type: .wifi,
title: title,
subtitle: subtitle,
icon: "wifi",
extraData: extraData
)
}
// MARK: - Email
private static func parseEmail(_ content: String) -> ParsedQRData {
let emailInfo = content.replacingOccurrences(of: "mailto:", with: "", options: .caseInsensitive)
let components = emailInfo.components(separatedBy: "?")
let emailAddress = components.first ?? ""
var subject = ""
var body = ""
//
if components.count > 1 {
let queryString = components[1]
let queryParams = queryString.components(separatedBy: "&")
for param in queryParams {
let keyValue = param.components(separatedBy: "=")
if keyValue.count == 2 {
let key = keyValue[0].lowercased()
let value = keyValue[1].removingPercentEncoding ?? keyValue[1]
if key == "subject" {
subject = value
} else if key == "body" {
body = value
}
}
}
}
//
var subtitle = emailAddress
if !subject.isEmpty {
subtitle += String(format: "\n主题: %@", subject)
}
if !body.isEmpty {
let truncatedBody = body.count > 100 ? String(body.prefix(100)) + "..." : body
subtitle += String(format: "\n内容: %@", truncatedBody)
}
// Email
let emailDetails = EmailDetails(emailAddress: emailAddress, subject: subject, body: body)
let extraData = try? JSONEncoder().encode(emailDetails)
return ParsedQRData(
type: .mail,
title: "email_address".localized,
subtitle: subtitle,
icon: "envelope",
extraData: extraData
)
}
// MARK: - Phone
private static func parsePhone(_ content: String) -> ParsedQRData {
let phone = content.replacingOccurrences(of: "tel:", with: "", options: .caseInsensitive)
return ParsedQRData(
type: .phone,
title: "phone_number".localized,
subtitle: phone,
icon: "phone"
)
}
// MARK: - SMS
private static func parseSMS(_ content: String) -> ParsedQRData {
let smsInfo = content.replacingOccurrences(of: "SMSTO:", with: "", options: .caseInsensitive)
let components = smsInfo.components(separatedBy: ":")
let phone = components.first?.trimmingCharacters(in: .whitespaces) ?? ""
let message = components.count > 1 ? components[1].trimmingCharacters(in: .whitespaces) : ""
let title = "sms".localized
var subtitle = ""
//
if !phone.isEmpty {
subtitle += String(format: "📱 %@", phone)
}
if !message.isEmpty {
if !subtitle.isEmpty { subtitle += "\n" }
let truncatedMessage = message.count > 100 ? String(message.prefix(100)) + "..." : message
subtitle += String(format: "💬 %@", truncatedMessage)
}
// SMS
let smsDetails = SMSDetails(phoneNumber: phone, message: message)
let extraData = try? JSONEncoder().encode(smsDetails)
return ParsedQRData(
type: .sms,
title: title,
subtitle: subtitle,
icon: "message",
extraData: extraData
)
}
// MARK: - vCard
private static func parseVCard(_ content: String) -> ParsedQRData {
// vCard3.0
let normalizedVCard = VCardConverter.normalizeVCard(content)
let contactInfo = parseContactInfoFromVCard(normalizedVCard)
var subtitle = ""
if !contactInfo.name.isEmpty { subtitle += String(format: "contact_name".localized, contactInfo.name) + "\n" }
if !contactInfo.phoneNumber.isEmpty { subtitle += String(format: "contact_phone".localized, contactInfo.phoneNumber) + "\n" }
if !contactInfo.email.isEmpty { subtitle += String(format: "contact_email".localized, contactInfo.email) + "\n" }
if !contactInfo.organization.isEmpty { subtitle += String(format: "contact_company".localized, contactInfo.organization) + "\n" }
if !contactInfo.title.isEmpty { subtitle += String(format: "contact_title".localized, contactInfo.title) + "\n" }
if !contactInfo.address.isEmpty { subtitle += String(format: "contact_address".localized, contactInfo.address) + "\n" }
//
if subtitle.hasSuffix("\n") {
subtitle = String(subtitle.dropLast())
}
//
let extraData = try? JSONEncoder().encode(contactInfo)
return ParsedQRData(
type: .vcard,
title: "contact_information".localized,
subtitle: subtitle,
icon: "person.crop.rectangle",
extraData: extraData
)
}
// MARK: - MeCard
private static func parseMeCard(_ content: String) -> ParsedQRData {
let contactInfo = parseContactInfoFromMeCard(content)
var subtitle = ""
if !contactInfo.name.isEmpty { subtitle += String(format: "contact_name".localized, contactInfo.name) + "\n" }
if !contactInfo.phoneNumber.isEmpty { subtitle += String(format: "contact_phone".localized, contactInfo.phoneNumber) + "\n" }
if !contactInfo.email.isEmpty { subtitle += String(format: "contact_email".localized, contactInfo.email) + "\n" }
if !contactInfo.organization.isEmpty { subtitle += String(format: "contact_company".localized, contactInfo.organization) + "\n" }
if !contactInfo.title.isEmpty { subtitle += String(format: "contact_title".localized, contactInfo.title) + "\n" }
if !contactInfo.address.isEmpty { subtitle += String(format: "contact_address".localized, contactInfo.address) + "\n" }
//
if subtitle.hasSuffix("\n") {
subtitle = String(subtitle.dropLast())
}
//
let extraData = try? JSONEncoder().encode(contactInfo)
return ParsedQRData(
type: .mecard,
title: "contact_information".localized,
subtitle: subtitle,
icon: "person.crop.rectangle",
extraData: extraData
)
}
// MARK: - Calendar
private static func parseCalendar(_ content: String) -> ParsedQRData {
let lines = content.components(separatedBy: .newlines)
var summary = ""
var startTime = ""
var endTime = ""
var location = ""
var description = ""
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.uppercased().hasPrefix("SUMMARY:") {
summary = String(trimmedLine.dropFirst(8)).trimmingCharacters(in: .whitespaces)
} else if trimmedLine.uppercased().hasPrefix("DTSTART:") {
startTime = String(trimmedLine.dropFirst(8)).trimmingCharacters(in: .whitespaces)
} else if trimmedLine.uppercased().hasPrefix("DTEND:") {
endTime = String(trimmedLine.dropFirst(6)).trimmingCharacters(in: .whitespaces)
} else if trimmedLine.uppercased().hasPrefix("LOCATION:") {
location = String(trimmedLine.dropFirst(9)).trimmingCharacters(in: .whitespaces)
} else if trimmedLine.uppercased().hasPrefix("DESCRIPTION:") {
description = String(trimmedLine.dropFirst(12)).trimmingCharacters(in: .whitespaces)
}
}
//
let formattedStartTime = formatCalendarTime(startTime)
let formattedEndTime = formatCalendarTime(endTime)
let title = "calendar_event".localized
var subtitle = ""
//
if !summary.isEmpty {
subtitle += summary
}
if !formattedStartTime.isEmpty {
if !subtitle.isEmpty { subtitle += "\n" }
subtitle += String(format: "📅 %@", formattedStartTime)
}
if !formattedEndTime.isEmpty && formattedEndTime != formattedStartTime {
subtitle += String(format: " - %@", formattedEndTime)
}
if !location.isEmpty {
if !subtitle.isEmpty { subtitle += "\n" }
subtitle += String(format: "📍 %@", location)
}
if !description.isEmpty {
if !subtitle.isEmpty { subtitle += "\n" }
let truncatedDescription = description.count > 100 ? String(description.prefix(100)) + "..." : description
subtitle += String(format: "📝 %@", truncatedDescription)
}
//
let calendarDetails = CalendarDetails(summary: summary, startTime: startTime, endTime: endTime, location: location, description: description)
let extraData = try? JSONEncoder().encode(calendarDetails)
return ParsedQRData(
type: .calendar,
title: title,
subtitle: subtitle,
icon: "calendar",
extraData: extraData
)
}
// MARK: -
private static func formatCalendarTime(_ timeString: String) -> String {
guard !timeString.isEmpty else { return "" }
let dateFormatter = DateFormatter()
//
let formats = [
"yyyyMMdd'T'HHmmss", // 20241201T140000
"yyyyMMdd'T'HHmmss'Z'", // Z20241201T140000Z
"yyyyMMdd", // 20241201
"yyyyMMdd'T'HHmm", // 20241201T1400
"yyyy-MM-dd'T'HH:mm:ss", // ISO2024-12-01T14:00:00
"yyyy-MM-dd'T'HH:mm:ss'Z'" // ISOZ2024-12-01T14:00:00Z
]
for format in formats {
dateFormatter.dateFormat = format
if let date = dateFormatter.date(from: timeString) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
return displayFormatter.string(from: date)
}
}
//
return timeString
}
// MARK: - Instagram
private static func parseInstagram(_ content: String) -> ParsedQRData {
var username = ""
if content.lowercased().hasPrefix("instagram://user?username=") {
username = content.replacingOccurrences(of: "instagram://user?username=", with: "", options: .caseInsensitive)
} else if content.lowercased().contains("instagram.com") {
// URL
let components = content.components(separatedBy: "/")
if let lastComponent = components.last, !lastComponent.isEmpty && !lastComponent.contains("?") {
username = lastComponent
}
}
return ParsedQRData(
type: .instagram,
title: "instagram".localized,
subtitle: String(format: "instagram_username".localized, username),
icon: "camera"
)
}
// MARK: - Facebook
private static func parseFacebook(_ content: String) -> ParsedQRData {
var profileId = ""
if content.lowercased().hasPrefix("fb://profile/") {
profileId = content.replacingOccurrences(of: "fb://profile/", with: "", options: .caseInsensitive)
} else if content.lowercased().contains("facebook.com") {
// URLID
let components = content.components(separatedBy: "/")
if let lastComponent = components.last, !lastComponent.isEmpty && !lastComponent.contains("?") {
profileId = lastComponent
}
}
return ParsedQRData(
type: .facebook,
title: "facebook".localized,
subtitle: String(format: "facebook_profile_id".localized, profileId),
icon: "person.2"
)
}
// MARK: - Spotify
private static func parseSpotify(_ content: String) -> ParsedQRData {
var subtitle = ""
if content.lowercased().hasPrefix("spotify:search:") {
let searchQuery = content.replacingOccurrences(of: "spotify:search:", with: "", options: .caseInsensitive)
subtitle = String(format: "spotify_search_query".localized, searchQuery)
} else if content.lowercased().hasPrefix("spotify:track:") {
let trackId = content.replacingOccurrences(of: "spotify:track:", with: "", options: .caseInsensitive)
subtitle = String(format: "spotify_track".localized, trackId)
} else if content.lowercased().hasPrefix("spotify:album:") {
let albumId = content.replacingOccurrences(of: "spotify:album:", with: "", options: .caseInsensitive)
subtitle = String(format: "spotify_album".localized, albumId)
} else if content.lowercased().hasPrefix("spotify:artist:") {
let artistId = content.replacingOccurrences(of: "spotify:artist:", with: "", options: .caseInsensitive)
subtitle = String(format: "spotify_artist".localized, artistId)
} else if content.lowercased().hasPrefix("spotify:playlist:") {
let playlistId = content.replacingOccurrences(of: "spotify:playlist:", with: "", options: .caseInsensitive)
subtitle = String(format: "spotify_playlist".localized, playlistId)
} else if content.lowercased().contains("open.spotify.com") {
// URL
if content.lowercased().contains("/track/") {
let trackId = content.components(separatedBy: "/track/").last?.components(separatedBy: "?").first ?? ""
subtitle = String(format: "spotify_track".localized, trackId)
} else if content.lowercased().contains("/album/") {
let albumId = content.components(separatedBy: "/album/").last?.components(separatedBy: "?").first ?? ""
subtitle = String(format: "spotify_album".localized, albumId)
} else if content.lowercased().contains("/artist/") {
let artistId = content.components(separatedBy: "/artist/").last?.components(separatedBy: "?").first ?? ""
subtitle = String(format: "spotify_artist".localized, artistId)
} else if content.lowercased().contains("/playlist/") {
let playlistId = content.components(separatedBy: "/playlist/").last?.components(separatedBy: "?").first ?? ""
subtitle = String(format: "spotify_playlist".localized, playlistId)
}
}
return ParsedQRData(
type: .spotify,
title: "spotify".localized,
subtitle: subtitle,
icon: "music.note"
)
}
// MARK: - X (Twitter)
private static func parseTwitter(_ content: String) -> ParsedQRData {
var username = ""
if content.lowercased().hasPrefix("twitter://user?screen_name=") {
username = content.replacingOccurrences(of: "twitter://user?screen_name=", with: "", options: .caseInsensitive)
} else if content.lowercased().contains("x.com") || content.lowercased().contains("twitter.com") {
username = content.components(separatedBy: "/").dropLast().last ?? ""
}
return ParsedQRData(
type: .twitter,
title: "x".localized,
subtitle: String(format: "twitter_username".localized, username),
icon: "bird"
)
}
// MARK: - WhatsApp
private static func parseWhatsApp(_ content: String) -> ParsedQRData {
let phone = content.replacingOccurrences(of: "whatsapp://send?phone=", with: "", options: .caseInsensitive)
return ParsedQRData(
type: .whatsapp,
title: "whatsapp".localized,
subtitle: String(format: "whatsapp_phone_number".localized, phone),
icon: "message.circle"
)
}
// MARK: - Viber
private static func parseViber(_ content: String) -> ParsedQRData {
let phone = content.replacingOccurrences(of: "viber://add?number=", with: "", options: .caseInsensitive)
return ParsedQRData(
type: .viber,
title: "viber".localized,
subtitle: String(format: "viber_phone_number".localized, phone),
icon: "bubble.left.and.bubble.right"
)
}
// MARK: - Snapchat
private static func parseSnapchat(_ content: String) -> ParsedQRData {
let username = content.replacingOccurrences(of: "snapchat://", with: "", options: .caseInsensitive)
return ParsedQRData(
type: .snapchat,
title: "snapchat".localized,
subtitle: String(format: "snapchat_username".localized, username),
icon: "camera.viewfinder"
)
}
// MARK: - TikTok
private static func parseTikTok(_ content: String) -> ParsedQRData {
var username = ""
if content.lowercased().contains("www.tiktok.com") {
// https://www.tiktok.com/@username
if let atIndex = content.lastIndex(of: "@") {
username = String(content[content.index(after: atIndex)...])
}
} else if content.lowercased().contains("tiktok.com") {
// https://tiktok.com/@username
if let atIndex = content.lastIndex(of: "@") {
username = String(content[content.index(after: atIndex)...])
}
}
return ParsedQRData(
type: .tiktok,
title: "tiktok".localized,
subtitle: String(format: "tiktok_username".localized, username),
icon: "music.mic"
)
}
// MARK: - URL
private static func parseURL(_ content: String) -> ParsedQRData {
return ParsedQRData(
type: .url,
title: "url_link".localized,
subtitle: content,
icon: "link"
)
}
// MARK: - Location
private static func parseLocation(_ content: String) -> ParsedQRData {
let coordinates = content.replacingOccurrences(of: "geo:", with: "")
let coords = coordinates.components(separatedBy: ",")
let latitude = coords.first ?? ""
let longitude = coords.count > 1 ? coords[1] : ""
let title = "geolocation".localized
let subtitle = String(format: "geolocation_coordinates".localized, latitude, longitude)
return ParsedQRData(
type: .location,
title: title,
subtitle: subtitle,
icon: "location"
)
}
// MARK: -
/// vCard
static func parseContactInfoFromVCard(_ content: String) -> ContactInfo {
let lines = content.components(separatedBy: .newlines)
var contactInfo = ContactInfo()
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine.hasPrefix("FN:") {
contactInfo.name = String(trimmedLine.dropFirst(3))
} else if trimmedLine.hasPrefix("TEL") {
let telValue = String(trimmedLine.dropFirst(3))
if telValue.contains(":") {
let number = telValue.components(separatedBy: ":").last ?? ""
contactInfo.phoneNumber = number
}
} else if trimmedLine.hasPrefix("EMAIL") {
let emailValue = String(trimmedLine.dropFirst(5))
if emailValue.contains(":") {
let emailAddress = emailValue.components(separatedBy: ":").last ?? ""
contactInfo.email = emailAddress
}
} else if trimmedLine.hasPrefix("ORG:") {
contactInfo.organization = String(trimmedLine.dropFirst(4))
} else if trimmedLine.hasPrefix("TITLE:") {
contactInfo.title = String(trimmedLine.dropFirst(6))
} else if trimmedLine.hasPrefix("ADR") {
let adrValue = String(trimmedLine.dropFirst(3))
if adrValue.contains(":") {
let addressParts = adrValue.components(separatedBy: ":")
if addressParts.count > 1 {
let addressComponents = addressParts[1].components(separatedBy: ";")
if addressComponents.count >= 3 {
contactInfo.address = "\(addressComponents[2]) \(addressComponents[1])"
}
}
}
}
}
return contactInfo
}
/// MeCard
static func parseContactInfoFromMeCard(_ content: String) -> ContactInfo {
let mecardInfo = content.replacingOccurrences(of: "MECARD:", with: "")
let components = mecardInfo.components(separatedBy: ";")
var contactInfo = ContactInfo()
for component in components {
let trimmedComponent = component.trimmingCharacters(in: .whitespaces)
if trimmedComponent.isEmpty { continue }
if trimmedComponent.hasPrefix("N:") {
let nameValue = String(trimmedComponent.dropFirst(2))
let nameParts = nameValue.components(separatedBy: ",")
if nameParts.count >= 2 {
let lastName = nameParts[0]
let firstName = nameParts[1]
contactInfo.name = "\(firstName) \(lastName)"
} else if nameParts.count == 1 {
contactInfo.name = nameParts[0]
}
} else if trimmedComponent.hasPrefix("TEL:") {
contactInfo.phoneNumber = String(trimmedComponent.dropFirst(4))
} else if trimmedComponent.hasPrefix("EMAIL:") {
contactInfo.email = String(trimmedComponent.dropFirst(6))
} else if trimmedComponent.hasPrefix("ORG:") {
contactInfo.organization = String(trimmedComponent.dropFirst(4))
} else if trimmedComponent.hasPrefix("TITLE:") {
contactInfo.title = String(trimmedComponent.dropFirst(6))
} else if trimmedComponent.hasPrefix("ADR:") {
contactInfo.address = String(trimmedComponent.dropFirst(4))
}
}
return contactInfo
}
///
static func parseContactInfo(from content: String) -> ContactInfo? {
if content.hasPrefix("BEGIN:VCARD") {
let normalizedVCard = VCardConverter.normalizeVCard(content)
let contactInfo = parseContactInfoFromVCard(normalizedVCard)
return (contactInfo.name.isEmpty && contactInfo.phoneNumber.isEmpty) ? nil : contactInfo
} else if content.hasPrefix("MECARD:") {
let contactInfo = parseContactInfoFromMeCard(content)
return (contactInfo.name.isEmpty && contactInfo.phoneNumber.isEmpty) ? nil : contactInfo
}
return nil
}
// MARK: - URL
private static func isValidURL(_ string: String) -> Bool {
guard let url = URL(string: string) else { return false }
return UIApplication.shared.canOpenURL(url)
}
}