Text::JSContact - Convert between vCard and JSContact

DESCRIPTION

Text::JSContact provides bidirectional conversion between vCard (RFC 6350)
and JSContact (RFC 9553), following the mapping rules in RFC 9555 and the
extensions in RFC 9554.  Cards are JSContact version 2.0 (RFC 9982), where
uid is optional: a vCard without UID gives a Card without uid.

The module handles both standard vCard properties and widely-used vendor
extensions, particularly those from Apple's Contacts/AddressBook framework.

FUNCTIONS

  vcard_to_jscontact($vcard_string)

    Parse a vCard string and return a JSContact Card hashref (version
    "2.0"; no uid unless the vCard has a UID, per RFC 9982).

  jscontact_to_vcard($card_hashref)

    Convert a JSContact Card hashref to a vCard string (version 4.0).
    The same Card always gives the same bytes.

  jscontact_to_vcard($card_hashref, original => $vcard_string)

    Write the Card as an edit of $vcard_string, the vCard it was read
    from (as fetched from the server: bytes or characters).  The Card is
    compared with what $vcard_string itself reads as, and only the
    entries that differ are rewritten, in the vCard's own version (a
    VERSION:3.0 card stays a valid 3.0 card); every other line, including
    everything JSContact cannot represent, is kept byte for byte.  An
    unchanged Card gives $vcard_string back.

    The caller does not need the Card it first read, and may change
    anything in the Card before writing it (add a uid, say): keys that
    are not Card properties are ignored.  If $vcard_string cannot be
    parsed, the Card is written as a new vCard, as without "original".

    Returns a character string, like the one-argument form.  This is the
    recommended way to update a contact via CardDAV.

  patch_vcard($original_vcard_string, $old_card, $new_card)

    The older form of the same patch: applies the changes between
    $old_card and $new_card to the original vCard.  Returns
    $original_vcard_string itself when $old_card and $new_card do not
    differ; otherwise the patched vCard, as bytes if the original was
    bytes and as characters if it was characters.

    $original_vcard_string - the raw vCard text as fetched from the server
    $old_card              - the JSContact Card parsed from that vCard
    $new_card              - the modified JSContact Card to write back

APPLE VCARD EXTENSIONS

Apple's Contacts framework (macOS/iOS) uses several non-standard vCard
properties and parameters.  These are not part of any RFC but are widely
used by Apple clients, iCloud, and third-party applications that need to
interoperate with the Apple ecosystem.

There is no formal Apple specification for these extensions.  Our
implementation is based on reverse-engineering Apple-exported vCards and
community documentation, including:

  - iOS full vCard examples from cozy/cozy-vcard test suite
    https://github.com/cozy/cozy-vcard/blob/master/test/ios-full.vcf

  - X-ABLabel discussion and behavior analysis
    https://github.com/mstilkerich/rcmcarddav/issues/310
    https://github.com/FossifyOrg/Contacts/issues/187

  - Apple group contact format (X-ADDRESSBOOKSERVER-KIND/MEMBER)
    https://github.com/nextcloud/server/issues/9369

  - RFC 9554 standardization of SOCIALPROFILE (replacing X-SOCIALPROFILE)
    https://datatracker.ietf.org/doc/rfc9554/

  - X-ABCROP-RECTANGLE format analysis
    https://gitlab.com/CardBook/CardBook/-/issues/283

Policy: Reading Apple Extensions

