feat(contacts): P2 шаг1 — типы TEL/EMAIL (парсинг из raw, подписи в карточке, сохранение при правке) +4 теста
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user