User record property labels as text

regulus633 has demonstrated a short method of getting text representations of user record property labels. (If you need to do this, you’re not using AppleScript properly; but the question keeps coming up.)

set theRecord to {firstName:"John", lastName:"Smith"}

tell application "Automator Runner"
	set keysList to call method "allKeys" of theRecord
end tell

If the record contains any reserved labels, the script renders them in the returned list as the integer values of their four-byte compiled tokens.

It’s rather irksome having to script an application just for this purpose, especially if you don’t normally have the application open. The time taken to launch it and quit it can make the script quite slow. So here’s a script which uses vanilla methods to the same effect. It uses the File Read/Write commands to write the record to a temporary file and to parse the file’s contents:

on allKeys(rec)
	script o
		property keyList : {}
		
		-- Parse the temporary file for top-level record labels.
		on parseAllLabels(fRef) -- Enter here having skipped "reco" at the beginning of the file.
			-- The next four bytes give the number of reserved-label properties in the record. Properties with user labels are grouped togther as a list value with the reserved label "usrf".
			repeat (read fRef for 4 as integer) times
				set labelKey to (read fRef for 4 as integer) -- Get each reserved label as a 4-byte integer value. 
				if (labelKey is 1.970500198E+9) then -- If it's "usrf", extract the user label(s).
					parseUserLabels(fRef)
				else -- Otherwise store the integer and wind on to the next label.
					set end of my keyList to labelKey
					skipValue(fRef)
				end if
			end repeat
		end parseAllLabels
		
		-- Extract user labels from the "usrf" list.
		on parseUserLabels(fRef) -- Enter here having just read "usrf".
			read fRef for 4 -- Skip the next four bytes ("list").
			-- The four bytes after that give the number of items in the list. The odd-numbered items are the labels and the even-numbered ones the values.
			repeat (read fRef for 4 as integer) div 2 times
				set labelTextClass to (read fRef for 4 as string)
				set labelByteLength to (read fRef for 4 as integer)
				if (labelTextClass is "utxt") then -- Unicode text in Snow Leopard (and Leopard?).
					set end of my keyList to (read fRef for labelByteLength as Unicode text)
				else -- "TEXT" in Tiger.
					set end of my keyList to (read fRef for labelByteLength as string)
					-- Skip any padding of the label in the file to an even number of bytes.
					read fRef for (labelByteLength mod 2)
				end if
				skipValue(fRef)
			end repeat
		end parseUserLabels
		
		-- Recursively coax the file mark past the value of a property in the file,
		on skipValue(fRef)
			set valueClass to (read fRef for 4 as string) -- Get the value class code.
			if ((valueClass is "reco") or (valueClass is "list")) then
				repeat (read fRef for 4 as integer) times -- Skip each entry in a subordinate record or list.
					if (valueClass is "reco") then read fRef for 4 -- Skip the reserved label if in a record.
					skipValue(fRef)
				end repeat
			else
				set valueByteLength to (read fRef for 4 as integer)
				read fRef for (valueByteLength + valueByteLength mod 2) -- Skip the value and any padding.
			end if
		end skipValue
	end script
	
	-- Open a temporary file to which to write the record and from which to parse it.
	set fRef to (open for access file ((path to temporary items as Unicode text) & "Rec.dat") with write permission)
	try
		set eof fRef to 0
		write rec to fRef
		read fRef from 5 for 0 -- Set the file mark to 5 to skip the opening "reco".
		o's parseAllLabels(fRef) -- Parse the labels.
	on error msg
		display dialog msg
	end try
	close access fRef
	
	return o's keyList
end allKeys

set theRecord to {firstName:"John", otherNames:{"Aardvark", "Lazenby"}, lastName:"Smith"}
allKeys(theRecord)

The script advances the file mark past information it doesn’t need by simply reading the information from the file (and then ignoring it). It might be more efficient to maintain an ongoing calculation of the file mark and just read what’s actually needed with the help of 'read’s ‘from’ parameter.

That’s pretty amazing Nigel. It really shows a lot of knowledge. I’m impressed.

Here’s another way to do it using the “try” block…

local aRecord, recinfo, anitem, tid
set aRecord to {firstName:"John", otherNames:"Aardvark", lastName:"Smith"}
try
	aRecord as text
on error recinfo
	set recinfo to text 13 thru -18 of recinfo
end try
set tid to text item delimiters
set text item delimiters to ":"
set recinfo to items 1 thru -2 of text items of recinfo
set text item delimiters to tid
repeat with anitem in recinfo
	set contents of anitem to last word of anitem
end repeat
return recinfo
2 Likes

Nowadays, if the record’s one dimensional and only has one-word user labels, you could do this:

use framework "Foundation"

on allKeys(rec)
	return (current application's class "NSDictionary"'s dictionaryWithDictionary:(rec))'s allKeys() as list
end allKeys