When parsing vCards, we recognize and convert the following Apple
extensions to their JSContact equivalents:

  X-ABLabel           -> label field on the associated property
                         Parsed from itemN.X-ABLabel grouped with the
                         labeled property.  Apple standard labels like
                         _$!<HomePage>!$_ are unwrapped to just "HomePage".

  X-ADDRESSBOOKSERVER-KIND   -> kind (e.g. "group")
  X-ADDRESSBOOKSERVER-MEMBER -> members (Apple's vCard 3.0 equivalent
                                of the standard KIND + MEMBER properties)

  X-ABRELATEDNAMES    -> relatedTo, with relation type derived from the
                         associated X-ABLabel.  Mappings:
                           Mother/Father/Parent -> parent
                           Brother/Sister       -> sibling
                           Child                -> child
                           Friend               -> friend
                           Spouse/Partner        -> spouse
                           Assistant/Manager     -> colleague

  X-ABDATE            -> anniversaries, with kind derived from X-ABLabel.
                         "Anniversary" label maps to kind "wedding".

  X-ABADR             -> addresses[].countryCode (ISO country code,
                         grouped with the associated ADR property)

  X-PHONETIC-FIRST-NAME  -> name.phoneticComponents (kind: given)
  X-PHONETIC-MIDDLE-NAME -> name.phoneticComponents (kind: given2)
  X-PHONETIC-LAST-NAME   -> name.phoneticComponents (kind: surname)

  X-SOCIALPROFILE      -> onlineServices (with service from TYPE param
                          and user from x-user param)

  X-AIM, X-ICQ, X-MSN,
  X-YAHOO, X-JABBER,
  X-SKYPE, X-TWITTER,
  X-GOOGLE-TALK        -> onlineServices (legacy IM properties)

Policy: Writing Apple Extensions

When generating vCards, we write standard vCard 4.0 properties.  Apple
extensions are generated in the following cases:

  - When a JSContact property has a "label" field, we generate a grouped
    itemN.X-ABLabel property alongside the main property.  This ensures
    custom labels survive round-trips through Apple clients.

  - Standard vCard 4.0 equivalents are used where they exist:
      KIND instead of X-ADDRESSBOOKSERVER-KIND
      MEMBER instead of X-ADDRESSBOOKSERVER-MEMBER
      RELATED instead of X-ABRELATEDNAMES
      ANNIVERSARY instead of X-ABDATE
      SOCIALPROFILE instead of X-SOCIALPROFILE (per RFC 9554)

  - Legacy X-service IM properties (X-AIM, X-SKYPE, etc.) are written
    as IMPP properties with SERVICE-TYPE parameter.

The intent is that vCards generated by this module are valid vCard 4.0
that Apple clients can read, while vCards from Apple clients are fully
parsed without data loss.

Preserving Unknown Properties (patching)

When updating a contact via CardDAV, a naive approach of converting
JSContact -> vCard and PUT-ing the result will lose any vCard properties
that JSContact does not model.  This includes vendor extensions, custom
X-properties, and any standard properties we don't yet handle.

jscontact_to_vcard(..., original => ...) and patch_vcard() avoid that by
patching the original vCard line by line:

  - a line for an entry the change does not touch keeps its bytes, its
    group, its folding and its place, as does every line JSContact cannot
    represent;
  - a changed entry is written over its own line, keeping its name and
    group as written, every parameter the reader does not interpret and
    every TYPE value it does not map;
  - a removed entry takes its line with it, and its group's X-ABLabel,
    X-ABADR, GEO and TZ once nothing else is left in the group;
  - a new entry is added after the lines of the same kind, with a new
    itemN group (one the original does not use) if it has a label.

In a vCard 3.0 card, the lines patched in are vCard 3.0: no PROP-ID,
TYPE=pref rather than PREF=1, ENCODING=b for inline data, Apple's
X-SERVICE-TYPE, X-USER and X-ADDRESSBOOKSERVER-KIND/MEMBER, and X- forms
of the other vCard 4 parameters.

Limits: without PROP-IDs (every vCard 3.0 card, many 4.0 ones) the ids of
a Card's entries are their positions, so adding or removing an entry
renumbers the entries read after it.  Phonetic names are read from
X-PHONETIC-* but not written, so a change to them is not patched.

SPECIFICATIONS

  RFC 6350  - vCard Format Specification
  RFC 9553  - JSContact: A JSON Representation of Contact Data
  RFC 9554  - vCard Format Extensions for JSContact
  RFC 9555  - JSContact: Converting from and to vCard

AUTHOR

  Bron Gondwana <brong@cpan.org>

LICENSE

  Copyright 2026 Fastmail Pty Ltd.

  This program is free software; you can redistribute it and/or modify it
  under the same terms as Perl itself; that is, either the GNU General
  Public License as published by the Free Software Foundation (version 1,
  or at your option any later version), or the Artistic License.

  See http://dev.perl.org/licenses/ and the LICENSE file included with
  this distribution for more information.
