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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project are documented in this file. The format foll
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/): until `1.0`, a minor release may break the public API.

## [Unreleased]

### Added

- `Target`: the vCard version and dialects `VCardEncoder` writes for, with `Target::apple()` and
`Target::android()` ready-made.
- `Dialects::all()`: every dialect, to read vCards from an unknown address book.

### Changed

- **Breaking**: `VCardEncoder::encode()` and `convert()` take a `Target` instead of a `VCardVersion`, and the
`VCardEncoder` constructor no longer takes dialects. Dialects write in the order given, no longer in
reverse.

## [0.1.0] - 2026-10-03

First release.
Expand Down
30 changes: 20 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ $json = (new JsonEncoder())->encode(new Card(

```php
use Rondeto\JSContact\VCard\VCardDecoder;
use Rondeto\JSContact\VCard\Target;
use Rondeto\JSContact\VCard\VCardEncoder;
use Rondeto\JSContact\VCard\VCardVersion;

Expand All @@ -141,7 +142,7 @@ foreach ((new VCardDecoder())->decode(file_get_contents('contacts.vcf')) as $res
}

// Writes vCard 4.0 by default.
$result = (new VCardEncoder())->encode($card, VCardVersion::V30);
$result = (new VCardEncoder())->encode($card, new Target(VCardVersion::V30));
file_put_contents('contact.vcf', $result->value);
```

Expand Down Expand Up @@ -178,16 +179,20 @@ example, writes a spouse as `X-ABRELATEDNAMES` with the label `_$!<Spouse>!$_`,
A `Dialect` teaches the converter one family of these properties: it rewrites them as the standard
properties they mean before reading, and back after writing. Dialects are opt-in.

To read, pass the dialects to the decoder. The address book a vCard comes from is seldom known, and each
dialect reads only its own properties: `Dialects::all()` reads them all.

To write, the address book the vCard is for is known: pass a `Target` to `encode()`, the vCard version and
the dialects that address book reads. `Target::apple()` and `Target::android()` are ready-made.

```php
use Rondeto\JSContact\VCard\Dialect\Apple;
use Rondeto\JSContact\VCard\Dialect\LegacyMessaging;
use Rondeto\JSContact\VCard\Dialect\Dialects;
use Rondeto\JSContact\VCard\Target;
use Rondeto\JSContact\VCard\VCardDecoder;
use Rondeto\JSContact\VCard\VCardEncoder;

$dialects = [new Apple(), new LegacyMessaging()];

$results = (new VCardDecoder(dialects: $dialects))->decode($vCards); // the spouse is now in relatedTo
$result = (new VCardEncoder(dialects: $dialects))->encode($card); // and back to X-ABRELATEDNAMES
$results = (new VCardDecoder(dialects: Dialects::all()))->decode($vCards); // the spouse is now in relatedTo
$result = (new VCardEncoder())->encode($card, Target::apple()); // and back to X-ABRELATEDNAMES
```

| Dialect | For |
Expand All @@ -196,9 +201,14 @@ $result = (new VCardEncoder(dialects: $dialects))->encode($card); // and back
| `Android` | The Android contacts app: relations, anniversaries and nicknames in `X-ANDROID-CUSTOM`, dates without a year in vCard 3.0 |
| `LegacyMessaging` | Instant messaging properties from before vCard had `IMPP`: `X-AIM`, `X-ICQ`, `X-JABBER`, `X-SKYPE`… |

To read, pass every dialect the vCards may use. To write, pass those of the address book the vCard is for,
since Apple and Android write relations and dates differently. Pass the same list to the decoder and the
encoder: the encoder applies it in reverse order, so that it works both ways.
| Target | Writes |
|---------------------|----------------------------------------------------------------------------------------------------------|
| `Target::apple()` | vCard 3.0 with `LegacyMessaging` and `Apple`, for Apple Contacts and iCloud, and Google Contacts |
| `Target::android()` | vCard 3.0 with `LegacyMessaging` and `Android`: Android exports vCard 2.1, which the encoder does not write |

For any other combination, build one: `new Target(VCardVersion::V30, [new LegacyMessaging()])`. Dialects
write in the order given, each rewriting what the previous ones left: `LegacyMessaging` comes before `Apple`,
or Apple's `X-SOCIALPROFILE` would take the AIM user names that address books expect as `X-AIM`.

### Localizations

Expand Down
22 changes: 22 additions & 0 deletions src/VCard/Dialect/Dialects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Rondeto\JSContact\VCard\Dialect;

/**
* The dialects of this library.
*/
final class Dialects
{
/**
* Every dialect, for VCardDecoder: each reads only its own vendor properties, so reading
* with all of them is safe when the address book a vCard comes from is unknown.
*
* @return list<Dialect>
*/
public static function all(): array
{
return [new LegacyMessaging(), new Apple(), new Android()];
}
}
48 changes: 48 additions & 0 deletions src/VCard/Target.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Rondeto\JSContact\VCard;

use Rondeto\JSContact\VCard\Dialect\Android;
use Rondeto\JSContact\VCard\Dialect\Apple;
use Rondeto\JSContact\VCard\Dialect\Dialect;
use Rondeto\JSContact\VCard\Dialect\LegacyMessaging;

/**
* What VCardEncoder writes for: a vCard version, and the dialects of the address book the
* vCard is for.
*
* Dialects write in the order given, each rewriting what the previous ones left: with
* LegacyMessaging before Apple, an AIM user name is written as X-AIM, not as Apple's
* X-SOCIALPROFILE.
*/
final readonly class Target
{
/**
* @param list<Dialect> $dialects
*/
public function __construct(
public VCardVersion $version = VCardVersion::V40,
public array $dialects = [],
) {
}

/**
* Apple Contacts and iCloud, which import vCard 3.0. Also fits Google Contacts, which
* exports the same properties.
*/
public static function apple(): self
{
return new self(VCardVersion::V30, [new LegacyMessaging(), new Apple()]);
}

/**
* The Android contacts app. It exports vCard 2.1, which VCardEncoder does not write:
* vCard 3.0 is the closest.
*/
public static function android(): self
{
return new self(VCardVersion::V30, [new LegacyMessaging(), new Android()]);
}
}
3 changes: 2 additions & 1 deletion src/VCard/VCardDecoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* With strict: true, any issue makes the conversion fail instead.
*
* Dialects first rewrite the vendor properties they know as the RFC properties they mean,
* in the order given.
* in the order given. As the address book a vCard comes from is seldom known, pass
* Dialects::all() to read them all.
*/
final readonly class VCardDecoder
{
Expand Down
22 changes: 8 additions & 14 deletions src/VCard/VCardEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Rondeto\JSContact\Model\Card;
use Rondeto\JSContact\Validation\CardValidator;
use Rondeto\JSContact\Validation\InvalidCardException;
use Rondeto\JSContact\VCard\Dialect\Dialect;
use Rondeto\JSContact\VCard\Internal\Exporter;
use Sabre\VObject\Component\VCard;

Expand All @@ -22,19 +21,14 @@
* Writing is strict by default: a Card that breaks the JSContact specification is refused.
* Pass validate: false to write it anyway.
*
* Dialects then rewrite RFC properties as the vendor properties they stand for, in the
* reverse order of the list: the one VCardDecoder applies first applies last, so that the
* same list works both ways.
* The Target gives the vCard version, and the dialects that then rewrite RFC properties as
* the vendor properties they stand for.
*/
final readonly class VCardEncoder
{
/**
* @param list<Dialect> $dialects
*/
public function __construct(
private bool $validate = true,
private CardValidator $validator = new CardValidator(),
private array $dialects = [],
) {
}

Expand All @@ -43,9 +37,9 @@ public function __construct(
*
* @throws InvalidCardException when validation is on and the Card is invalid
*/
public function encode(Card $card, VCardVersion $version = VCardVersion::V40): Result
public function encode(Card $card, Target $target = new Target()): Result
{
$result = $this->convert($card, $version);
$result = $this->convert($card, $target);

return new Result($result->value->serialize(), $result->issues);
}
Expand All @@ -55,21 +49,21 @@ public function encode(Card $card, VCardVersion $version = VCardVersion::V40): R
*
* @throws InvalidCardException when validation is on and the Card is invalid
*/
public function convert(Card $card, VCardVersion $version = VCardVersion::V40): Result
public function convert(Card $card, Target $target = new Target()): Result
{
$issues = $this->validate ? $this->validator->validate($card) : [];
if ([] !== $issues) {
throw new InvalidCardException($issues);
}

$exporter = new Exporter($version);
$exporter = new Exporter($target->version);
$result = $exporter->export($card);
if ([] === $this->dialects) {
if ([] === $target->dialects) {
return $result;
}

$dialectIssues = [];
foreach (array_reverse($this->dialects) as $dialect) {
foreach ($target->dialects as $dialect) {
array_push($dialectIssues, ...$dialect->write($result->value));
}

Expand Down
22 changes: 10 additions & 12 deletions tests/VCard/Dialect/AndroidTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
use Rondeto\JSContact\Model\PartialDate;
use Rondeto\JSContact\Model\Relation;
use Rondeto\JSContact\VCard\Dialect\Android;
use Rondeto\JSContact\VCard\Dialect\Apple;
use Rondeto\JSContact\VCard\Dialect\LegacyMessaging;
use Rondeto\JSContact\VCard\Dialect\Dialects;
use Rondeto\JSContact\VCard\Target;
use Rondeto\JSContact\VCard\VCardDecoder;
use Rondeto\JSContact\VCard\VCardEncoder;
use Rondeto\JSContact\VCard\VCardVersion;
Expand Down Expand Up @@ -61,7 +61,7 @@ public function testACardIsWrittenAsAndroidReadsIt(): void
relatedTo: ['Jane' => new Relation(['spouse']), 'Ann' => new Relation(['co-worker']), 'Bob' => new Relation([])],
);

$result = new VCardEncoder(dialects: [new Android()])->encode($card, VCardVersion::V30);
$result = new VCardEncoder()->encode($card, new Target(VCardVersion::V30, [new Android()]));

self::assertSame([], array_map(strval(...), $result->issues));
foreach ([
Expand All @@ -83,30 +83,28 @@ public function testACardIsWrittenAsAndroidReadsIt(): void
}

/**
* @return iterable<string, array{string, list<\Rondeto\JSContact\VCard\Dialect\Dialect>}>
* @return iterable<string, array{string, Target}>
*/
public static function exports(): iterable
{
foreach (['android.vcf', 'android-full.vcf', 'android-quotedprintable.vcf'] as $file) {
yield $file => [$file, [new Android(), new LegacyMessaging()]];
yield $file => [$file, Target::android()];
}

foreach (['apple.vcf', 'google-full.vcf', 'ios-full.vcf'] as $file) {
yield $file => [$file, [new Apple(), new LegacyMessaging()]];
yield $file => [$file, Target::apple()];
}
}

/**
* Reading with every dialect, writing with those of the address book the vCard is for.
*
* @param list<\Rondeto\JSContact\VCard\Dialect\Dialect> $dialects
* Reading with every dialect, writing for the address book the vCard comes from.
*/
#[DataProvider('exports')]
public function testExportsSurviveARoundTripThroughTheirDialects(string $file, array $dialects): void
public function testExportsSurviveARoundTripThroughTheirDialects(string $file, Target $target): void
{
$decoder = new VCardDecoder(dialects: [new Apple(), new Android(), new LegacyMessaging()]);
$decoder = new VCardDecoder(dialects: Dialects::all());
foreach ($decoder->decode((string) file_get_contents(__DIR__.'/../../Fixtures/CozyVcard/'.$file)) as $result) {
$vCard = new VCardEncoder(validate: false, dialects: $dialects)->encode($result->value, VCardVersion::V30)->value;
$vCard = new VCardEncoder(validate: false)->encode($result->value, $target)->value;

self::assertEquals($result->value, $decoder->decode($vCard)[0]->value ?? null);
}
Expand Down
17 changes: 9 additions & 8 deletions tests/VCard/Dialect/AppleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Rondeto\JSContact\Model\PartialDate;
use Rondeto\JSContact\Model\Relation;
use Rondeto\JSContact\VCard\Dialect\Apple;
use Rondeto\JSContact\VCard\Target;
use Rondeto\JSContact\VCard\VCardDecoder;
use Rondeto\JSContact\VCard\VCardEncoder;
use Rondeto\JSContact\VCard\VCardVersion;
Expand Down Expand Up @@ -75,7 +76,7 @@ public function testACardIsWrittenAsAppleReadsIt(): void
relatedTo: ['Jane' => new Relation(['spouse']), 'Bob' => new Relation(['agent']), 'Ann' => new Relation(['co-worker'])],
);

$result = new VCardEncoder(dialects: [new Apple()])->encode($card);
$result = new VCardEncoder()->encode($card, new Target(dialects: [new Apple()]));
$lines = explode("\r\n", $result->value);

self::assertSame([], array_map(strval(...), $result->issues));
Expand Down Expand Up @@ -113,15 +114,15 @@ public function testIssuesAboutPropertiesTheDialectReplacedAreDropped(): void
'/kind: vCard 3.0 does not define KIND, wrote it anyway',
'/relatedTo/Jane: vCard 3.0 does not define RELATED, wrote it anyway',
'/members: vCard 3.0 does not define MEMBER, wrote it anyway',
], array_map(strval(...), new VCardEncoder()->encode($card, VCardVersion::V30)->issues));
], array_map(strval(...), new VCardEncoder()->encode($card, new Target(VCardVersion::V30))->issues));

self::assertSame([], array_map(strval(...), new VCardEncoder(dialects: [new Apple()])->encode($card, VCardVersion::V30)->issues));
self::assertSame([], array_map(strval(...), new VCardEncoder()->encode($card, new Target(VCardVersion::V30, [new Apple()]))->issues));

// A KIND Apple has no equivalent for stays, and so does the issue.
self::assertSame([
'/kind: vCard 3.0 does not define KIND, wrote it anyway',
'/kind: Apple has no kind "location", kept KIND',
], array_map(strval(...), new VCardEncoder(dialects: [new Apple()])->encode(new Card(kind: Card::KIND_LOCATION), VCardVersion::V30)->issues));
], array_map(strval(...), new VCardEncoder()->encode(new Card(kind: Card::KIND_LOCATION), new Target(VCardVersion::V30, [new Apple()]))->issues));
}

public function testDatesWithoutYearAreWrittenInYear1604InVersion3(): void
Expand All @@ -131,9 +132,9 @@ public function testDatesWithoutYearAreWrittenInYear1604InVersion3(): void
'w' => new Anniversary(Anniversary::KIND_WEDDING, new PartialDate(null, 6, 12)),
]);

