Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions src/VCard/Internal/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}

Expand Down
22 changes: 22 additions & 0 deletions tests/VCard/VCardDecoderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading