From bc1a4970efead05b1dbc9132c6991394cd0a72e0 Mon Sep 17 00:00:00 2001 From: b-mob-contacts Date: Mon, 13 Jul 2026 07:34:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20=D1=8F=D0=B4=D1=80=D0=BE=20P1?= =?UTF-8?q?=20=E2=80=94=20updateContact/deleteContact=20(If-Match)=20+=20V?= =?UTF-8?q?CardEditor=20round-trip=20+=2010=20=D1=8E=D0=BD=D0=B8=D1=82-?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../f7cloud/core/network/CardDavClient.kt | 291 ++++++++++++++++-- .../f7cloud/core/network/VCardEditorTest.kt | 132 ++++++++ 3 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 578b873..6a24bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` +- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый ``; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт) - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt index 80bb238..8ac98ab 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt @@ -26,6 +26,35 @@ data class DavContact( val birthday: String = "", val emails: String = "", val phones: String = "", + // Ниже — для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard. + // Заполняются только чтением из книги (listContacts); create возвращает пустыми — ок. + val href: String = "", + val etag: String = "", + val raw: String = "", +) + +/** Типизированное значение (EMAIL/TEL) с меткой TYPE (HOME/WORK/CELL/…). */ +data class VCardTypedValue( + val value: String, + val type: String = "", +) + +/** + * Управляемые поля контакта, которые правит UI. Всё, чего здесь нет + * (PHOTO, X-*, UID, кастомные свойства), сохраняется из сырого vCard как есть. + */ +data class VCardFields( + val displayName: String, + val firstName: String = "", + val lastName: String = "", + val emails: List = emptyList(), + val phones: List = emptyList(), + val organization: String = "", + val title: String = "", + val address: String = "", + val website: String = "", + val birthday: String = "", + val note: String = "", ) object CardDavClient { @@ -150,6 +179,58 @@ object CardDavClient { ) } + /** + * Правка существующего контакта. Берёт СЫРОЙ vCard (round-trip: неизвестные свойства — + * PHOTO, X-*, кастомные — сохраняются), заменяет управляемые поля и делает PUT с If-Match + * по ETag (защита от гонки). Возвращает обновлённый DavContact с новым ETag. + * Сигнатуры list/create не трогаю — это отдельный метод. + */ + fun updateContact( + client: OkHttpClient, + href: String, + etag: String, + rawVCard: String, + fields: VCardFields, + bookName: String = "", + ): DavContact { + require(href.isNotBlank()) { "Пустой href контакта — правка невозможна" } + val newVCard = VCardEditor.applyEdits(rawVCard, fields) + val builder = Request.Builder() + .url(href) + .put(newVCard.toRequestBody("text/vcard; charset=utf-8".toMediaType())) + if (etag.isNotBlank()) builder.header("If-Match", etag) + val newEtag = client.newCall(builder.build()).execute().use { response -> + if (response.code == 412) { + error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите") + } + if (response.code !in 200..299 && response.code != 204) { + error("Не удалось сохранить контакт (HTTP ${response.code})") + } + response.header("ETag").orEmpty() + } + return davContactFromVCard(newVCard, bookName, href, newEtag.ifBlank { etag }) + ?: error("Не удалось разобрать сохранённый контакт") + } + + /** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */ + fun deleteContact( + client: OkHttpClient, + href: String, + etag: String, + ) { + require(href.isNotBlank()) { "Пустой href контакта — удаление невозможно" } + val builder = Request.Builder().url(href).delete() + if (etag.isNotBlank()) builder.header("If-Match", etag) + client.newCall(builder.build()).execute().use { response -> + if (response.code == 412) { + error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите") + } + if (response.code !in 200..299 && response.code != 204 && response.code != 404) { + error("Не удалось удалить контакт (HTTP ${response.code})") + } + } + } + private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String { val fn = displayName.trim() val parts = fn.split(Regex("\\s+"), limit = 2) @@ -199,7 +280,7 @@ object CardDavClient { """.trimIndent() val xml = propfind(client, url, depth = 1, body) - return parseContacts(xml, bookName).take(limit) + return parseContacts(xml, bookName, url).take(limit) } private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String { @@ -264,47 +345,35 @@ object CardDavClient { return out } - private fun parseContacts(xml: String, bookName: String): List { + private fun parseContacts(xml: String, bookName: String, bookUrl: String): List { val parser = newParser(xml) val out = mutableListOf() var inAddressData = false val data = StringBuilder() + var currentHref = "" + var currentEtag = "" while (parser.eventType != XmlPullParser.END_DOCUMENT) { when (parser.eventType) { - XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") { - inAddressData = true - data.clear() + XmlPullParser.START_TAG -> when (parser.localTag()) { + "response" -> { + currentHref = "" + currentEtag = "" + } + // href до address-data — это путь vCard в текущем + "href" -> if (!inAddressData && currentHref.isBlank()) { + currentHref = parser.readText().trim() + } + "getetag" -> currentEtag = parser.readText().trim() + "address-data" -> { + inAddressData = true + data.clear() + } } XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text) XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) { - val vcard = unfoldVCard(data.toString()) - val uid = extract(uidPattern, vcard).ifBlank { - vcard.hashCode().toString() - } - val name = resolveContactName(vcard) - val allEmails = extractAll(emailPattern, vcard) - val allPhones = extractAll(telPattern, vcard) - val email = allEmails.firstOrNull().orEmpty() - val phone = allPhones.firstOrNull().orEmpty() - val photo = parseContactPhoto(vcard) - if (name.isNotBlank() || email.isNotBlank()) { - out += DavContact( - uid = uid, - displayName = name, - email = email, - phone = phone, - bookName = bookName, - photoBase64 = photo?.base64.orEmpty(), - photoMimeType = photo?.mimeType.orEmpty(), - organization = formatOrganization(extract(orgPattern, vcard)), - title = extract(titlePattern, vcard), - address = formatAddress(extract(adrPattern, vcard)), - website = extract(urlPattern, vcard), - birthday = formatBirthday(extract(bdayPattern, vcard)), - emails = allEmails.joinToString("\n"), - phones = allPhones.joinToString("\n"), - ) - } + val fullHref = if (currentHref.isBlank()) "" else resolveHref(bookUrl, currentHref) + davContactFromVCard(data.toString(), bookName, fullHref, currentEtag) + ?.let { out += it } inAddressData = false } } @@ -313,6 +382,46 @@ object CardDavClient { return out } + /** + * Строит DavContact из сырого vCard. `raw` сохраняем как есть (для round-trip при правке), + * разбор — по развёрнутому (unfold) тексту. Общий для чтения книги и для updateContact. + */ + private fun davContactFromVCard( + rawVCard: String, + bookName: String, + href: String, + etag: String, + ): DavContact? { + val vcard = unfoldVCard(rawVCard) + val uid = extract(uidPattern, vcard).ifBlank { vcard.hashCode().toString() } + val name = resolveContactName(vcard) + val allEmails = extractAll(emailPattern, vcard) + val allPhones = extractAll(telPattern, vcard) + val email = allEmails.firstOrNull().orEmpty() + val phone = allPhones.firstOrNull().orEmpty() + val photo = parseContactPhoto(vcard) + if (name.isBlank() && email.isBlank()) return null + return DavContact( + uid = uid, + displayName = name, + email = email, + phone = phone, + bookName = bookName, + photoBase64 = photo?.base64.orEmpty(), + photoMimeType = photo?.mimeType.orEmpty(), + organization = formatOrganization(extract(orgPattern, vcard)), + title = extract(titlePattern, vcard), + address = formatAddress(extract(adrPattern, vcard)), + website = extract(urlPattern, vcard), + birthday = formatBirthday(extract(bdayPattern, vcard)), + emails = allEmails.joinToString("\n"), + phones = allPhones.joinToString("\n"), + href = href, + etag = etag, + raw = rawVCard, + ) + } + private fun unfoldVCard(raw: String): String { val normalized = raw.replace("\r\n", "\n").replace('\r', '\n') val lines = normalized.split('\n') @@ -484,3 +593,119 @@ object CardDavClient { } } } + +/** + * Чистый (без сети) редактор vCard 3.0 для round-trip правки. Управляемые свойства + * (FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE) заменяются значениями из [VCardFields]; + * все прочие (VERSION, UID, PHOTO, REV, X-* и кастомные) сохраняются из исходного vCard как есть. + * Тестируется юнитами: round-trip неизвестных полей, FN с запятыми, PHOTO base64, + * многострочный (folded) ADR, CRLF-фолдинг. + */ +internal object VCardEditor { + private const val CRLF = "\r\n" + private val MANAGED = setOf( + "FN", "N", "EMAIL", "TEL", "ORG", "TITLE", "ADR", "URL", "BDAY", "NOTE", + ) + + fun applyEdits(rawVCard: String, fields: VCardFields): String { + val logical = groupLogicalLines(rawVCard) + val preserved = logical.filter { line -> + val p = propertyName(line).uppercase() + p.isNotEmpty() && p != "BEGIN" && p != "END" && p !in MANAGED + } + val hasVersion = preserved.any { propertyName(it).equals("VERSION", ignoreCase = true) } + val sb = StringBuilder() + sb.append("BEGIN:VCARD").append(CRLF) + if (!hasVersion) sb.append("VERSION:3.0").append(CRLF) + preserved.forEach { appendFolded(sb, it) } + emitManaged(sb, fields) + sb.append("END:VCARD").append(CRLF) + return sb.toString() + } + + private fun emitManaged(sb: StringBuilder, f: VCardFields) { + val fn = f.displayName.trim() + if (fn.isNotEmpty()) appendFolded(sb, "FN:${escapeText(fn)}") + val last = f.lastName.trim() + val first = f.firstName.trim() + when { + last.isNotEmpty() || first.isNotEmpty() -> + appendFolded(sb, "N:${escapeComponent(last)};${escapeComponent(first)};;;") + fn.isNotEmpty() -> { + // N обязателен в vCard 3.0 — выводим из FN (первое слово — имя, остальное — фамилия) + val parts = fn.split(Regex("\\s+"), limit = 2) + val given = parts.getOrNull(0).orEmpty() + val family = parts.getOrNull(1).orEmpty() + appendFolded(sb, "N:${escapeComponent(family)};${escapeComponent(given)};;;") + } + } + f.emails.filter { it.value.isNotBlank() }.forEach { + appendFolded(sb, "EMAIL;TYPE=${it.type.ifBlank { "INTERNET" }}:${escapeText(it.value.trim())}") + } + f.phones.filter { it.value.isNotBlank() }.forEach { + appendFolded(sb, "TEL;TYPE=${it.type.ifBlank { "CELL" }}:${escapeText(it.value.trim())}") + } + if (f.organization.isNotBlank()) appendFolded(sb, "ORG:${escapeComponent(f.organization.trim())}") + if (f.title.isNotBlank()) appendFolded(sb, "TITLE:${escapeText(f.title.trim())}") + if (f.address.isNotBlank()) { + // плоский адрес → поле street структуры ADR: pobox;ext;street;city;region;zip;country + appendFolded(sb, "ADR;TYPE=HOME:;;${escapeComponent(f.address.trim())};;;;") + } + if (f.website.isNotBlank()) appendFolded(sb, "URL:${escapeText(f.website.trim())}") + if (f.birthday.isNotBlank()) appendFolded(sb, "BDAY:${f.birthday.trim()}") + if (f.note.isNotBlank()) appendFolded(sb, "NOTE:${escapeText(f.note.trim())}") + } + + /** Группирует физические строки в логические: продолжение начинается с ' ' или '\t'. */ + private fun groupLogicalLines(raw: String): List { + val normalized = raw.replace("\r\n", "\n").replace('\r', '\n') + val out = mutableListOf() + val cur = StringBuilder() + for (line in normalized.split('\n')) { + if (line.isEmpty()) continue + if ((line[0] == ' ' || line[0] == '\t') && cur.isNotEmpty()) { + cur.append(line.substring(1)) + } else { + if (cur.isNotEmpty()) out += cur.toString() + cur.setLength(0) + cur.append(line) + } + } + if (cur.isNotEmpty()) out += cur.toString() + return out + } + + private fun propertyName(logicalLine: String): String { + val end = logicalLine.indexOfFirst { it == ':' || it == ';' } + val head = if (end < 0) logicalLine else logicalLine.substring(0, end) + // свойство может иметь group-префикс "item1.EMAIL" — берём часть после точки + return head.substringAfterLast('.').trim() + } + + /** Фолдинг длинных строк по 75 символов с CRLF+пробел (RFC 2426 §2.6). */ + private fun appendFolded(sb: StringBuilder, line: String) { + if (line.length <= 75) { + sb.append(line).append(CRLF) + return + } + var i = 0 + var first = true + while (i < line.length) { + val take = if (first) 75 else 74 + val end = minOf(i + take, line.length) + if (!first) sb.append(' ') + sb.append(line, i, end).append(CRLF) + i = end + first = false + } + } + + // TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard + // экранируем только backslash и перевод строки (запятые в имени оставляем литералом). + private fun escapeText(v: String): String = + v.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "") + + // компонент структурного значения (N/ADR/ORG): дополнительно экранируем разделители ; и , + private fun escapeComponent(v: String): String = + escapeText(v).replace(";", "\\;").replace(",", "\\,") +} diff --git a/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt b/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt new file mode 100644 index 0000000..b78d482 --- /dev/null +++ b/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt @@ -0,0 +1,132 @@ +package ru.forbion.f7cloud.core.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Тесты round-trip редактора vCard (P1 правка контакта, mail/072). + * Ключевое: неизвестные свойства (PHOTO, X-*, UID) сохраняются, управляемые — заменяются. + * Краевые из письма лида: FN с запятыми, PHOTO base64, многострочный ADR, CRLF-фолдинг. + */ +class VCardEditorTest { + + private fun edit(raw: String, fields: VCardFields) = VCardEditor.applyEdits(raw, fields) + + private val base = VCardFields(displayName = "Иван Петров") + + @Test fun preserves_unknown_properties_and_uid_and_photo() { + val raw = buildString { + append("BEGIN:VCARD\r\n") + append("VERSION:3.0\r\n") + append("UID:abc-123\r\n") + append("FN:Старое Имя\r\n") + append("N:Имя;Старое;;;\r\n") + append("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD\r\n") + append("X-CUSTOM-FIELD:держим-как-есть\r\n") + append("TEL;TYPE=CELL:+70000000000\r\n") + append("END:VCARD\r\n") + } + val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK")))) + // UID и неизвестные свойства сохранены + assertTrue("UID сохранён", out.contains("UID:abc-123")) + assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD")) + assertTrue("X-CUSTOM сохранён", out.contains("X-CUSTOM-FIELD:держим-как-есть")) + // управляемые поля заменены на новые + assertTrue("новый FN", out.contains("FN:Иван Петров")) + assertFalse("старый FN убран", out.contains("Старое Имя")) + assertTrue("новый телефон", out.contains("TEL;TYPE=WORK:+79990001122")) + assertFalse("старый телефон убран", out.contains("+70000000000")) + // валидный каркас + assertTrue(out.startsWith("BEGIN:VCARD\r\n")) + assertTrue(out.trimEnd().endsWith("END:VCARD")) + assertEquals("одна VERSION", 1, Regex("(?m)^VERSION:").findAll(out).count()) + } + + @Test fun fn_with_commas_is_kept_literally() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:X\r\nEND:VCARD\r\n" + val out = edit(raw, VCardFields(displayName = "Петров, Иван, Иванович")) + assertTrue("запятые в FN сохранены литералом", out.contains("FN:Петров, Иван, Иванович")) + } + + @Test fun folded_multiline_adr_is_regrouped_and_replaced() { + // ADR разбит фолдингом на 2 физические строки (продолжение с пробела) + val raw = buildString { + append("BEGIN:VCARD\r\n") + append("VERSION:3.0\r\n") + append("UID:y\r\n") + append("FN:Кто-то\r\n") + append("ADR;TYPE=HOME:;;ул. Очень Длинная Дом 1 Корпус 2 Строение 3\r\n") + append(" Квартира 456 Подъезд 7;Город;;;\r\n") + append("END:VCARD\r\n") + } + val out = edit(raw, VCardFields(displayName = "Кто-то", address = "Новый Город, ул. Новая 5")) + assertTrue("новый адрес в ADR", out.contains("Новый Город")) + assertFalse("старый адрес убран", out.contains("Очень Длинная")) + // не осталось «висящей» строки-продолжения старого ADR + assertFalse(out.contains("Квартира 456")) + } + + @Test fun long_line_is_folded_at_75_with_crlf_space() { + val longNote = "Z".repeat(200) + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:N\r\nEND:VCARD\r\n" + val out = edit(raw, base.copy(note = longNote)) + // каждая физическая строка ≤ 76 символов (75 + возможный ведущий пробел) + val physical = out.split("\r\n").filter { it.isNotEmpty() } + assertTrue("все строки свёрнуты ≤76", physical.all { it.length <= 76 }) + // продолжения начинаются с пробела + assertTrue("есть свёрнутое продолжение", physical.any { it.startsWith(" Z") }) + // при развороте NOTE восстанавливается целиком + val unfolded = out.replace("\r\n ", "") + assertTrue(unfolded.contains("NOTE:$longNote")) + } + + @Test fun crlf_and_lf_inputs_both_parse() { + val lf = "BEGIN:VCARD\nVERSION:3.0\nUID:m\nFN:Старое\nX-KEEP:1\nEND:VCARD\n" + val out = edit(lf, base) + assertTrue(out.contains("X-KEEP:1")) + assertTrue(out.contains("FN:Иван Петров")) + // выход всегда CRLF + assertTrue(out.contains("\r\n")) + assertFalse("нет одиночных LF", out.replace("\r\n", "").contains("\n")) + } + + @Test fun n_derived_from_fn_when_name_parts_absent() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nEND:VCARD\r\n" + val out = edit(raw, VCardFields(displayName = "Иван Петров")) + // N: фамилия;имя;;; — из FN: given=Иван, family=Петров + assertTrue(out.contains("N:Петров;Иван;;;")) + } + + @Test fun explicit_name_parts_win_over_fn() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:e\r\nEND:VCARD\r\n" + val out = edit(raw, VCardFields(displayName = "Ivan P", firstName = "Ivan", lastName = "Petrov")) + assertTrue(out.contains("N:Petrov;Ivan;;;")) + } + + @Test fun multiple_emails_and_types_emitted() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:me\r\nFN:X\r\nEMAIL:old@x.ru\r\nEND:VCARD\r\n" + val out = edit( + raw, + base.copy( + emails = listOf( + VCardTypedValue("home@x.ru", "HOME"), + VCardTypedValue("work@x.ru", "WORK"), + ), + ), + ) + assertFalse(out.contains("old@x.ru")) + assertTrue(out.contains("EMAIL;TYPE=HOME:home@x.ru")) + assertTrue(out.contains("EMAIL;TYPE=WORK:work@x.ru")) + } + + @Test fun blank_managed_fields_are_omitted() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b\r\nFN:X\r\nEND:VCARD\r\n" + val out = edit(raw, VCardFields(displayName = "Только Имя")) + assertFalse("нет пустого EMAIL", out.contains("EMAIL")) + assertFalse("нет пустого TEL", out.contains("TEL")) + assertFalse("нет пустого ADR", out.contains("ADR")) + assertFalse("нет пустого ORG", out.contains("ORG")) + } +}