VCardParser.swift 173 lignes · 5758 octets
import Foundation

/// vCard 3.0 / 4.0 import.
enum VCardParser {
    static func parse(_ text: String) -> [ContactCard] {
        let unfolded = unfold(text)
        var cards: [ContactCard] = []
        var buf: [String] = []
        for line in unfolded.components(separatedBy: "\n") {
            let t = trimEnd(line)
            if equalsIgnoreCase(t, "BEGIN:VCARD") {
                buf = []
            } else if equalsIgnoreCase(t, "END:VCARD") {
                cards.append(parseOne(buf))
            } else if !t.isEmpty {
                buf.append(t)
            }
        }
        return cards
    }

    private static func equalsIgnoreCase(_ a: String, _ b: String) -> Bool {
        a.compare(b, options: .caseInsensitive) == .orderedSame
    }

    /// Kotlin `trimEnd()` — strips trailing whitespace.
    private static func trimEnd(_ s: String) -> String {
        var t = s
        while let last = t.last, last.isWhitespace { t.removeLast() }
        return t
    }

    private static func unfold(_ text: String) -> String {
        var out = ""
        let normalized = text
            .replacingOccurrences(of: "\r\n", with: "\n")
            .replacingOccurrences(of: "\r", with: "\n")
        for line in normalized.components(separatedBy: "\n") {
            if line.hasPrefix(" ") || line.hasPrefix("\t") {
                out += line.dropFirst()
            } else {
                if !out.isEmpty { out += "\n" }
                out += line
            }
        }
        return out
    }

    private static func parseOne(_ lines: [String]) -> ContactCard {
        var fullName: String? = nil
        var firstName: String? = nil
        var lastName: String? = nil
        var company: String? = nil
        var jobTitle: String? = nil
        var phones: [String] = []
        var emails: [String] = []
        var website: String? = nil
        var address: String? = nil
        var note: String? = nil

        for line in lines {
            guard let colon = line.firstIndex(of: ":") else { continue }
            let nameAndParams = String(line[..<colon])
            let rawValue = String(line[line.index(after: colon)...])
            let prop = propertyName(nameAndParams).uppercased()
            if prop == "PHOTO" || prop == "VERSION" || prop == "BEGIN" {
                continue
            }
            let value = unescape(rawValue)
            switch prop {
            case "FN":
                fullName = value.nilIfBlank
            case "N":
                let parts = splitUnescaped(rawValue)
                lastName = unescape(parts.count > 0 ? parts[0] : "").nilIfBlank
                firstName = unescape(parts.count > 1 ? parts[1] : "").nilIfBlank
            case "ORG":
                company = value.nilIfBlank
            case "TITLE":
                jobTitle = value.nilIfBlank
            case "TEL":
                if let v = value.nilIfBlank { phones.append(v) }
            case "EMAIL":
                if let v = value.nilIfBlank { emails.append(v) }
            case "URL":
                website = value.nilIfBlank
            case "ADR":
                let parts = splitUnescaped(rawValue)
                    .map { unescape($0).trimmingCharacters(in: .whitespacesAndNewlines) }
                    .filter { !$0.isEmpty }
                address = parts.joined(separator: ", ").nilIfBlank
            case "NOTE":
                note = value.nilIfBlank
            default:
                break
            }
        }

        return ContactCard(
            fullName: fullName,
            firstName: firstName,
            lastName: lastName,
            company: company,
            jobTitle: jobTitle,
            phones: phones,
            emails: emails,
            website: website,
            address: address,
            note: note
        )
    }

    /// Property name before ':' : strip group prefix (item1.), take part before first ';'.
    private static func propertyName(_ nameAndParams: String) -> String {
        let afterGroup: String
        if let dot = nameAndParams.lastIndex(of: ".") {
            afterGroup = String(nameAndParams[nameAndParams.index(after: dot)...])
        } else {
            afterGroup = nameAndParams
        }
        return String(afterGroup.prefix(while: { $0 != ";" }))
    }

    private static func unescape(_ value: String) -> String {
        var out = ""
        let chars = Array(value)
        var i = 0
        while i < chars.count {
            let c = chars[i]
            if c == "\\" && i + 1 < chars.count {
                let n = chars[i + 1]
                switch n {
                case "n", "N":
                    out.append("\n")
                case "r", "R":
                    break // skip CR
                default:
                    out.append(n) // covers ';' ',' '\\' and any other escaped char
                }
                i += 2
            } else {
                out.append(c)
                i += 1
            }
        }
        return out
    }

    /// Split on unescaped ';' (vCard structured values).
    private static func splitUnescaped(_ value: String) -> [String] {
        var parts: [String] = []
        var buf = ""
        let chars = Array(value)
        var i = 0
        while i < chars.count {
            let c = chars[i]
            if c == "\\" && i + 1 < chars.count {
                buf.append(c)
                buf.append(chars[i + 1])
                i += 2
            } else if c == ";" {
                parts.append(buf)
                buf = ""
                i += 1
            } else {
                buf.append(c)
                i += 1
            }
        }
        parts.append(buf)
        return parts
    }
}