self::assertStringContainsString('JSPROP', new VCardEncoder()->encode($card, VCardVersion::V30)->value);
self::assertStringContainsString('JSPROP', new VCardEncoder()->encode($card, new Target(VCardVersion::V30))->value);

$result = new VCardEncoder(dialects: [new Apple()])->encode($card, VCardVersion::V30);
$result = new VCardEncoder()->encode($card, new Target(VCardVersion::V30, [new Apple()]));

self::assertSame([], array_map(strval(...), $result->issues));
self::assertStringNotContainsString('JSPROP', $result->value);
Expand All @@ -145,7 +146,7 @@ public function testDatesWithoutYearAreWrittenInYear1604InVersion3(): void

// With a place, the JSPROP is still needed.
$withPlace = new Card(anniversaries: ['b' => new Anniversary(Anniversary::KIND_BIRTH, new PartialDate(null, 4, 12), new Address(full: 'Paris'))]);
$result = new VCardEncoder(dialects: [new Apple()])->encode($withPlace, VCardVersion::V30);
$result = new VCardEncoder()->encode($withPlace, new Target(VCardVersion::V30, [new Apple()]));
self::assertStringContainsString('JSPROP', $result->value);
self::assertSame(['/anniversaries/b: vCard 3.0 has no partial dates, wrote the anniversary as JSPROP'], array_map(strval(...), $result->issues));
}
Expand All @@ -165,7 +166,7 @@ public function testAppleExportsSurviveARoundTripThroughTheDialect(string $file)
{
$decoder = new VCardDecoder(dialects: [new Apple()]);
foreach ($decoder->decode((string) file_get_contents(__DIR__.'/../../Fixtures/CozyVcard/'.$file)) as $result) {
$vCard = new VCardEncoder(validate: false, dialects: [new Apple()])->encode($result->value, VCardVersion::V30)->value;
$vCard = new VCardEncoder(validate: false)->encode($result->value, new Target(VCardVersion::V30, [new Apple()]))->value;

self::assertEquals($result->value, $decoder->decode($vCard)[0]->value ?? null);
}
Expand Down
9 changes: 5 additions & 4 deletions tests/VCard/Dialect/DialectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Rondeto\JSContact\Model\Nickname;
use Rondeto\JSContact\Validation\InvalidCardException;
use Rondeto\JSContact\VCard\Dialect\Dialect;
use Rondeto\JSContact\VCard\Target;
use Rondeto\JSContact\VCard\VCardDecoder;
use Rondeto\JSContact\VCard\VCardEncoder;
use Rondeto\JSContact\VCard\VCardVersion;
Expand Down Expand Up @@ -49,7 +50,7 @@ public function testConvertLeavesTheGivenVCardUnchanged(): void

public function testADialectRewritesTheVCardOnceWritten(): void
{
$result = new VCardEncoder(dialects: [new NicknameDialect()])->encode(new Card(nicknames: ['n1' => new Nickname('Janie')]));
$result = new VCardEncoder()->encode(new Card(nicknames: ['n1' => new Nickname('Janie')]), new Target(dialects: [new NicknameDialect()]));

self::assertStringContainsString("X-NICK:Janie\r\n", $result->value);
self::assertStringNotContainsString('NICKNAME', $result->value);
Expand All @@ -64,17 +65,17 @@ public function testOnlyIssuesAboutHowAPropertyIsWrittenGoWithIt(): void
media: ['m1' => new Media('https://example.com/a.jpg', Media::KIND_PHOTO), 'm2' => new Media('https://example.com/a.mp4', 'example.com:video')],
);

$issues = static fn (VCardEncoder $encoder): array => array_map(strval(...), $encoder->encode($card, VCardVersion::V30)->issues);
$issues = static fn (Target $target): array => array_map(strval(...), new VCardEncoder()->encode($card, $target)->issues);
self::assertSame([
'/kind: vCard 3.0 does not define KIND, wrote it anyway',
'/emails/e1/contexts/example.com:car: vCard has no TYPE for this context, left it out',
'/media/m2: vCard has no media of kind "example.com:video", wrote it as JSPROP',
], $issues(new VCardEncoder()));
], $issues(new Target(VCardVersion::V30)));

// A dialect replacing every property: the context is still left out.
self::assertSame([
'/emails/e1/contexts/example.com:car: vCard has no TYPE for this context, left it out',
], $issues(new VCardEncoder(dialects: [new ReplacingDialect()])));
], $issues(new Target(VCardVersion::V30, [new ReplacingDialect()])));
}
}

Expand Down
Loading
Loading