set theRecord to {firstName:"John", otherNames:{"Aardvark", "Lazenby"}, lastName:"Smith"}
allKeys(theRecord)

But Robert’s suggestion also copes with nested records and reserved labels. Not multi-word labels, however. Maybe something like this would do:

use framework "Foundation"

on allKeys(rec)
	try
		rec as text
	on error recinfo
		set recinfo to current application's class "NSMutableString"'s stringWithString:(recinfo)
	end try
	recinfo's replaceOccurrencesOfString:("\"[^\"]*+\"") withString:"" options:(current application's NSRegularExpressionSearch) range:({0, recinfo's |length|()})
	set regex to current application's class "NSRegularExpression"'s regularExpressionWithPattern:("(?:\\{|, )([^:\\}]++):") options:(0) |error|:(missing value)
	set keyMatches to regex's matchesInString:(recinfo) options:(0) range:({0, recinfo's |length|()})
	set theKeys to {}
	repeat with thisMatch in keyMatches
		set theKeys's end to (recinfo's substringWithRange:(thisMatch's rangeAtIndex:(1))) as text
	end repeat
	return theKeys
end allKeys

set theRecord to {name:"John", otherNames:{"Aardvark", "Lazenby"}, |last name|:"Smith", nestedList:{1, 2}, nestedRecord:{a:1, b:2}, boobyTrap:"{a:1, b:2}"}
allKeys(theRecord)

Nice! Now I’m pondering if it’s possible to return…

{"name", "otherNames", "|last name|", "nestedList", "nestedRecord", "a of nestedRecord", "b of nestedRecord", "boobyTrap"}

I have a script that will parse sub-records also. It is pure AppleScript.

local aRecord, aRecordAsText, theKeys, startChar, endChar, f, p
set aRecord to {otherNames:{mate:{related:"sister"}, animal:{Safer:"Aardvark", Dangerous:"Elephant"}}, lastname:"Banks, Jr", firstName:"John, \"M", myNums:5}
try
	set aRecordAsList to aRecord as text
on error aRecordAsText
	get aRecordAsText
end try
set aRecordAsText to text 13 thru -18 of aRecordAsText
parseRec(aRecordAsText)

on parseRec(recText)
	local theKeys, startChar, endChar, subtext, c, e, f, p, ch, fo, so, d, tid
	set startChar to 0
	set endChar to startChar
	set theKeys to {}
	set f to 0 -- skip records and text flag
	set ch to ""
	set d to 0 -- record depth
	set c to 1
	repeat until c > length of recText
		set p to ch
		set ch to text c of recText
		if f = 3 then -- inside quote
			if ch = "\"" then
				if p ≠ "\\" then
					set f to 1
				end if
			end if
		else if f = 2 then -- inside record
			if ch = "}" then set f to 1
		else if f = 1 then -- at key
			if ch = "\"" then
				set f to 3
			else if ch = "{" then -- is sub-list or sub-record
				set f to 2
				set e to c
				set d to d + 1
				repeat until d = 0
					set subtext to text (e + 1) thru -1 of recText
					set fo to offset of "{" in subtext
					set so to offset of "}" in subtext
					if fo = 0 or so < fo then -- no sub-rec
						set d to d - 1
						set e to e + so
					else
						set d to d + 1
						set e to e + fo
					end if
				end repeat
				set subtext to text (c + 1) thru (e - 1) of recText
				set fo to offset of ":" in subtext
				if fo ≠ 0 then -- test if list or record
					set so to offset of "}" in subtext
					if so = 0 or so > fo then -- is record
						set end of theKeys to parseRec(subtext)
						set f to 1
					end if
				end if
				set c to e
				set ch to text c of recText
			else if ch = "," then
				set f to 0
			end if
		else
			if ch = "\"" then
				set f to 3
			else if ch = "{" then
				set f to 2
			else if ch = ":" then
				set end of theKeys to text startChar thru (c - 1) of recText
				set f to 1
				set startChar to 0
			else if startChar = 0 then
				if ch = "," then
					set f to 3
				else if f = 0 and ch ≠ " " then
					set startChar to c
				end if
			end if
		end if
		set c to c + 1
	end repeat
	return theKeys
end parseRec
2 Likes

It seems that it should be possible to use ASObjC to work with nested records either with a loop or recursive handler, but I have no idea how to write this. As Nigel has already noted, the following is a simple solution if the record is one-dimensional:

use framework "Foundation"
use scripting additions

set theRecord to {|name|:"John", otherNames:{"Aardvark", "Lazenby"}, |last name|:"Smith", nestedList:{1, 2}, nestedRecord:{a:1, b:2}, boobyTrap:"{a:1, b:2}"}
set theDictionary to current application's NSDictionary's dictionaryWithDictionary:theRecord
set theKeys to (theDictionary's allKeys()) as list --> {"nestedRecord", "otherNames", "last name", "boobyTrap", "name", "nestedList"}

Here is a recursive handler, although the conversion to NSDictionary doesn’t like using properties for the record keys:

use framework "Foundation"
use scripting additions

set theRecord to {|name|:"John", otherNames:{"Aardvark", "Lazenby"}, |last name|:"Smith", nestedList:[1, 2], nestedRecord:{a:{one:"one", two:{three:"three", four:"four"}}, b:2}, boobyTrap:"{a:1, b:2}"}
getKeys from theRecord -- with hierarchyText

# Record keys that are properties or otherwise illegal (spaces, etc) must be escaped with |pipes|.
to getKeys from (dictionary as record) given hierarchyText:(hierarchyText as boolean) : false
    set results to {}
    set dictionary to current application's NSDictionary's dictionaryWithDictionary:dictionary
    set theKeys to (dictionary's allKeys())'s sortedArrayUsingSelector:"localizedStandardCompare:"
    repeat with aKey in theKeys
        set aKey to aKey as text
        set object to (dictionary's objectForKey:aKey)
        if (current application's NSStringFromClass(object's |class|()) as text) contains "Dictionary" then
            set theKeys to (getKeys from object given hierarchyText:hierarchyText)
            if hierarchyText then -- include item hierarchy
                set end of results to aKey
                repeat with anItem in theKeys
                    set end of results to (anItem as text) & " of " & aKey
                end repeat
            else
                set end of results to aKey
                set end of results to theKeys
                -- or --
                -- set end of results to {aKey, theKeys}
            end if
        else
            set end of results to aKey
        end if
    end repeat
    return results
end getKeys
1 Like

Here’s another ASObjC effort. The logic’s similar to red_menace’s, but is differently coded. There’s also an effort to perform with records nested in list values.

use framework "Foundation"
use scripting additions

set aRecord to {otherNames:{mate:{related:"sister"}, animal:{Safer:"Aardvark", Dangerous:"Elephant"}}, listContainingrecords:{1, 2, {a:1, b:2}, 3, {c:1, d:2}, "Hello"}, lastname:"Banks, Jr", firstName:"John, \"M", myNums:5}
return allLabels(aRecord)

on allLabels(theRecord)
	script o
		property dict : current application's class "NSDictionary"'s dictionaryWithDictionary:(theRecord)
		property output : {}
		
		on recurse(currDict, parentList)
			set theKeys to currDict's allKeys()
			set theValues to currDict's objectsForKeys:(theKeys) notFoundMarker:(null) -- AS null, not ObjC null.
			set subList to {}
			set parentList's end to subList
			repeat with i from 1 to (count theKeys)
				set subList's end to (theKeys's item i) as text
				set thisValue to theValues's item i
				if (thisValue's isKindOfClass:(current application's class "NSDictionary")) then
					recurse(thisValue, subList)
				else if (thisValue's isKindOfClass:(current application's class "NSArray")) then
					set subsublist to {}
					set end of subList to subsublist
					repeat with listedValue in thisValue
						if (listedValue's isKindOfClass:(current application's class "NSDictionary")) then recurse(listedValue, subsublist)
					end repeat
				end if
			end repeat
		end recurse
	end script
	
	o's recurse(o's dict, o's output)
	return o's output's beginning
