feat(contacts): P2 шаг1 — типы TEL/EMAIL (парсинг из raw, подписи в карточке, сохранение при правке) +4 теста

This commit is contained in:
b-mob-contacts
2026-07-13 08:57:34 +00:00
parent 559a35aeb3
commit a8741e982c
6 changed files with 151 additions and 26 deletions
@@ -212,6 +212,10 @@ object CardDavClient {
?: error("Не удалось разобрать сохранённый контакт")
}
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
VCardEditor.parseTypedValues(rawVCard, property)
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
fun deleteContact(
client: OkHttpClient,
@@ -700,6 +704,47 @@ internal object VCardEditor {
}
}
/**
* Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой
* токен TYPE (WORK/HOME/CELL/…). Порядок — как в vCard, пустые пропускаем. Для карточки и
* префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой.
*/
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> {
val prop = property.uppercase()
return groupLogicalLines(rawVCard)
.filter { propertyName(it).uppercase() == prop }
.mapNotNull { line ->
val colon = line.indexOf(':')
if (colon < 0) return@mapNotNull null
val value = unescapeText(line.substring(colon + 1).trim())
if (value.isBlank()) {
null
} else {
VCardTypedValue(value = value, type = extractType(line.substring(0, colon)))
}
}
}
private val typeParamPattern = Regex("(?i)TYPE=\"?([A-Za-z0-9,\\- ]+)\"?")
private fun extractType(header: String): String {
// vCard 3.0: TYPE=WORK / TYPE="WORK,VOICE"; vCard 2.1: ...;WORK;VOICE
val fromParam = typeParamPattern.find(header)?.groupValues?.get(1)
val raw = fromParam
?: header.substringAfter(';', "").split(';').firstOrNull {
it.isNotBlank() && !it.contains('=')
}
?: ""
// берём первый содержательный токен, игнорируя технические (INTERNET/VOICE/PREF)
val ignored = setOf("INTERNET", "VOICE", "PREF")
return raw.split(',').map { it.trim().uppercase() }
.firstOrNull { it.isNotBlank() && it !in ignored }
?: raw.split(',').firstOrNull()?.trim()?.uppercase().orEmpty()
}
private fun unescapeText(v: String): String =
v.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
private fun escapeText(v: String): String =
@@ -135,4 +135,50 @@ class VCardEditorTest {
assertFalse("нет пустого ADR", out.contains("ADR"))
assertFalse("нет пустого ORG", out.contains("ORG"))
}
// --- P2: разбор типов TEL/EMAIL ---
@Test fun parses_email_and_phone_types() {
val raw = buildString {
append("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:t\r\nFN:X\r\n")
append("EMAIL;TYPE=WORK:work@x.ru\r\n")
append("EMAIL;TYPE=HOME:home@x.ru\r\n")
append("TEL;TYPE=CELL:+7900\r\n")
append("TEL;TYPE=WORK:+7495\r\n")
append("END:VCARD\r\n")
}
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
assertEquals(listOf("work@x.ru", "home@x.ru"), emails.map { it.value })
assertEquals(listOf("WORK", "HOME"), emails.map { it.type })
val phones = VCardEditor.parseTypedValues(raw, "TEL")
assertEquals(listOf("CELL", "WORK"), phones.map { it.type })
}
@Test fun ignores_technical_type_tokens() {
// INTERNET/VOICE/PREF — технические, берём содержательный токен
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:i\r\nFN:X\r\n" +
"EMAIL;TYPE=INTERNET,HOME:h@x.ru\r\n" +
"TEL;TYPE=VOICE,CELL:+7900\r\n" +
"END:VCARD\r\n"
assertEquals("HOME", VCardEditor.parseTypedValues(raw, "EMAIL").single().type)
assertEquals("CELL", VCardEditor.parseTypedValues(raw, "TEL").single().type)
}
@Test fun typeless_value_yields_blank_type() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nFN:X\r\nEMAIL:plain@x.ru\r\nEND:VCARD\r\n"
val e = VCardEditor.parseTypedValues(raw, "EMAIL").single()
assertEquals("plain@x.ru", e.value)
assertEquals("", e.type)
}
@Test fun round_trip_preserves_parsed_types() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:r\r\nFN:X\r\n" +
"EMAIL;TYPE=WORK:w@x.ru\r\nTEL;TYPE=HOME:+7495\r\nEND:VCARD\r\n"
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
val phones = VCardEditor.parseTypedValues(raw, "TEL")
val out = VCardEditor.applyEdits(raw, VCardFields(displayName = "X", emails = emails, phones = phones))
// типы не потеряны при правке (чиним P1-каветат нормализации)
assertTrue(out.contains("EMAIL;TYPE=WORK:w@x.ru"))
assertTrue(out.contains("TEL;TYPE=HOME:+7495"))
}
}