import Foundation /// Splits and cleans `.vcf` files the user already has. /// /// This is the path for files exported from somewhere else. A single combined /// `.vcf` holding hundreds of contacts is the failure mode that started all /// this — click-wheel firmware reads the first card and then stops, or stops at /// the first card it cannot parse — so every card becomes its own file. public enum VCardFileSanitizer { /// Properties carrying binary payloads the device's parser chokes on. static let strippedProperties: Set = ["PHOTO", "LOGO", "SOUND", "KEY"] public static func sanitize(contentsOf url: URL) throws -> FileSanitizationResult { sanitize(text: try TextFile.read(contentsOf: url)) } public static func sanitize(text: String) -> FileSanitizationResult { let lines = joiningSoftLineBreaks(ContentLine.unfold(text)) var cards: [[String]] = [] var current: [String] = [] var insideCard = false var unterminated = 0 for line in lines { let upper = line.uppercased() if upper.hasPrefix("BEGIN:VCARD") { if insideCard { unterminated += 1 } insideCard = true current = [line] } else if upper.hasPrefix("END:VCARD") { if insideCard { current.append(line) cards.append(current) } insideCard = false current = [] } else if insideCard { current.append(line) } } if insideCard { unterminated += 1 } var warnings: [String] = [] if cards.isEmpty { warnings.append("No vCards found — the file may not be a vCard export.") } if unterminated > 0 { warnings.append("\(unterminated) card(s) had no END:VCARD and were skipped.") } let files = cards.map { card -> SanitizedFile in let sanitized = sanitize(cardLines: card) var builder = ContentLineBuilder() for line in sanitized { builder.appendRawLine(line) } // The display name is read back from the *sanitized* lines, so a // quoted-printable name yields "José" rather than "Jos=C3=A9". return SanitizedFile( suggestedName: displayName(in: sanitized), contents: builder.render() ) } return FileSanitizationResult(files: files, warnings: warnings) } /// Applies the per-property filtering to one card's lines. static func sanitize(cardLines: [String]) -> [String] { var output: [String] = [] for line in cardLines { let upper = line.uppercased() if upper.hasPrefix("BEGIN:VCARD") || upper.hasPrefix("END:VCARD") { output.append(line) continue } guard var property = PropertyLine(line) else { continue } if strippedProperties.contains(property.name) { continue } if property.name.hasPrefix("X-") { continue } // Any remaining base64 payload is binary the device cannot use. if property.parameterValue(named: "ENCODING") .map({ ["B", "BASE64"].contains($0.uppercased()) }) == true { continue } if property.parameterValue(named: "ENCODING")?.uppercased() == "QUOTED-PRINTABLE" { property.value = QuotedPrintable.decode( property.value, charset: property.parameterValue(named: "CHARSET") ) property.removeParameters(named: ["ENCODING", "CHARSET"]) } output.append(property.rendered) } return output } /// vCard 2.1 continues a quoted-printable value on the next line when the /// current one ends in `=`. Unlike RFC folding the continuation is *not* /// indented, so `ContentLine.unfold` leaves it split and it is rejoined here. static func joiningSoftLineBreaks(_ lines: [String]) -> [String] { var output: [String] = [] var awaitingContinuation = false for line in lines { if awaitingContinuation, !output.isEmpty { output[output.count - 1] = String(output[output.count - 1].dropLast()) + line } else { output.append(line) } let last = output[output.count - 1] awaitingContinuation = last.hasSuffix("=") && last.uppercased().contains("QUOTED-PRINTABLE") } return output } /// Best available human name for the card, used as its filename. static func displayName(in lines: [String]) -> String { var structuredName: String? var organization: String? for line in lines { guard let property = PropertyLine(line) else { continue } switch property.name { case "FN" where !property.value.isEmpty: return PropertyLine.unescape(property.value) case "N" where structuredName == nil: // N is family;given;middle;prefix;suffix — display it given-first. let parts = property.value .components(separatedBy: ";") .map { PropertyLine.unescape($0).trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } if !parts.isEmpty { structuredName = parts.count >= 2 ? "\(parts[1]) \(parts[0])" : parts[0] } case "ORG" where organization == nil: organization = PropertyLine.unescape(property.value) .components(separatedBy: ";") .first? .trimmingCharacters(in: .whitespaces) default: break } } return structuredName ?? organization ?? "contact" } }