end allLabels
1 Like

This seems to work:

use framework "Foundation"
use scripting additions

set aRecord to {otherNames:{mate:{related:"sister"}, animal:{Safer:"Aardvark", Dangerous:"Elephant"}}, listContainingrecords:{1, 2, {a:1, b:2}, 3, {c:1, d:{4, 5, {e:missing value}}}, "Hello"}, lastname:"Banks, Jr", firstName:"John, \"M", myNums:5}
return allLabels(aRecord)

on allLabels(theRecord)
	script o
		property dict : current application's class "NSDictionary"'s dictionaryWithDictionary:(theRecord)
		property output : {}
		
		on recurse(currDict, parentText)
			set theKeys to currDict's allKeys()
			set theValues to currDict's objectsForKeys:(theKeys) notFoundMarker:(null) -- AS null, not ObjC null.
			repeat with i from 1 to (count theKeys)
				set newParentText to (theKeys's item i as text) & parentText
				set my output's end to newParentText
				set thisValue to theValues's item i
				if (thisValue's isKindOfClass:(current application's class "NSDictionary")) then
					recurse(thisValue, " of " & newParentText)
				else if (thisValue's isKindOfClass:(current application's class "NSArray")) then
					set recordCounter to 0
					repeat with listedValue in thisValue
						if (listedValue's isKindOfClass:(current application's class "NSDictionary")) then
							set recordCounter to recordCounter + 1
							recurse(listedValue, " of record " & recordCounter & " in list value of " & newParentText)
						end if
					end repeat
				end if
			end repeat
		end recurse
	end script
	
	o's recurse(o's dict, "")
	return o's output
end allLabels