Scripting Contacts

OK. This explicitly adds each contact to the specified container (account) and to the specified group there — assuming the Xcode documentation’s correct and I haven’t introduced any more typos when copying from my test script!

use AppleScript version "2.5" -- Mac OS 10.11 (El Capitan) or later.
use framework "Foundation"
use framework "Contacts"
use scripting additions

property preferredAccount : "On My Mac"
property |⌘| : current application

on main()
	set roster to {}
	set NA to "#N/A"
	
	activate application "Contacts"
	
	tell application "Microsoft Excel"
		activate
		set Cohort to string value of cell "Edit!A37"
	end tell
	
	-- Identify the target account (container) and group, creating the latter if necessary.
	set contactStore to |⌘|'s class "CNContactStore"'s new()
	set containerID to setContainer(contactStore)
	set targetGroup to setGroup(contactStore, containerID, Cohort)
	-- Initialise a save request to be added to during the repeat below.
	set saveRequest to |⌘|'s class "CNSaveRequest"'s new()

	tell application "Microsoft Excel"
		repeat with i from 2 to 33
			if string value of cell ("Edit!B" & i) is NA then
				set j to i - 1
				exit repeat
			end if
		end repeat
	end tell
	
	repeat with i from 2 to j
		-- Get data from Excel into a record.
		set TEMP to {key:i - 1, LastName:"", FirstName:"", MI:"", Rank:"", |Hash|:"", |Nickname|:"", Srvc:"", Corps:"", Addressline1:"", |City|:"", |State|:"", |Zip|:"", HomePhone:"", |Email|:"", ParentMilitaryCommand:"", MobilePhone:"", WorkPhone:""}
		tell application "Microsoft Excel"
			set LastName of TEMP to string value of cell ("Edit!B" & i)
			set FirstName of TEMP to string value of cell ("Edit!C" & i)
			set MI of TEMP to string value of cell ("Edit!D" & i)
			set Rank of TEMP to string value of cell ("Edit!F" & i)
			set |Hash| of TEMP to string value of cell ("Edit!G" & i)
			set |Nickname| of TEMP to string value of cell ("Edit!H" & i)
			set Srvc of TEMP to string value of cell ("Edit!J" & i)
			set Corps of TEMP to string value of cell ("Edit!M" & i)
			if Corps of TEMP is NA then set Corps of TEMP to ""
			set Addressline1 of TEMP to string value of cell ("Edit!N" & i)
			try
				if (not cell ("Edit!O" & i) is missing value) and (string value of cell ("Edit!O" & i) ≠ "") then
					set Addressline1 of TEMP to Addressline1 of TEMP & ", " & string value of cell ("Edit!O" & i)
					--AddressLine2
				end if
			end try
			set |City| of TEMP to string value of cell ("Edit!P" & i)
			set |State| of TEMP to string value of cell ("Edit!Q" & i)
			set |Zip| of TEMP to string value of cell ("Edit!R" & i)
			set HomePhone of TEMP to string value of cell ("Edit!S" & i)
			set |Email| of TEMP to string value of cell ("Edit!T" & i)
			set ParentMilitaryCommand of TEMP to string value of cell ("Edit!U" & i)
			set MobilePhone of TEMP to string value of cell ("Edit!V" & i)
			set WorkPhone of TEMP to string value of cell ("Edit!W" & i)
			-- (EmailDomain and rightstr() replaced with 'ends with' in makeStudent() below.)
		end tell
		
		-- Use the data to create a new "student" (person).
		set theStudent to makeStudent(TEMP, Cohort)
		-- Add saving the student to the target account and adding it to the target group to the save request.
		tell saveRequest to addContact:(theStudent) toContainerWithIdentifier:(containerID)
		tell saveRequest to addMember:(theStudent) toGroup:(targetGroup)
		-- Add the student's Contacts ID to the end of the roster list.
		set the end of roster to (theStudent's identifier()) as text
	end repeat
	-- When done, execute all the saves stored in the save request. The results appear in Contacts immediately.
	tell contactStore to executeSaveRequest:(saveRequest) |error|:(reference)
	-- Replace the IDs in 'roster' with the equivalent Contacts 'person' specifiers, which are what it contained in the original script.
	tell application "Contacts"
		repeat with i from 1 to (count roster)
			set item i of roster to person id (item i of roster)
		end repeat
	end tell
	beep
	
	return roster
end main

on setContainer(contactStore)
	-- Ask the user to choose a Contacts account. A container doesn't exactly correspond to an account, but they're close enough for the current purposes.
	set allContainers to contactStore's containersMatchingPredicate:(missing value) |error|:(missing value)
	set containerNames to (allContainers's valueForKey:("name")) as list
	set containerChoice to (choose from list containerNames with prompt "Which Contacts account?" default items {preferredAccount})
	if (containerChoice is false) then error number -128
	
	-- Get the ID of the container with the chosen name.
	set containerFilter to |⌘|'s class "NSPredicate"'s predicateWithFormat:("name == %@") argumentArray:(containerChoice)
	set targetContainer to (allContainers's filteredArrayUsingPredicate:(containerFilter))'s firstObject()
	
	return targetContainer's identifier()
end setContainer

on setGroup(contactStore, containerID, groupName)
	-- Get the names of the existing groups in this container.
	set groupFilter to |⌘|'s class "CNGroup"'s predicateForGroupsInContainerWithIdentifier:(containerID)
	set groupsInThisContainer to contactStore's groupsMatchingPredicate:(groupFilter) |error|:(missing value)
	set groupNames to groupsInThisContainer's valueForKey:("name")
	
	-- If the name passed to this handler is amongst them, get the corresponding group. Otherwise create a new one.
	-- NB. DUPLICATE NAMES ARE ALLOWED IN CONTACTS. IF THE CONTAINER CONTAINS MORE THAN ONE GROUP WITH THE SAME NAME, THIS MAY NOT RETURN THE CORRECT ONE.
	if (groupNames's containsObject:(groupName)) then
		-- Get the (first) existing group with the given name.
		set groupFilter to |⌘|'s class "NSPredicate"'s predicateWithFormat_("name == %@", groupName)
		set targetGroup to (groupsInThisContainer's filteredArrayUsingPredicate:(groupFilter))'s firstObject()
	else
		-- Create a new one.
		set targetGroup to |⌘|'s class "CNMutableGroup"'s new()
		tell targetGroup to setName:(groupName)
		-- Set up a request to save it in the container chosen above.
		set saveRequest to |⌘|'s class "CNSaveRequest"'s new()
		tell saveRequest to addGroup:(targetGroup) toContainerWithIdentifier:(containerID)
		-- Execute the request. The new group appears in the Contacts application instantly (on my machine) without having to quit and reopen it.
		tell contactStore to executeSaveRequest:(saveRequest) |error|:(missing value)
	end if
	
	-- Return the group (a CNMutableGroup).
	return targetGroup
end setGroup

on makeStudent(TEMP, groupName)
	-- Make a new contact and set its simpler properties.
	set thisPerson to |⌘|'s class "CNMutableContact"'s new()
	tell thisPerson to setFamilyName:(TEMP's LastName)
	tell thisPerson to setGivenName:(TEMP's FirstName)
	tell thisPerson to setMiddleName:(TEMP's MI)
	tell thisPerson to setNamePrefix:(TEMP's Rank)
	tell thisPerson to setNote:("Cohort: " & groupName & linefeed & "Hash: " & TEMP's |Hash|)
	tell thisPerson to setNickname:(TEMP's |Nickname|)
	tell thisPerson to setNameSuffix:(TEMP's Srvc)
	tell thisPerson to setJobTitle:(TEMP's Corps)
	tell thisPerson to setOrganizationName:(TEMP's ParentMilitaryCommand)
	
	-- Set an array for the e-mail.
	set emailArray to |⌘|'s class "NSMutableArray"'s new()
	set emailAddress to TEMP's |Email|
	if (emailAddress > "") then
		if ((emailAddress ends with ".mil") or (emailAddress ends with ".gov")) then
			set emailLabel to "Work"
		else
			set emailLabel to "Home"
		end if
		tell emailArray to addObject:(my makeEmail(emailLabel, emailAddress))
	end if
	tell thisPerson to setEmailAddresses:(emailArray)
	-- An array for the phones.
	set phoneArray to |⌘|'s class "NSMutableArray"'s new()
	if (TEMP's WorkPhone > "") then tell phoneArray to addObject:(my makePhone("Work", TEMP's WorkPhone))
	if (TEMP's MobilePhone > "") then tell phoneArray to addObject:(my makePhone("Mobile", TEMP's MobilePhone))
	if (TEMP's HomePhone > "") then tell phoneArray to addObject:(my makePhone("Home", TEMP's HomePhone))
	tell thisPerson to setPhoneNumbers:(phoneArray)
	-- An array for the address.	
	set addressArray to |⌘|'s class "NSMutableArray"'s new()
	set addressRecord to {street:TEMP's Addressline1, city:TEMP's |City|, state:TEMP's |State|, postalCode:TEMP's |Zip|}
	if (addressRecord is not {street:"", city:"", state:"", postalCode:""}) then tell addressArray to addObject:(my makeAddress("Home", addressRecord))
	tell thisPerson to setPostalAddresses:(addressArray)
	
	return thisPerson
end makeStudent

on makeEmail(label, emailAddress)
	if (emailAddress is "") then return false
	
	return |⌘|'s class "CNLabeledValue"'s labeledValueWithLabel:(label) value:(emailAddress)
end makeEmail

on makePhone(label, phoneNumber)
	if (phoneNumber is "") then return false
	
	set thisPhone to |⌘|'s class "CNPhoneNumber"'s phoneNumberWithStringValue:(phoneNumber)
	return |⌘|'s class "CNLabeledValue"'s labeledValueWithLabel:(label) value:(thisPhone)
end makePhone

on makeAddress(label, addressRecord)
	set emptyValues to {street:"", city:"", state:"", postalCode:"", country:"", countryCode:"", subAdministrativeArea:"", subLocality:""}
	set addressRecord to addressRecord & emptyValues
	if (addressRecord is emptyValues) then return false
	
	set thisAddress to |⌘|'s class "CNPostalAddress"'s new()
	tell thisAddress to setStreet:(addressRecord's street)
	tell thisAddress to setCity:(addressRecord's city)
	tell thisAddress to setState:(addressRecord's state)
	tell thisAddress to setPostalCode:(addressRecord's postalCode)
	tell thisAddress to setCountry:(addressRecord's country)
	tell thisAddress to setISOCountryCode:(addressRecord's countryCode)
	-- The following two properties were introduced in 10.12.4 (Sierra) and still have no equivalents in Contacts 12.0 in Mojave (10.14).
	-- Require Mojave or later to set them.
	considering numeric strings
		if (AppleScript's version ≥ "2.7") then
			tell thisAddress to setSubAdministrativeArea:(addressRecord's subAdministrativeArea)
			tell thisAddress to setSubLocality:(addressRecord's subLocality)
		end if
	end considering
	
	return |⌘|'s class "CNLabeledValue"'s labeledValueWithLabel:(label) value:(thisAddress)
end makeAddress

Edit: Modified to make the makeEmail(), makePhone(), and makeAddress() handlers usable more generally. Typos corrected. :rolleyes:

Hello Nigel —

Normally, when I run the script, I have this version of Excel running

Macintosh SSD:Applications:Office 2011:Microsoft Excel.app

with the applicable spreadsheet open. Now, the script is throwing errors after launching

Macintosh SSD:Applications:Microsoft Excel.app

(Excel 2016) which doesn’t have a spreadsheet open, etc, which is a new wrinkle. I’m in syntactical knots trying to specify the running version, so I thought I’d ask. My workaround has been to ask Microsoft Excel if it is running, and then doing nothing. I do sense a lack of elegance. :slight_smile:

Need to set variable j, which you may not have in your test suite. I combined at the top.


tell application "Microsoft Excel"
	activate
	set Cohort to string value of cell "Edit!A37"
	repeat with i from 2 to 33
		if string value of cell ("Edit!B" & i) is NA then
			set j to i - 1
			exit repeat
		end if
	end repeat
end tell

Getting an error at my line 75 on “set the end of roster to (theStudent’s indentifier()) as text”
-[CNMutableContact indentifier]: unrecognized selector sent to instance 0x7fa242f98db0

I commented it out, and added 20 students to the cohort group ON MY MAC! Apologies for the delay.

…best, Mick

Hi Mick.

Sorry about the typos after all that. I’ve now corrected them above.

Normally if you have two or more versions of an application on the same machine, and they all have the same name, a script will address the one that’s open at the time. Otherwise it’ll go for the newest version. With Numbers, I force scripts to use the ’09 version by including the path to it in the ‘tell’ line. Perhaps this would work for you with Excel:

tell application "Macintosh SSD:Applications:Office 2011:Microsoft Excel.app"
	activate
	
	-- etc.
end tell

Yeah. Sorry about that. I don’t have Excel, so I just set a hard value for j. Obviously I overlooked restoring your j-setting repeat when integrating my new code into your original script.

“indentifier” should of course have been “identifier”. Your original script adds each Contacts ‘person’ created to your ‘roster’ list. I don’t know what that’s for, but ‘theStudent’ in my code is a “Contacts” framework ‘CNContact’, not a Contacts application ‘person’, so I’ve stored the contact IDs instead and tell the Contacts application to replace them with the corresponding ‘persons’ later. I’m not sure how useful that is, but anyway, it’s there. :slight_smile:

Nigel —

Studying what you’ve done is going to take some time, so I’m waiting for a wintery day, maybe a wintery month, to do it. But much thanks. I’ve got exactly what I wanted. And thank you, too, for all that you do around here.

…best regards, Mick