From e16954037b077d50febecfcc11dab6378ed3f2ab Mon Sep 17 00:00:00 2001 From: Olivier Dolbeau Date: Sat, 3 Oct 2026 20:04:27 +0200 Subject: [PATCH] fix: Read a VALUE parameter that lists several types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL;VALUE=uri,text:… (written by ez-vcard) made sabre/vobject 5.0 throw a TypeError, and the whole card was lost. The Parser now keeps the first value of a repeated or listed VALUE parameter, and reports it. Upstream fix: sabre-io/vobject#795. Removing this workaround: #5. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ src/VCard/Internal/Parser.php | 32 ++++++++++++++++++++++---------- tests/VCard/VCardDecoderTest.php | 22 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4453b1..2e67882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project are documented in this file. The format foll ## [Unreleased] +### Fixed + +- `VCardDecoder` no longer loses the whole card on a `VALUE` parameter listing several types + (`URL;VALUE=uri,text:…`, written by ez-vcard): it keeps the first one and reports it. + ## [0.2.0] - 2026-10-03 ### Added diff --git a/src/VCard/Internal/Parser.php b/src/VCard/Internal/Parser.php index e6dbd35..e99d232 100644 --- a/src/VCard/Internal/Parser.php +++ b/src/VCard/Internal/Parser.php @@ -12,9 +12,10 @@ * Reads vCard text leniently, one card at a time, with sabre/vobject. * * Before handing each card to sabre, it reports the lines sabre would silently drop, - * removes repeated VALUE parameters (sabre/vobject 5.0 crashes on them), and keeps the - * raw value of each property: sabre cannot tell escaped commas in N and ADR from list - * separators, and loses the escapes of unknown properties, which jCard keeps as is. + * keeps one value of a repeated or listed VALUE parameter (sabre/vobject 5.0 crashes on + * them), and keeps the raw value of each property: sabre cannot tell escaped commas in N + * and ADR from list separators, and loses the escapes of unknown properties, which jCard + * keeps as is. * * @internal */ @@ -72,13 +73,24 @@ private static function parseCard(array $lines): ParsedCard $parts = explode(';', $head); $name = strtoupper((string) preg_replace('/^.*\./', '', $parts[0])); - // sabre/vobject workaround: a repeated VALUE parameter makes sabre 5.0 throw a - // TypeError (Document::getClassNameForPropertyValue() gets an array). - $values = preg_grep('/^VALUE=/i', \array_slice($parts, 1)) ?: []; - if (\count($values) > 1) { - $issues[] = \sprintf('line %d repeats the VALUE parameter, kept the first one', $number); - $first = array_key_first($values); - $head = implode(';', array_filter($parts, static fn (string $part, int $index): bool => 0 === $index || $index === $first || !\in_array($index, array_keys($values), true), \ARRAY_FILTER_USE_BOTH)); + // sabre/vobject workaround: a VALUE parameter with several values, repeated + // (VALUE=text;VALUE=TEXT) or listed (VALUE=uri,text), makes sabre 5.0 throw a + // TypeError (Document::getClassNameForPropertyValue() gets an array). Fixed + // upstream by sabre-io/vobject#795. + $valueParts = array_filter(\array_slice($parts, 1, null, true), static fn (string $part): bool => 0 === stripos($part, 'VALUE=')); + $types = []; + foreach ($valueParts as $part) { + $type = substr($part, 6); + array_push($types, ...(str_starts_with($type, '"') ? [$type] : explode(',', $type))); + } + + $first = array_key_first($valueParts); + if (null !== $first && \count($types) > 1) { + $issues[] = \count($valueParts) > 1 + ? \sprintf('line %d repeats the VALUE parameter, kept the first one', $number) + : \sprintf('line %d lists several values in the VALUE parameter, kept the first one', $number); + $parts[$first] = substr($parts[$first], 0, 6).$types[0]; + $head = implode(';', array_diff_key($parts, \array_slice($valueParts, 1, null, true))); $line = $head.substr($line, $colon); } diff --git a/tests/VCard/VCardDecoderTest.php b/tests/VCard/VCardDecoderTest.php index 2c2ba41..898516c 100644 --- a/tests/VCard/VCardDecoderTest.php +++ b/tests/VCard/VCardDecoderTest.php @@ -14,6 +14,7 @@ use Rondeto\JSContact\Model\Directory; use Rondeto\JSContact\Model\EmailAddress; use Rondeto\JSContact\Model\LanguagePref; +use Rondeto\JSContact\Model\Link; use Rondeto\JSContact\Model\Media; use Rondeto\JSContact\Model\Nickname; use Rondeto\JSContact\Model\Organization; @@ -28,6 +29,7 @@ use Rondeto\JSContact\Model\VCardProperty; use Rondeto\JSContact\Validation\InvalidCardException; use Rondeto\JSContact\VCard\VCardDecoder; +use Rondeto\JSContact\VCard\VCardEncoder; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; @@ -316,6 +318,26 @@ public function testRepeatedValueParametersAreReported(): void self::assertSame(['/: line 3 repeats the VALUE parameter, kept the first one'], array_map(strval(...), $result->issues)); } + public function testListedValueParametersAreReported(): void + { + $results = new VCardDecoder()->decode("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nURL;TYPE=work;VALUE=uri,text:https://jane.example\r\nEND:VCARD\r\n"); + self::assertCount(1, $results); + $card = $results[0]->value; + + self::assertSame('Jane Doe', $card->name?->full); + self::assertEquals(new Link('https://jane.example', contexts: ['work']), $card->links['LINK-1'] ?? null); + self::assertSame(['/: line 4 lists several values in the VALUE parameter, kept the first one'], array_map(strval(...), $results[0]->issues)); + self::assertStringContainsString("\r\nURL;PROP-ID=LINK-1;TYPE=work:https://jane.example\r\n", new VCardEncoder()->encode($card)->value); + } + + public function testRepeatedAndListedValueParametersAreReported(): void + { + $result = $this->decode("URL;VALUE=uri,text;VALUE=text:https://jane.example\r\n"); + + self::assertSame('https://jane.example', $result->value->links['LINK-1']->uri ?? null); + self::assertSame(['/: line 3 repeats the VALUE parameter, kept the first one'], array_map(strval(...), $result->issues)); + } + public function testADerivedFullNameIsNotKept(): void { $card = $this->decode("N:Doe;Jane;;;\r\nFN;DERIVED=TRUE:Jane Doe\r\n")->value;