feat(contact): VCardParser lecture vCard 3.0 et 4.0
EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:31
ec770cc6d54843804f3753ff9223ca9e642a6181
1 parent(s)
4 fichiers modifiés
+240
-0
A
android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt
+135
-0
@@ -0,0 +1,135 @@
| 1 | +package fr.ebii.card2vcf.contact | |
| 2 | + | |
| 3 | +object VCardParser { | |
| 4 | + fun parse(text: String): List<ContactCard> { | |
| 5 | + val unfolded = unfold(text) | |
| 6 | + val cards = mutableListOf<ContactCard>() | |
| 7 | + var buf = mutableListOf<String>() | |
| 8 | + for (line in unfolded.lineSequence()) { | |
| 9 | + val t = line.trimEnd() | |
| 10 | + when { | |
| 11 | + t.equals("BEGIN:VCARD", true) -> buf = mutableListOf() | |
| 12 | + t.equals("END:VCARD", true) -> cards.add(parseOne(buf)) | |
| 13 | + else -> if (t.isNotEmpty()) buf.add(t) | |
| 14 | + } | |
| 15 | + } | |
| 16 | + return cards | |
| 17 | + } | |
| 18 | + | |
| 19 | + private fun unfold(text: String): String = buildString { | |
| 20 | + for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) { | |
| 21 | + if (line.startsWith(" ") || line.startsWith("\t")) { | |
| 22 | + append(line.drop(1)) | |
| 23 | + } else { | |
| 24 | + if (isNotEmpty()) append('\n') | |
| 25 | + append(line) | |
| 26 | + } | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + private fun parseOne(lines: List<String>): ContactCard { | |
| 31 | + var fullName: String? = null | |
| 32 | + var firstName: String? = null | |
| 33 | + var lastName: String? = null | |
| 34 | + var company: String? = null | |
| 35 | + var jobTitle: String? = null | |
| 36 | + val phones = mutableListOf<String>() | |
| 37 | + val emails = mutableListOf<String>() | |
| 38 | + var website: String? = null | |
| 39 | + var address: String? = null | |
| 40 | + var note: String? = null | |
| 41 | + | |
| 42 | + for (line in lines) { | |
| 43 | + val colon = line.indexOf(':') | |
| 44 | + if (colon < 0) continue | |
| 45 | + val nameAndParams = line.substring(0, colon) | |
| 46 | + val rawValue = line.substring(colon + 1) | |
| 47 | + val prop = propertyName(nameAndParams) | |
| 48 | + if (prop.equals("PHOTO", true) || prop.equals("VERSION", true) || prop.equals("BEGIN", true)) { | |
| 49 | + continue | |
| 50 | + } | |
| 51 | + val value = unescape(rawValue) | |
| 52 | + when { | |
| 53 | + prop.equals("FN", true) -> fullName = value.takeIf { it.isNotBlank() } | |
| 54 | + prop.equals("N", true) -> { | |
| 55 | + val parts = splitUnescaped(rawValue) | |
| 56 | + lastName = unescape(parts.getOrElse(0) { "" }).takeIf { it.isNotBlank() } | |
| 57 | + firstName = unescape(parts.getOrElse(1) { "" }).takeIf { it.isNotBlank() } | |
| 58 | + } | |
| 59 | + prop.equals("ORG", true) -> company = value.takeIf { it.isNotBlank() } | |
| 60 | + prop.equals("TITLE", true) -> jobTitle = value.takeIf { it.isNotBlank() } | |
| 61 | + prop.equals("TEL", true) -> value.takeIf { it.isNotBlank() }?.let { phones.add(it) } | |
| 62 | + prop.equals("EMAIL", true) -> value.takeIf { it.isNotBlank() }?.let { emails.add(it) } | |
| 63 | + prop.equals("URL", true) -> website = value.takeIf { it.isNotBlank() } | |
| 64 | + prop.equals("ADR", true) -> { | |
| 65 | + val parts = splitUnescaped(rawValue) | |
| 66 | + .map { unescape(it).trim() } | |
| 67 | + .filter { it.isNotEmpty() } | |
| 68 | + address = parts.joinToString(", ").takeIf { it.isNotBlank() } | |
| 69 | + } | |
| 70 | + prop.equals("NOTE", true) -> note = value.takeIf { it.isNotBlank() } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + return ContactCard( | |
| 75 | + fullName = fullName, | |
| 76 | + firstName = firstName, | |
| 77 | + lastName = lastName, | |
| 78 | + company = company, | |
| 79 | + jobTitle = jobTitle, | |
| 80 | + phones = phones, | |
| 81 | + emails = emails, | |
| 82 | + website = website, | |
| 83 | + address = address, | |
| 84 | + note = note, | |
| 85 | + ) | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** Property name before ':' : strip group prefix (item1.), take part before first ';'. */ | |
| 89 | + private fun propertyName(nameAndParams: String): String { | |
| 90 | + val afterGroup = nameAndParams.substringAfterLast('.', nameAndParams) | |
| 91 | + return afterGroup.substringBefore(';') | |
| 92 | + } | |
| 93 | + | |
| 94 | + private fun unescape(value: String): String = buildString(value.length) { | |
| 95 | + var i = 0 | |
| 96 | + while (i < value.length) { | |
| 97 | + val c = value[i] | |
| 98 | + if (c == '\\' && i + 1 < value.length) { | |
| 99 | + when (val n = value[i + 1]) { | |
| 100 | + 'n', 'N' -> append('\n') | |
| 101 | + 'r', 'R' -> { /* skip CR */ } | |
| 102 | + ';', ',', '\\' -> append(n) | |
| 103 | + else -> append(n) | |
| 104 | + } | |
| 105 | + i += 2 | |
| 106 | + } else { | |
| 107 | + append(c) | |
| 108 | + i++ | |
| 109 | + } | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** Split on unescaped ';' (vCard structured values). */ | |
| 114 | + private fun splitUnescaped(value: String): List<String> { | |
| 115 | + val parts = mutableListOf<String>() | |
| 116 | + val buf = StringBuilder() | |
| 117 | + var i = 0 | |
| 118 | + while (i < value.length) { | |
| 119 | + val c = value[i] | |
| 120 | + if (c == '\\' && i + 1 < value.length) { | |
| 121 | + buf.append(c).append(value[i + 1]) | |
| 122 | + i += 2 | |
| 123 | + } else if (c == ';') { | |
| 124 | + parts.add(buf.toString()) | |
| 125 | + buf.clear() | |
| 126 | + i++ | |
| 127 | + } else { | |
| 128 | + buf.append(c) | |
| 129 | + i++ | |
| 130 | + } | |
| 131 | + } | |
| 132 | + parts.add(buf.toString()) | |
| 133 | + return parts | |
| 134 | + } | |
| 135 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt
+84
-0
@@ -0,0 +1,84 @@
| 1 | +package fr.ebii.card2vcf.contact | |
| 2 | + | |
| 3 | +import kotlin.test.Test | |
| 4 | +import kotlin.test.assertEquals | |
| 5 | +import kotlin.test.assertTrue | |
| 6 | + | |
| 7 | +class VCardParserTest { | |
| 8 | + | |
| 9 | + private fun loadFixture(name: String): String = | |
| 10 | + checkNotNull(javaClass.classLoader!!.getResourceAsStream("vcf/$name")) | |
| 11 | + .bufferedReader() | |
| 12 | + .use { it.readText() } | |
| 13 | + | |
| 14 | + @Test | |
| 15 | + fun parseSample3() { | |
| 16 | + val cards = VCardParser.parse(loadFixture("sample3.vcf")) | |
| 17 | + assertEquals(2, cards.size) | |
| 18 | + | |
| 19 | + val alice = cards[0] | |
| 20 | + assertEquals("Alice Martin", alice.fullName) | |
| 21 | + assertEquals("Alice", alice.firstName) | |
| 22 | + assertEquals("Martin", alice.lastName) | |
| 23 | + assertEquals("Acme", alice.company) | |
| 24 | + assertEquals(listOf("alice@acme.test"), alice.emails) | |
| 25 | + assertEquals(listOf("+33601020304"), alice.phones) | |
| 26 | + assertEquals("salon Lyon", alice.note) | |
| 27 | + | |
| 28 | + val bob = cards[1] | |
| 29 | + assertEquals("Bob", bob.fullName) | |
| 30 | + assertEquals(listOf("bob@unique.test"), bob.emails) | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + fun parseSample4() { | |
| 35 | + val cards = VCardParser.parse(loadFixture("sample4.vcf")) | |
| 36 | + assertEquals(1, cards.size) | |
| 37 | + val alice = cards[0] | |
| 38 | + assertEquals("Alice V4", alice.fullName) | |
| 39 | + assertEquals(listOf("alice@acme.test"), alice.emails) | |
| 40 | + assertEquals(listOf("+33601020304"), alice.phones) | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + fun unfoldFoldedLine() { | |
| 45 | + // Leading space/tab of continuation is the fold marker (dropped); extra space stays in value. | |
| 46 | + val folded = "BEGIN:VCARD\n" + | |
| 47 | + "VERSION:3.0\n" + | |
| 48 | + "FN:Alice\n" + | |
| 49 | + " Martin\n" + | |
| 50 | + "NOTE:hello\n" + | |
| 51 | + "\t world\n" + | |
| 52 | + "END:VCARD" | |
| 53 | + val cards = VCardParser.parse(folded) | |
| 54 | + assertEquals(1, cards.size) | |
| 55 | + assertEquals("Alice Martin", cards[0].fullName) | |
| 56 | + assertEquals("hello world", cards[0].note) | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + fun ignorePhoto() { | |
| 61 | + val text = """ | |
| 62 | + BEGIN:VCARD | |
| 63 | + VERSION:3.0 | |
| 64 | + FN:Photo Person | |
| 65 | + PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ | |
| 66 | + EMAIL:photo@test.example | |
| 67 | + END:VCARD | |
| 68 | + """.trimIndent() | |
| 69 | + val cards = VCardParser.parse(text) | |
| 70 | + assertEquals(1, cards.size) | |
| 71 | + assertEquals("Photo Person", cards[0].fullName) | |
| 72 | + assertEquals(listOf("photo@test.example"), cards[0].emails) | |
| 73 | + assertTrue(cards[0].note == null) | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + fun exportRoundtripVersion30() { | |
| 78 | + val cards = VCardParser.parse(loadFixture("sample3.vcf")) | |
| 79 | + val vcf = VCardSerializer.toVCard3(cards[0]) | |
| 80 | + assertTrue(vcf.contains("VERSION:3.0")) | |
| 81 | + assertTrue(vcf.contains("FN:Alice Martin")) | |
| 82 | + assertTrue(vcf.contains("ORG:Acme")) | |
| 83 | + } | |
| 84 | +} |
A
android/app/src/test/resources/vcf/sample3.vcf
+14
-0
@@ -0,0 +1,14 @@
| 1 | +BEGIN:VCARD | |
| 2 | +VERSION:3.0 | |
| 3 | +FN:Alice Martin | |
| 4 | +N:Martin;Alice;;; | |
| 5 | +ORG:Acme | |
| 6 | +EMAIL;TYPE=INTERNET:alice@acme.test | |
| 7 | +TEL;TYPE=CELL:+33601020304 | |
| 8 | +NOTE:salon Lyon | |
| 9 | +END:VCARD | |
| 10 | +BEGIN:VCARD | |
| 11 | +VERSION:3.0 | |
| 12 | +FN:Bob | |
| 13 | +EMAIL:bob@unique.test | |
| 14 | +END:VCARD |
A
android/app/src/test/resources/vcf/sample4.vcf
+7
-0
@@ -0,0 +1,7 @@
| 1 | +BEGIN:VCARD | |
| 2 | +VERSION:4.0 | |
| 3 | +FN:Alice V4 | |
| 4 | +N:V4;Alice;;; | |
| 5 | +EMAIL;TYPE=work:alice@acme.test | |
| 6 | +TEL;TYPE=cell:+33601020304 | |
| 7 | +END:VCARD |
GitRust