parse method

void parse(
  1. String content,
  2. Contact contact
)

Implementation

void parse(String content, Contact contact) {
  var lines = encode(unfold(content)).split('\n').map((String x) => x.trim());
  for (final line in lines) {
    // In general, a vCard line is of the form
    // [<group>.]<op>[;<param>=<value>[;...]]:<content>
    final parts = line.split(':');
    if (parts.length < 2) {
      continue; // invalid line
    }
    final prefix = parts[0];
    var content = parts.sublist(1).join(':');
    if (content.isEmpty) {
      continue; // no content => nothing to do
    }
    final prefixParts = prefix.split(';');
    final groupKey = prefixParts[0].split('.');
    final op = groupKey.last.toUpperCase(); // drop the group
    final group = groupKey.length == 2 ? groupKey.first : '';
    var params = <Param>[];
    for (final p in prefixParts.sublist(1)) {
      final paramParts = p.split('=');
      if (paramParts.length < 2) {
        // Allowed wtih vCard 2.1.
        params.add(Param(paramParts[0].toUpperCase(), null));
      }
      params.add(Param(
        paramParts[0].toUpperCase(),
        paramParts.sublist(1).join('=').toUpperCase(),
      ));
    }

    // https://mathematica.stackexchange.com/questions/111637/importing-a-vcf-file-with-quoted-printable-encoding
    if (params
        .any((p) => p.key == 'ENCODING' && p.value == 'QUOTED-PRINTABLE')) {
      content = Uri.decodeFull(content.replaceAll('=', '%'));
    }

    var labelOverride = '';
    if (group.isNotEmpty) {
      final relatedLines = lines.where((x) => x.startsWith(group));
      for (final related in relatedLines) {
        if (related
            .toUpperCase()
            .startsWith('${group.toUpperCase()}.X-ABLABEL:')) {
          final label = related.split(':').last;
          if (label.startsWith('_\$!<') && label.endsWith('>!\$_')) {
            labelOverride = label.substring(4, label.length - 4);
          } else {
            labelOverride = label;
          }
        }
      }
    }

    // We ignore lines that don't represent a property, such as BEGIN, END,
    // VERSION, etc. We also ignore a few unsupported properties such as GEO,
    // SOUND, GENDER, etc.
    switch (op) {
      case 'PHOTO':
        // The content can be base64-encoded or a URL. Try to decode it, and
        // ignore the line if it fails.
        try {
          contact.photo = base64.decode(decode(content));
        } on FormatException {
          // Pass.
        }
        break;
      case 'N':
        // Format is N:<last>;<first>;<middle>;<prefix>;<suffix>
        final parts = content.split(';');
        final n = parts.length;
        if (n >= 1) contact.name.last = decode(parts[0]);
        if (n >= 2) contact.name.first = decode(parts[1]);
        if (n >= 3) contact.name.middle = decode(parts[2]);
        if (n >= 4) contact.name.prefix = decode(parts[3]);
        if (n >= 5) contact.name.suffix = decode(parts[4]);
        break;
      case 'FN':
        contact.displayName = decode(content);
        break;
      case 'NICKNAME':
        // Format is NICKNAME:<nickname 1>[,<nickname 2>[,...]]
        final parts = content.split(',');
        contact.name.nickname = decode(parts[0]);
        break;
      case 'TEL':
      case 'PHONE':
        // 2.1 uses PHONE, 3.0/4.0 use TEL. The label is denoted with
        // TYPE=<label>. Multiple labels are denoted with TYPE=<label1>,<label2>
        // or TYPE="<label1>,<label2>" or TYPE=label1;TYPE=label2. vCard 3.0
        // specifies primary number by adding type "pref", while vCard 4.0 uses
        // a separate param PREF=1 (or a higher value supposed to represent
        // higher priority). vCard 4.0 sometimes specifies VALUE=uri with a
        // value prefixed with "tel:". There are sometimes extensions, for
        // example "tel:+1-555-555-5555;ext=5555". In such cases we denote the
        // extension with a ';' – see https://www.lifewire.com/automatically-dialing-extensions-on-android-577619
        Phone phone;
        final number =
            content.startsWith('tel:') ? content.substring(4) : content;
        final numberParts = number.split(';ext=');
        if (numberParts.length == 2) {
          phone =
              Phone('${decode(numberParts[0])};${decode(numberParts[1])}');
        } else {
          phone = Phone(decode(numberParts[0]));
        }
        _parseLabel(params, labelOverride, _parsePhoneLabel, phone);
        contact.phones.add(phone);
        break;
      case 'EMAIL':
        var email = Email(decode(content));
        _parseLabel(params, labelOverride, _parseEmailLabel, email);
        contact.emails.add(email);
        break;
      case 'ADR':
        // Format is ADR:<pobox>;<extended address>;<street>;<locality (city)>;
        // <region (state/province)>;<postal code>;<country>
        var addressParts = content.split(';');
        if (addressParts.length != 7) {
          continue; // invalid line
        }
        var address = Address('');
        if (([addressParts[0]] + addressParts.sublist(3))
            .any((x) => x.isNotEmpty)) {
          address.pobox = decode(addressParts[0]);
          address.street = decode(addressParts[2]);
          address.city = decode(addressParts[3]);
          address.state = decode(addressParts[4]);
          address.postalCode = decode(addressParts[5]);
          address.country = decode(addressParts[6]);
        }
        address.address =
            addressParts.map(decode).where((x) => x.isNotEmpty).join(' ');
        _parseLabel(params, labelOverride, _parseAddressLabel, address);
        contact.addresses.add(address);
        break;
      case 'ORG':
        // Format is ORG:<company>[;<division>[:<subdivision>...]]
        if (contact.organizations.isEmpty) {
          contact.organizations = [Organization()];
        }
        final orgParts = content.split(';');
        final n = orgParts.length;
        if (n >= 1) {
          contact.organizations.first.company = decode(orgParts[0]);
        }
        if (n >= 2) {
          contact.organizations.first.department = decode(orgParts[1]);
        }
        break;
      case 'TITLE':
        if (contact.organizations.isEmpty) {
          contact.organizations = [Organization()];
        }
        contact.organizations.first.title = decode(content);
        break;
      case 'ROLE':
        if (contact.organizations.isEmpty) {
          contact.organizations = [Organization()];
        }
        contact.organizations.first.jobDescription = decode(content);
        break;
      case 'URL':
        var website = Website(decode(content));
        _parseLabel(params, labelOverride, _parseWebsiteLabel, website);
        contact.websites.add(website);
        break;
      case 'IMPP':
        // Format is IMPP:<protocol>:<username>
        final contentParts = content.split(':');
        if (contentParts.length != 2) {
          continue; // invalid line
        }
        final serviceTypes = params.where((p) => p.key == 'X-SERVICE-TYPE');
        final protocol = decode(serviceTypes.isNotEmpty
            ? serviceTypes.first.value!
            : contentParts[0]);
        // ICQ gets duplicated into an ICQ and an AIM line due to a bug:
        // https://discussions.apple.com/thread/2769242
        if (serviceTypes.isNotEmpty &&
            serviceTypes.first.value == 'ICQ' &&
            contentParts[0] == 'aim') {
          continue;
        }
        final userName = decode(contentParts[1]);
        final label =
            lowerCaseStringToSocialMediaLabel[protocol.toLowerCase()] ??
                SocialMediaLabel.custom;
        final customLabel = label == SocialMediaLabel.custom ? protocol : '';
        contact.socialMedias.add(
            SocialMedia(userName, label: label, customLabel: customLabel));
        break;
      case 'X-SOCIALPROFILE':
        // On iOS social profiles are different from instant messaging, and
        // exported as X-SOCIALPROFILE;type=<protocol>:<username>
        var protocol = '';
        for (final param in params) {
          if (param.key == 'TYPE') {
            protocol = decode(param.value!);
          }
        }
        if (params.any((p) => p.key == 'X-USER' && p.value == 'TENCENT')) {
          protocol = 'tencent';
        }
        var userName = decode(content);
        for (final prefix in ['x-apple:', 'xmpp:']) {
          if (userName.startsWith(prefix)) {
            userName = userName.substring(prefix.length);
            break;
          }
        }
        final label =
            lowerCaseStringToSocialMediaLabel[protocol.toLowerCase()] ??
                SocialMediaLabel.custom;
        final customLabel = label == SocialMediaLabel.custom ? protocol : '';
        contact.socialMedias.add(
            SocialMedia(userName, label: label, customLabel: customLabel));
        break;
      case 'BDAY':
      case 'ANNIVERSARY':
        final label =
            op == 'BDAY' ? EventLabel.birthday : EventLabel.anniversary;
        final date = decode(content);
        final omitYear = params.any((p) => p.key == 'X-APPLE-OMIT-YEAR');
        _tryAddEvent(contact, date, label, '', omitYear);
        break;
      case 'NOTE':
        contact.notes.add(Note(decode(content)));
        break;
      case 'X-ANDROID-CUSTOM':
        // Android default contact app exports anniversary (1), other (2),
        // and custom events (0) as:
        // X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;--03-23;1;;;;;;;;;;;;;
        // X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;2021-04-23;2;;;;;;;;;;;;;
        // X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;2017-09-23;0;Custom;;;;;;;;;;;;
        // and nicknames as
        // X-ANDROID-CUSTOM:vnd.android.cursor.item/nickname;Nick;1;;;;;;;;;;;;;
        final contentParts = content.split(';');
        final n = contentParts.length;
        if (n < 2) {
          continue; // invalid line
        }
        switch (contentParts[0]) {
          case 'vnd.android.cursor.item/contact_event':
            final date = decode(contentParts[1]);
            final labelStr = n >= 3 ? contentParts[2] : '';
            var label = EventLabel.other;
            if (labelStr == '0') {
              label = EventLabel.custom;
            } else if (labelStr == '1') {
              label = EventLabel.anniversary;
            }
            final customLabel = n >= 4 && label == EventLabel.custom
                ? decode(contentParts[3])
                : '';
            _tryAddEvent(contact, date, label, customLabel, false);
            break;
          case 'vnd.android.cursor.item/nickname':
            contact.name.nickname = decode(contentParts[1]);
            break;
        }
        break;
      case 'X-AIM':
        contact.socialMedias
            .add(SocialMedia(decode(content), label: SocialMediaLabel.aim));
        break;
      case 'X-MSN':
        contact.socialMedias
            .add(SocialMedia(decode(content), label: SocialMediaLabel.msn));
        break;
      case 'X-YAHOO':
        contact.socialMedias
            .add(SocialMedia(decode(content), label: SocialMediaLabel.yahoo));
        break;
      case 'X-SKYPE-USERNAME':
        contact.socialMedias
            .add(SocialMedia(decode(content), label: SocialMediaLabel.skype));
        break;
      case 'X-QQ':
        contact.socialMedias.add(
            SocialMedia(decode(content), label: SocialMediaLabel.qqchat));
        break;
      case 'X-GOOGLE-TALK':
        contact.socialMedias.add(
            SocialMedia(decode(content), label: SocialMediaLabel.googleTalk));
        break;
      case 'X-ICQ':
        contact.socialMedias
            .add(SocialMedia(decode(content), label: SocialMediaLabel.icq));
        break;
      case 'X-JABBER':
        contact.socialMedias.add(
            SocialMedia(decode(content), label: SocialMediaLabel.jabber));
        break;
      case 'X-PHONETIC-FIRST-NAME':
        contact.name.firstPhonetic = decode(content);
        break;
      case 'X-PHONETIC-LAST-NAME':
        contact.name.lastPhonetic = decode(content);
        break;
      case 'X-PHONETIC-ORG':
        if (contact.organizations.isEmpty) {
          contact.organizations = [Organization()];
        }
        contact.organizations.first.phoneticName = decode(content);
        break;
      case 'X-ABDATE':
        var tempContact = Contact();
        final date = decode(content);
        final omitYear = params.any((p) => p.key == 'X-APPLE-OMIT-YEAR');
        _tryAddEvent(tempContact, date, EventLabel.birthday, '', omitYear);
        if (tempContact.events.isNotEmpty) {
          _parseEventLabel(labelOverride, tempContact.events.last, true);
          contact.events.add(tempContact.events.last);
        }
        break;
    }
  }
  // Deduplicate properties, mostly because of iOS duplicating social profiles
  // and instant messaging.
  contact.deduplicateProperties();
}