/// import { Cell, cell, derive, getRecipeEnvironment, handler, ID, JSONSchema, Mutable, NAME, recipe, Schema, str, UI, } from "commontools"; const Classification = { Unclassified: "unclassified", Confidential: "confidential", Secret: "secret", TopSecret: "topsecret", } as const; const ClassificationSecret = "secret"; // This is used by the various Google tokens created with tokenToAuthData export const AuthSchema = { type: "object", properties: { token: { type: "string", default: "", ifc: { classification: [ClassificationSecret] }, }, tokenType: { type: "string", default: "" }, scope: { type: "array", items: { type: "string" }, default: [] }, expiresIn: { type: "number", default: 0 }, expiresAt: { type: "number", default: 0 }, refreshToken: { type: "string", default: "", ifc: { classification: [ClassificationSecret] }, }, user: { type: "object", properties: { email: { type: "string", default: "" }, name: { type: "string", default: "" }, picture: { type: "string", default: "" }, }, }, }, } as const satisfies JSONSchema; const env = getRecipeEnvironment(); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const ContactProperties = { resourceName: { type: "string", title: "Resource Name", description: "Unique identifier for the contact", }, etag: { type: "string", title: "ETag", description: "Entity tag for the contact", default: "", }, displayName: { type: "string", title: "Display Name", description: "Contact's display name", default: "", }, givenName: { type: "string", title: "Given Name", description: "Contact's first name", default: "", }, familyName: { type: "string", title: "Family Name", description: "Contact's last name", default: "", }, middleName: { type: "string", title: "Middle Name", description: "Contact's middle name", default: "", }, emails: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Emails", description: "List of email addresses", default: [], }, phoneNumbers: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, canonicalForm: { type: "string" }, }, }, title: "Phone Numbers", description: "List of phone numbers", default: [], }, addresses: { type: "array", items: { type: "object", properties: { formattedValue: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, streetAddress: { type: "string" }, city: { type: "string" }, region: { type: "string" }, postalCode: { type: "string" }, country: { type: "string" }, countryCode: { type: "string" }, }, }, title: "Addresses", description: "List of addresses", default: [], }, organizations: { type: "array", items: { type: "object", properties: { name: { type: "string" }, title: { type: "string" }, department: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Organizations", description: "List of organizations", default: [], }, birthdays: { type: "array", items: { type: "object", properties: { date: { type: "object", properties: { year: { type: "number" }, month: { type: "number" }, day: { type: "number" }, }, }, text: { type: "string" }, }, }, title: "Birthdays", description: "List of birthdays", default: [], }, photos: { type: "array", items: { type: "object", properties: { url: { type: "string" }, metadata: { type: "object", properties: { primary: { type: "boolean" }, source: { type: "object", properties: { type: { type: "string" }, id: { type: "string" }, }, }, }, }, }, }, title: "Photos", description: "List of photos", default: [], }, biographies: { type: "array", items: { type: "object", properties: { value: { type: "string" }, contentType: { type: "string" }, }, }, title: "Biographies", description: "List of biographies", default: [], }, ageRanges: { type: "array", items: { type: "object", properties: { ageRange: { type: "string" }, metadata: { type: "object" }, }, }, title: "Age Ranges", description: "Age range of the contact", default: [], }, calendarUrls: { type: "array", items: { type: "object", properties: { url: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Calendar URLs", description: "Calendar URLs for the contact", default: [], }, clientData: { type: "array", items: { type: "object", properties: { key: { type: "string" }, value: { type: "string" }, }, }, title: "Client Data", description: "Client-specific data", default: [], }, coverPhotos: { type: "array", items: { type: "object", properties: { url: { type: "string" }, metadata: { type: "object" }, }, }, title: "Cover Photos", description: "Cover photos", default: [], }, events: { type: "array", items: { type: "object", properties: { date: { type: "object", properties: { year: { type: "number" }, month: { type: "number" }, day: { type: "number" }, }, }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Events", description: "Important events", default: [], }, externalIds: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "External IDs", description: "External identifiers", default: [], }, genders: { type: "array", items: { type: "object", properties: { value: { type: "string" }, formattedValue: { type: "string" }, addressMeAs: { type: "string" }, }, }, title: "Genders", description: "Gender information", default: [], }, imClients: { type: "array", items: { type: "object", properties: { username: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, protocol: { type: "string" }, formattedProtocol: { type: "string" }, }, }, title: "IM Clients", description: "Instant messaging clients", default: [], }, interests: { type: "array", items: { type: "object", properties: { value: { type: "string" }, }, }, title: "Interests", description: "Personal interests", default: [], }, locales: { type: "array", items: { type: "object", properties: { value: { type: "string" }, }, }, title: "Locales", description: "Language locales", default: [], }, locations: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, current: { type: "boolean" }, buildingId: { type: "string" }, floor: { type: "string" }, floorSection: { type: "string" }, deskCode: { type: "string" }, }, }, title: "Locations", description: "Physical locations", default: [], }, memberships: { type: "array", items: { type: "object", properties: { contactGroupMembership: { type: "object", properties: { contactGroupId: { type: "string" }, contactGroupResourceName: { type: "string" }, }, }, domainMembership: { type: "object", properties: { inViewerDomain: { type: "boolean" }, }, }, }, }, title: "Memberships", description: "Group memberships", default: [], }, metadata: { type: "object", properties: { sources: { type: "array", items: { type: "object", properties: { type: { type: "string" }, id: { type: "string" }, etag: { type: "string" }, updateTime: { type: "string" }, }, }, default: [], }, previousResourceNames: { type: "array", items: { type: "string" }, default: [], }, linkedPeopleResourceNames: { type: "array", items: { type: "string" }, default: [], }, deleted: { type: "boolean", default: false }, objectType: { type: "string", default: "" }, }, title: "Metadata", description: "Contact metadata", default: {}, }, miscKeywords: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Keywords", description: "Miscellaneous keywords", default: [], }, nicknames: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, }, }, title: "Nicknames", description: "Nicknames", default: [], }, occupations: { type: "array", items: { type: "object", properties: { value: { type: "string" }, }, }, title: "Occupations", description: "Occupations", default: [], }, relations: { type: "array", items: { type: "object", properties: { person: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "Relations", description: "Relationships", default: [], }, sipAddresses: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "SIP Addresses", description: "SIP addresses", default: [], }, skills: { type: "array", items: { type: "object", properties: { value: { type: "string" }, }, }, title: "Skills", description: "Professional skills", default: [], }, urls: { type: "array", items: { type: "object", properties: { value: { type: "string" }, type: { type: "string" }, formattedType: { type: "string" }, }, }, title: "URLs", description: "Web URLs", default: [], }, userDefined: { type: "array", items: { type: "object", properties: { key: { type: "string" }, value: { type: "string" }, }, }, title: "User Defined", description: "User-defined fields", default: [], }, } as const; const ContactSchema = { type: "object", properties: ContactProperties, required: [ "resourceName", "etag", "displayName", "givenName", "familyName", "middleName", "emails", "phoneNumbers", "addresses", "organizations", "birthdays", "photos", "biographies", "ageRanges", "calendarUrls", "clientData", "coverPhotos", "events", "externalIds", "genders", "imClients", "interests", "locales", "locations", "memberships", "metadata", "miscKeywords", "nicknames", "occupations", "relations", "sipAddresses", "skills", "urls", "userDefined", ], ifc: { classification: [Classification.Confidential] }, } as const satisfies JSONSchema; type Contact = Mutable>; type Auth = Schema; const PeopleImporterInputs = { type: "object", properties: { settings: { type: "object", properties: { limit: { type: "number", description: "number of contacts to import", default: 100, }, personFields: { type: "string", description: "comma-separated list of person fields to retrieve", default: "names,emailAddresses,phoneNumbers,photos,organizations,addresses,birthdays,biographies,ageRanges,calendarUrls,clientData,coverPhotos,events,externalIds,genders,imClients,interests,locales,locations,memberships,metadata,miscKeywords,nicknames,occupations,relations,sipAddresses,skills,urls,userDefined", }, }, required: ["limit", "personFields"], }, auth: AuthSchema, }, required: ["settings", "auth"], description: "Google People Importer", } as const satisfies JSONSchema; const ResultSchema = { type: "object", properties: { contacts: { type: "array", items: { type: "object", properties: ContactProperties, }, }, }, } as const satisfies JSONSchema; const updateLimit = handler({ type: "object", properties: { detail: { type: "object", properties: { value: { type: "string" } }, required: ["value"], }, }, }, { type: "object", properties: { limit: { type: "number", asCell: true } }, required: ["limit"], }, ({ detail }, state) => { state.limit.set(parseInt(detail?.value ?? "100") || 0); }); const updatePersonFields = handler({ type: "object", properties: { detail: { type: "object", properties: { value: { type: "string" } }, required: ["value"], }, }, }, { type: "object", properties: { personFields: { type: "string", asCell: true } }, required: ["personFields"], }, ({ detail }, state) => { state.personFields.set( detail?.value ?? "names,emailAddresses,phoneNumbers,photos", ); }); interface PeopleClientConfig { // How many times the client will retry after an HTTP failure retries?: number; // In milliseconds, the delay between making any subsequent requests due to failure. delay?: number; // In milliseconds, the amount to permanently increment to the `delay` on every 429 response. delayIncrement?: number; } class PeopleClient { private auth: Cell; private retries: number; private delay: number; private delayIncrement: number; constructor( auth: Cell, { retries = 3, delay = 1000, delayIncrement = 100 }: PeopleClientConfig = {}, ) { this.auth = auth; this.retries = retries; this.delay = delay; this.delayIncrement = delayIncrement; } private async refreshAuth() { const body = { refreshToken: this.auth.get().refreshToken, }; console.log("refreshAuthToken", body); const res = await fetch( new URL("/api/integrations/google-oauth/refresh", env.apiUrl), { method: "POST", body: JSON.stringify(body), }, ); if (!res.ok) { throw new Error("Could not acquire a refresh token."); } const json = await res.json(); const authData = json.tokenInfo as Auth; this.auth.update(authData); } async fetchContacts( pageSize: number = 100, personFields: string = "names,emailAddresses,phoneNumbers,photos", pageToken?: string, ): Promise<{ connections: any[]; nextPageToken?: string }> { const params = new URLSearchParams({ pageSize: pageSize.toString(), personFields: personFields, }); if (pageToken) { params.append("pageToken", pageToken); } const url = new URL( `https://people.googleapis.com/v1/people/me/connections?${params.toString()}`, ); const res = await this.googleRequest(url); const json = await res.json(); if (!json || !("connections" in json) || !Array.isArray(json.connections)) { console.log(`No connections found in response: ${JSON.stringify(json)}`); return { connections: [] }; } return { connections: json.connections, nextPageToken: json.nextPageToken, }; } private async googleRequest( url: URL, _options?: RequestInit, _retries?: number, ): Promise { const token = this.auth.get().token; if (!token) { throw new Error("No authorization token."); } const retries = _retries ?? this.retries; const options = _options ?? {}; options.headers = new Headers(options.headers); options.headers.set("Authorization", `Bearer ${token}`); const res = await fetch(url, options); const { ok, status, statusText } = res; // Allow all 2xx status if (ok) { console.log(`${url}: ${status} ${statusText}`); return res; } console.warn( `${url}: ${status} ${statusText}`, `Remaining retries: ${retries}`, ); if (retries === 0) { throw new Error("Too many failed attempts."); } await sleep(this.delay); if (status === 401) { await this.refreshAuth(); } else if (status === 429) { this.delay += this.delayIncrement; console.log(`Incrementing delay to ${this.delay}`); await sleep(this.delay); } return this.googleRequest(url, _options, retries - 1); } } const peopleUpdater = handler( {}, { type: "object", properties: { contacts: { type: "array", items: ContactSchema, default: [], asCell: true, }, auth: { ...AuthSchema, asCell: true }, settings: PeopleImporterInputs.properties.settings, }, required: ["contacts", "auth", "settings"], } as const satisfies JSONSchema, (_event, state) => { console.log("peopleUpdater!"); if (!state.auth.get().token) { console.warn("no token found in auth cell"); return; } process( state.auth, state.settings.limit, state.settings.personFields, state, ).catch((error) => { console.error("Error in peopleUpdater:", error); }); }, ); function connectionToContact(connection: any): Contact | null { try { const contact: Contact = { resourceName: connection.resourceName, etag: connection.etag || "", displayName: "", givenName: "", familyName: "", middleName: "", emails: [], phoneNumbers: [], addresses: [], organizations: [], birthdays: [], photos: [], biographies: [], ageRanges: [], calendarUrls: [], clientData: [], coverPhotos: [], events: [], externalIds: [], genders: [], imClients: [], interests: [], locales: [], locations: [], memberships: [], metadata: {}, miscKeywords: [], nicknames: [], occupations: [], relations: [], sipAddresses: [], skills: [], urls: [], userDefined: [], }; // Process names if (connection.names && Array.isArray(connection.names)) { const primaryName = connection.names.find((name: any) => name.metadata?.primary ) || connection.names[0]; if (primaryName) { contact.displayName = primaryName.displayName || ""; contact.givenName = primaryName.givenName || ""; contact.familyName = primaryName.familyName || ""; contact.middleName = primaryName.middleName || ""; } } // Process emails if (connection.emailAddresses && Array.isArray(connection.emailAddresses)) { contact.emails = connection.emailAddresses.map((email: any) => ({ value: email.value || "", type: email.type || "", formattedType: email.formattedType || "", })); } // Process phone numbers if (connection.phoneNumbers && Array.isArray(connection.phoneNumbers)) { contact.phoneNumbers = connection.phoneNumbers.map((phone: any) => ({ value: phone.value || "", type: phone.type || "", formattedType: phone.formattedType || "", canonicalForm: phone.canonicalForm || "", })); } // Process addresses if (connection.addresses && Array.isArray(connection.addresses)) { contact.addresses = connection.addresses.map((address: any) => ({ formattedValue: address.formattedValue || "", type: address.type || "", formattedType: address.formattedType || "", streetAddress: address.streetAddress || "", city: address.city || "", region: address.region || "", postalCode: address.postalCode || "", country: address.country || "", countryCode: address.countryCode || "", })); } // Process organizations if (connection.organizations && Array.isArray(connection.organizations)) { contact.organizations = connection.organizations.map((org: any) => ({ name: org.name || "", title: org.title || "", department: org.department || "", type: org.type || "", formattedType: org.formattedType || "", })); } // Process birthdays if (connection.birthdays && Array.isArray(connection.birthdays)) { contact.birthdays = connection.birthdays.map((birthday: any) => ({ date: birthday.date || {}, text: birthday.text || "", })); } // Process photos if (connection.photos && Array.isArray(connection.photos)) { contact.photos = connection.photos.map((photo: any) => ({ url: photo.url || "", metadata: photo.metadata || {}, })); } // Process biographies if (connection.biographies && Array.isArray(connection.biographies)) { contact.biographies = connection.biographies.map((bio: any) => ({ value: bio.value || "", contentType: bio.contentType || "", })); } // Process age ranges if (connection.ageRanges && Array.isArray(connection.ageRanges)) { contact.ageRanges = connection.ageRanges.map((ageRange: any) => ({ ageRange: ageRange.ageRange || "", metadata: ageRange.metadata || {}, })); } // Process calendar URLs if (connection.calendarUrls && Array.isArray(connection.calendarUrls)) { contact.calendarUrls = connection.calendarUrls.map((cal: any) => ({ url: cal.url || "", type: cal.type || "", formattedType: cal.formattedType || "", })); } // Process client data if (connection.clientData && Array.isArray(connection.clientData)) { contact.clientData = connection.clientData.map((data: any) => ({ key: data.key || "", value: data.value || "", })); } // Process cover photos if (connection.coverPhotos && Array.isArray(connection.coverPhotos)) { contact.coverPhotos = connection.coverPhotos.map((photo: any) => ({ url: photo.url || "", metadata: photo.metadata || {}, })); } // Process events if (connection.events && Array.isArray(connection.events)) { contact.events = connection.events.map((event: any) => ({ date: event.date || {}, type: event.type || "", formattedType: event.formattedType || "", })); } // Process external IDs if (connection.externalIds && Array.isArray(connection.externalIds)) { contact.externalIds = connection.externalIds.map((extId: any) => ({ value: extId.value || "", type: extId.type || "", formattedType: extId.formattedType || "", })); } // Process genders if (connection.genders && Array.isArray(connection.genders)) { contact.genders = connection.genders.map((gender: any) => ({ value: gender.value || "", formattedValue: gender.formattedValue || "", addressMeAs: gender.addressMeAs || "", })); } // Process IM clients if (connection.imClients && Array.isArray(connection.imClients)) { contact.imClients = connection.imClients.map((im: any) => ({ username: im.username || "", type: im.type || "", formattedType: im.formattedType || "", protocol: im.protocol || "", formattedProtocol: im.formattedProtocol || "", })); } // Process interests if (connection.interests && Array.isArray(connection.interests)) { contact.interests = connection.interests.map((interest: any) => ({ value: interest.value || "", })); } // Process locales if (connection.locales && Array.isArray(connection.locales)) { contact.locales = connection.locales.map((locale: any) => ({ value: locale.value || "", })); } // Process locations if (connection.locations && Array.isArray(connection.locations)) { contact.locations = connection.locations.map((location: any) => ({ value: location.value || "", type: location.type || "", current: location.current || false, buildingId: location.buildingId || "", floor: location.floor || "", floorSection: location.floorSection || "", deskCode: location.deskCode || "", })); } // Process memberships if (connection.memberships && Array.isArray(connection.memberships)) { contact.memberships = connection.memberships.map((membership: any) => ({ contactGroupMembership: membership.contactGroupMembership || {}, domainMembership: membership.domainMembership || {}, })); } // Process metadata if (connection.metadata) { contact.metadata = { sources: connection.metadata.sources || [], previousResourceNames: connection.metadata.previousResourceNames || [], linkedPeopleResourceNames: connection.metadata.linkedPeopleResourceNames || [], deleted: connection.metadata.deleted || false, objectType: connection.metadata.objectType || "", }; } // Process miscellaneous keywords if (connection.miscKeywords && Array.isArray(connection.miscKeywords)) { contact.miscKeywords = connection.miscKeywords.map((keyword: any) => ({ value: keyword.value || "", type: keyword.type || "", formattedType: keyword.formattedType || "", })); } // Process nicknames if (connection.nicknames && Array.isArray(connection.nicknames)) { contact.nicknames = connection.nicknames.map((nickname: any) => ({ value: nickname.value || "", type: nickname.type || "", })); } // Process occupations if (connection.occupations && Array.isArray(connection.occupations)) { contact.occupations = connection.occupations.map((occupation: any) => ({ value: occupation.value || "", })); } // Process relations if (connection.relations && Array.isArray(connection.relations)) { contact.relations = connection.relations.map((relation: any) => ({ person: relation.person || "", type: relation.type || "", formattedType: relation.formattedType || "", })); } // Process SIP addresses if (connection.sipAddresses && Array.isArray(connection.sipAddresses)) { contact.sipAddresses = connection.sipAddresses.map((sip: any) => ({ value: sip.value || "", type: sip.type || "", formattedType: sip.formattedType || "", })); } // Process skills if (connection.skills && Array.isArray(connection.skills)) { contact.skills = connection.skills.map((skill: any) => ({ value: skill.value || "", })); } // Process URLs if (connection.urls && Array.isArray(connection.urls)) { contact.urls = connection.urls.map((url: any) => ({ value: url.value || "", type: url.type || "", formattedType: url.formattedType || "", })); } // Process user-defined fields if (connection.userDefined && Array.isArray(connection.userDefined)) { contact.userDefined = connection.userDefined.map((userField: any) => ({ key: userField.key || "", value: userField.value || "", })); } return contact; } catch (error: any) { console.error( "Error processing connection:", "message" in error ? error.message : error, ); return null; } } export async function process( auth: Cell, limit: number = 100, personFields: string = "names,emailAddresses,phoneNumbers,photos", state: { contacts: Cell; }, ) { if (!auth.get()) { console.warn("no token"); return; } const existingContactIds = new Set( state.contacts.get().map((contact) => contact.resourceName), ); const client = new PeopleClient(auth); const allConnections: any[] = []; let pageToken: string | undefined; let totalFetched = 0; // Fetch contacts with pagination while (totalFetched < limit) { // People API max is 1000, but we'll use 100 for safety const pageSize = Math.min(100, limit - totalFetched); try { await sleep(1000); // Rate limiting const { connections, nextPageToken } = await client.fetchContacts( pageSize, personFields, pageToken, ); if (connections.length === 0) { break; } allConnections.push(...connections); totalFetched += connections.length; pageToken = nextPageToken; if (!pageToken) { break; } } catch (error: any) { console.error( "Error fetching contacts:", "message" in error ? error.message : error, ); break; } } // Filter out existing contacts const newConnections = allConnections.filter( (connection: { resourceName: string }) => !existingContactIds.has(connection.resourceName), ); if (newConnections.length === 0) { console.log("No new contacts to import"); return; } // Convert connections to contacts const contacts = newConnections .map(connectionToContact) .filter((contact): contact is Contact => contact !== null); if (contacts.length > 0) { console.log(`Adding ${contacts.length} new contacts`); contacts.forEach((contact) => { contact[ID] = contact.resourceName; }); state.contacts.push(...contacts); } else { console.log("No contacts could be processed"); } console.log( "Successfully processed", allConnections.length, "connections total", ); } const clearContacts = handler( {}, { type: "object", properties: { contacts: { type: "array", items: ContactSchema, default: [], asCell: true, }, }, required: ["contacts"], }, (_event, state) => { state.contacts.set([]); }, ); export default recipe( PeopleImporterInputs, ResultSchema, ({ settings, auth }) => { const contacts = cell([]); derive(contacts, (contacts) => { console.log("contacts", contacts.length); }); return { [NAME]: str`Google People Importer ${ derive(auth, (auth) => auth?.user?.email || "unauthorized") }`, [UI]: (

