text: let text = {
    'group.member.remove': {
      'fr-FR': "✅ Enlever",
      '*':     "✅ Remove"
    },
    'group.member.restore': {
      'fr-FR': "🔙 Remettre",
      '*':     "🔙 Restore"
    },
    'contact.anonymous': {
      'fr-FR': "⁉️ anonyme",
      '*':     "⁉️ anonymous"
    },
  
    'error': {
      'fr-FR': "Erreur",
      '*':     "Error"
    },
    'error.msg.remove': {
      'fr-FR': "Impossible de retirer du groupe. Vérifiez que votre compte par défault pour les contacts est bien iCloud.",
      '*':     "Failed to remove contact. Please check your default account is iCloud."
    },
    'error.msg.restore': {
      'fr-FR': "Échec de réintégration. Vérifiez que votre compte par défault pour les contacts est bien iCloud.",
      '*':     "Failed to restore contact in group. Please check your default account is iCloud."
    },
    'ok': {
      'fr-FR': "OK",
      '*':     "OK"
    },
  
  
    // Use this as a template for localization
    '__': {
      'fr-FR': "",
      '*':     ""
    }
  };
  
  let container = await ContactsContainer.default();
      
  async function groupSelected(group) {
    if ( null !== group ) {
      console.log(`Group: ${group.name}`);
      let members = await Contact.inGroups([ group ]);
      let orderedMembers = orderContacts(members);
      
      console.log(
        orderedMembers.map(
          (c) => { return `${c.familyName} ${c.givenName}`; }
        )
      );
      
      console.log('manage');
      manageGroupMembers(container, group, members);
    }
  };
      
  await selectContactGroup(container, groupSelected);
  
  
  // ----- 
  
  
  
  // ===== UI UTILITIES
  
  Object.prototype.i18n = function (key) {
    let langs = Device.preferredLanguages();
    langs.push('*');
    
    if ( 'undefined' === typeof this[key] ) {
      console.log(`Missing key ${key}`);
      Script.complete();
    }
    
    let msg;
    while ( 'undefined' === typeof msg && (langs.length > 0) ) {
      msg = this[key][langs.shift()];
    }
    
    return msg;
  };
  
  
  /** Create a simple alert dialog.
   */
  function createAlertDialog(title, message, cancelLabel) {
    let dialog = new Alert();
    
    dialog.title = title;
    dialog.message = message;
    
    dialog.addCancelAction(cancelLabel);
    
    return dialog;
  }
  
  
  // ===== GROUP TABLE UTILITIES
  
  
  /** Create a simple UITableRow for a group
   *
   * @param group 
   *        ContactsGroup object
   * @param selectFn 
   *        Function to call when group is selected `(group: ContactsGroup) => { ... }`
   */
  async function createGroupRow(group, selectFn) { 
    let row = new UITableRow();
    row.height = 50;
  
    let members = await Contact.inGroups([group]);
    
    let nameCell = UITableCell.text(group.name);
    nameCell.leftAligned();
    
    let countCell = UITableCell.text(`${ members.length } contacts`)
    countCell.rightAligned();
    
    row.addCell(nameCell);
    row.addCell(countCell);
  
    row.onSelect = selectFn;
    
    return row;
  }
  
  
  /** Build a UITable to present a list of ContactsGroup.
   */
  async function createGroupTable(groups, selectFn) {
    let table = new UITable();
    
    for ( group of groups ) {
      let row = await createGroupRow(group, selectFn);
      table.addRow(row);
    }
    
    return table;
  }
  
  
  // ===== GROUP MEMBER TABLE UTILITIES
  
  /** Simple class to wrap a contact and manage their group membership.
   */
  function GroupMember(group, contact) {
    let member = {
      group: group,
      contact: contact,
      isMember: true
    };
    
    return member;
  }
  
  
  
  
  
  // ===== CONTACT GROUP UTILITY
  
  /** Order an array of ContactsGroup by their name.
   */
  function orderGroups(groups) {
    groups.sort(
      (g1, g2) => { return g1.name.localeCompare(g2.name); }
    );
    
    return groups;
  }
  
  
  /** Order an array of ContactsGroup by their name.
   */
  function orderContacts(contacts) {
    contacts.sort(
      (c1, c2) => { 
        let c1HasName = !c1.familyName.match(/^\s*$/);
        let c2HasName = !c2.familyName.match(/^\s*$/);
        
        let c1HasFirstName = !c1.givenName.match(/^\s*$/);
        let c2HasFirstName = !c2.givenName.match(/^\s*$/);
        
        let left  = ( c1HasName ) ? c1.familyName : ( c1HasFirstName ) ? c1.givenName : c1.organizationName;
        let right = ( c2HasName ) ? c2.familyName : ( c2HasFirstName ) ? c2.givenName : c2.organizationName;      
          
        let compare = left.localeCompare(right);
        
        return compare;
      }
    );
    
    return contacts;
  }
  
  
  // ===== CONTACT INTERACTION
  
  
  /** Build and present a list of group to select the one to manage.
   */
  async function selectContactGroup(container, selectedFn) {
    let allGroups = await ContactsGroup.all([ container ]);
    
    let orderedGroups = orderGroups(allGroups);
    
    // We build a small function to map row number to group object
    // as this is the kind of parameter expected by the selectedFn parameter.
    let groupSelectionHandler = (index) => {
      let group = orderedGroups[index];
      selectedFn(group);
    };
    
    let groupTable = await createGroupTable(orderedGroups, groupSelectionHandler);
    
    groupTable.present();
  }
  
  
  
  // -----
  
  
  /** Build and present a list of group members and allow on-the-fly membership management.
   */
  async function manageGroupMembers(container, group, contacts) {
    let memberList = contacts.map(
      (contact) => { return GroupMember(group, contact); }
    );
    
    // Change membership on the fly
    let selectHandler = (member, complete) => {
      if ( member.isMember ) {
        console.log('will remove');
        removeContactFromGroup(member.contact, group, () => { 
          member.isMember = false; 
          console.log(`did remove ${member}`);
          complete();
        });
      }
      else {
        console.log('will add');
        addContactToGroup(member.contact, group, () => { 
          console.log('did add');
          member.isMember = true; 
          complete();
        });
      }
    };
    
    let table = await createMembersTable(memberList, selectHandler);
    console.log('present member table')
    table.present();
  }
  
  
  /** Build a UITable to present a list of ContactsGroup.
   */
  async function createMembersTable(members, selectFn) {
    let table = new UITable();
    table.showSeparators = true;
    
    let selectedFn = (rowIndex) => {
      let member = members[rowIndex];
      console.log(`@${rowIndex}: `);
      
      console.log('Calling handler...');
      selectFn(member, () => { 
        console.log('Reloading...');
        refreshMembersTable(table, members, selectedFn);
      });
    };
    
    await refreshMembersTable(table, members, selectedFn);
    
    return table;
  }
  
  
  async function refreshMembersTable(table, members, selectedFn) {
    table.removeAllRows();
    
    for ( member of members ) {
      let row = await createMemberRow(member, selectedFn);
      table.addRow(row);
    }
    
    table.reload();
  }
  
  
  
  async function createMemberRow(member, selectFn) { 
    let row = new UITableRow();
    row.height = 64;
  
    let name = [member.contact.familyName, member.contact.givenName].join(' ');
    let nameCell = null;
    
    if ( name.match(/^\s*$/) ) {
      if ( member.contact.emailAddresses.length > 0 ) {
        let email = member.contact.emailAddresses[0];
        name = [email.localizedLabel, email.value].join(' ');
      }
      else if ( member.contact.phoneNumbers.length > 0 ) {
        let phone = member.contact.phoneNumbers[0];
        name = [phone.localizedLabel, phone.value].join(' ');
      }
      else {
        name = text.i18n('contact.anonymous');
      }
    }
  
    
    let corpName = member.contact.organizationName;
    if ( corpName && corpName.match(/^\s*$/) ) {
      // No corp name, text cell has only a title with name
      nameCell = UITableCell.text(name);
    } 
    else {
      // With corp name, put corp in title, name in subtitle
      nameCell = UITableCell.text(corpName, name);
    }
    nameCell.leftAligned();
  
    let actionKey = 
      ( member.isMember ) ? "group.member.remove" 
                          : "group.member.restore";
    let action = text.i18n(actionKey);
    let actionCell = UITableCell.text(action);
    
    row.addCell(nameCell);
    row.addCell(actionCell);
  
    row.onSelect = selectFn;
    row.dismissOnSelect = false;
    
    return row;
  }
  
  
  
  /** Remove contact from the group and apply change to the store.
   */
  function removeContactFromGroup(contact, group, complete) {
    group.removeMember(contact);
    
    Contact.persistChanges()
    .then((data) => {
      complete();
      console.log(`Contact ${ contact.familyName } removed from group ${ group.name }.`); 
    })
    .catch((error) => {
      console.log(`Failed to remove contact from group. ${error}`);
      let alert = createAlertDialog(
        text.i18n('error'),
        text.i18n('error.msg.remove'),
        text.i18n('ok')
        );
      alert.present();
    });
  }
  
  
  function addContactToGroup(contact, group, complete) {
    group.addMember(contact)
    
    Contact.persistChanges()
    .then((data) => {
      complete();
      console.log(`Contact ${ contact.familyName } added to group ${ group.name }.`); 
    })
    .catch((error) => {
      console.log(`Failed to add contact to group. ${error}`);
      let alert = createAlertDialog(
        text.i18n('error'),
        text.i18n('error.msg.restore'),
        text.i18n('ok')
        );
      alert.present();
    });
  }
  
  