VCardParser.kt 135 lignes · 5077 octets
package fr.ebii.card2vcf.contact

object VCardParser {
    fun parse(text: String): List<ContactCard> {
        val unfolded = unfold(text)
        val cards = mutableListOf<ContactCard>()
        var buf = mutableListOf<String>()
        for (line in unfolded.lineSequence()) {
            val t = line.trimEnd()
            when {
                t.equals("BEGIN:VCARD", true) -> buf = mutableListOf()
                t.equals("END:VCARD", true) -> cards.add(parseOne(buf))
                else -> if (t.isNotEmpty()) buf.add(t)
            }
        }
        return cards
    }

    private fun unfold(text: String): String = buildString {
        for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) {
            if (line.startsWith(" ") || line.startsWith("\t")) {
                append(line.drop(1))
            } else {
                if (isNotEmpty()) append('\n')
                append(line)
            }
        }
    }

    private fun parseOne(lines: List<String>): ContactCard {
        var fullName: String? = null
        var firstName: String? = null
        var lastName: String? = null
        var company: String? = null
        var jobTitle: String? = null
        val phones = mutableListOf<String>()
        val emails = mutableListOf<String>()
        var website: String? = null
        var address: String? = null
        var note: String? = null

        for (line in lines) {
            val colon = line.indexOf(':')
            if (colon < 0) continue
            val nameAndParams = line.substring(0, colon)
            val rawValue = line.substring(colon + 1)
            val prop = propertyName(nameAndParams)
            if (prop.equals("PHOTO", true) || prop.equals("VERSION", true) || prop.equals("BEGIN", true)) {
                continue
            }
            val value = unescape(rawValue)
            when {
                prop.equals("FN", true) -> fullName = value.takeIf { it.isNotBlank() }
                prop.equals("N", true) -> {
                    val parts = splitUnescaped(rawValue)
                    lastName = unescape(parts.getOrElse(0) { "" }).takeIf { it.isNotBlank() }
                    firstName = unescape(parts.getOrElse(1) { "" }).takeIf { it.isNotBlank() }
                }
                prop.equals("ORG", true) -> company = value.takeIf { it.isNotBlank() }
                prop.equals("TITLE", true) -> jobTitle = value.takeIf { it.isNotBlank() }
                prop.equals("TEL", true) -> value.takeIf { it.isNotBlank() }?.let { phones.add(it) }
                prop.equals("EMAIL", true) -> value.takeIf { it.isNotBlank() }?.let { emails.add(it) }
                prop.equals("URL", true) -> website = value.takeIf { it.isNotBlank() }
                prop.equals("ADR", true) -> {
                    val parts = splitUnescaped(rawValue)
                        .map { unescape(it).trim() }
                        .filter { it.isNotEmpty() }
                    address = parts.joinToString(", ").takeIf { it.isNotBlank() }
                }
                prop.equals("NOTE", true) -> note = value.takeIf { it.isNotBlank() }
            }
        }

        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 fun propertyName(nameAndParams: String): String {
        val afterGroup = nameAndParams.substringAfterLast('.', nameAndParams)
        return afterGroup.substringBefore(';')
    }

    private fun unescape(value: String): String = buildString(value.length) {
        var i = 0
        while (i < value.length) {
            val c = value[i]
            if (c == '\\' && i + 1 < value.length) {
                when (val n = value[i + 1]) {
                    'n', 'N' -> append('\n')
                    'r', 'R' -> { /* skip CR */ }
                    ';', ',', '\\' -> append(n)
                    else -> append(n)
                }
                i += 2
            } else {
                append(c)
                i++
            }
        }
    }

    /** Split on unescaped ';' (vCard structured values). */
    private fun splitUnescaped(value: String): List<String> {
        val parts = mutableListOf<String>()
        val buf = StringBuilder()
        var i = 0
        while (i < value.length) {
            val c = value[i]
            if (c == '\\' && i + 1 < value.length) {
                buf.append(c).append(value[i + 1])
                i += 2
            } else if (c == ';') {
                parts.add(buf.toString())
                buf.clear()
                i++
            } else {
                buf.append(c)
                i++
            }
        }
        parts.add(buf.toString())
        return parts
    }
}