{auth?.user?.email}

Imported contact count: {derive(contacts, (contacts) => contacts.length)}

Fetch Contacts Clear Contacts
{derive(contacts, (allContacts) => `Showing ${allContacts.length} contact${ allContacts.length !== 1 ? "s" : "" }`)}
{derive(contacts, (allContacts) => allContacts.map((contact) => ( )))}
PHOTO NAME RESOURCE NAME EMAILS PHONE NUMBERS ORGANIZATIONS ADDRESSES BIRTHDAYS BIOGRAPHIES AGE RANGES CALENDAR URLS EVENTS GENDERS IM CLIENTS INTERESTS NICKNAMES OCCUPATIONS RELATIONS SKILLS URLS LOCALES ETAG
{derive(contact, (contact) => { const primaryPhoto = contact?.photos?.find((p) => p.metadata?.primary ) || contact?.photos?.[0]; return primaryPhoto?.url ? ( {contact.displayName} ) : (
{contact.displayName?.[0] || "?"}
); })}
{contact.displayName || "(No display name)"} {derive(contact, (contact) => { const parts = []; if (contact.givenName) { parts.push(`Given: ${contact.givenName}`); } if (contact.middleName) { parts.push(`Middle: ${contact.middleName}`); } if (contact.familyName) { parts.push(`Family: ${contact.familyName}`); } return parts.length > 0 ? (
{parts.map((part, idx) => (
{part}
))}
) : null; })}
{contact.resourceName} {derive(contact, (contact) => contact?.emails?.length > 0 ? ( contact.emails.map((email, idx) => (
{email.value}
{(email.type || email.formattedType) && (
{email.formattedType || email.type}
)}
)) ) : No emails)}
{derive(contact, (contact) => contact?.phoneNumbers?.length > 0 ? ( contact.phoneNumbers.map((phone, idx) => (
{phone.value}
{(phone.type || phone.formattedType) && (
{phone.formattedType || phone.type}
)} {phone.canonicalForm && phone.canonicalForm !== phone.value && (
Canonical: {phone.canonicalForm}
)}
)) ) : No phones)}
{derive(contact, (contact) => contact?.organizations?.length > 0 ? ( contact.organizations.map((org, idx) => (
{org.name && (
{org.name}
)} {org.title && (
{org.title}
)} {org.department && (
Dept: {org.department}
)} {(org.type || org.formattedType) && (
{org.formattedType || org.type}
)}
)) ) : ( No organizations ))}
{derive(contact, (contact) => contact?.addresses?.length > 0 ? ( contact.addresses.map((addr, idx) => (
{addr.formattedValue && (
{addr.formattedValue}
)}
{addr.streetAddress && (
Street: {addr.streetAddress}
)} {addr.city &&
City: {addr.city}
} {addr.region && (
Region: {addr.region}
)} {addr.postalCode && (
Postal: {addr.postalCode}
)} {addr.country && (
Country: {addr.country}
)} {addr.countryCode && (
Code: {addr.countryCode}
)}
{(addr.type || addr.formattedType) && (
{addr.formattedType || addr.type}
)}
)) ) : No addresses)}
{derive(contact, (contact) => contact?.birthdays?.length > 0 ? ( contact.birthdays.map((birthday, idx) => (
{birthday.text || (birthday.date && (
{birthday.date.month || "?"}/{birthday.date.day || "?"} {birthday.date.year && `/${birthday.date.year}`}
)) || No date}
)) ) : No birthdays)}
{derive(contact, (contact) => contact?.biographies?.length > 0 ? ( contact.biographies.map((bio, idx) => (
{bio.value}
{bio.contentType && (
Type: {bio.contentType}
)}
)) ) : No biographies)}
{derive(contact, (contact) => contact?.ageRanges?.length > 0 ? ( contact.ageRanges.map((age, idx) => (
{age.ageRange}
)) ) : No age ranges)}
{derive(contact, (contact) => contact?.calendarUrls?.length > 0 ? ( contact.calendarUrls.map((cal, idx) => ( )) ) : ( No calendar URLs ))} {derive(contact, (contact) => contact?.events?.length > 0 ? ( contact.events.map((event, idx) => (
{event.formattedType || event.type}
{event.date && (
{event.date.month || "?"}/ {event.date.day || "?"} {event.date.year && `/${event.date.year}`}
)}
)) ) : No events)}
{derive(contact, (contact) => contact?.genders?.length > 0 ? ( contact.genders.map((gender, idx) => (
{gender.formattedValue || gender.value} {gender.addressMeAs && (
Address as: {gender.addressMeAs}
)}
)) ) : No gender info)}
{derive(contact, (contact) => contact?.imClients?.length > 0 ? ( contact.imClients.map((im, idx) => (
{im.username}
{im.formattedProtocol || im.protocol} {im.formattedType && ` - ${im.formattedType}`}
)) ) : No IM clients)}
{derive(contact, (contact) => contact?.interests?.length > 0 ? ( contact.interests.map((interest, idx) => (
{interest.value}
)) ) : No interests)}
{derive(contact, (contact) => contact?.nicknames?.length > 0 ? ( contact.nicknames.map((nickname, idx) => (
{nickname.value} {nickname.type && ( {` (${nickname.type})`} )}
)) ) : No nicknames)}
{derive(contact, (contact) => contact?.occupations?.length > 0 ? ( contact.occupations.map((occupation, idx) => (
{occupation.value}
)) ) : No occupations)}
{derive(contact, (contact) => contact?.relations?.length > 0 ? ( contact.relations.map((relation, idx) => (
{relation.person}
{relation.formattedType || relation.type}
)) ) : No relations)}
{derive(contact, (contact) => contact?.skills?.length > 0 ? ( contact.skills.map((skill, idx) => (
{skill.value}
)) ) : No skills)}
{derive(contact, (contact) => contact?.urls?.length > 0 ? ( contact.urls.map((url, idx) => ( )) ) : No URLs)} {derive(contact, (contact) => contact?.locales?.length > 0 ? ( contact.locales.map((locale, idx) => (
{locale.value}
)) ) : No locales)}
{contact.etag || "(No etag)"}
), contacts, }; }, );