Localizing GUI scripts

GUI scripting often relies on referring to interface elements by name. Those names vary in different locales, and as a consequence, such scripts often don’t travel well.

The solution to this used to work reasonably well. Widgets had a US English name, and the localized string command could be used to find the localized equivalent. This was done by looking up .strings files, which typically used the original US English names as keys to the translations. The translation only had to be done on other systems.

In recent years Apple has introduced a slightly different method of handling localization. It makes it a bit easier for developers in some areas, but harder for scripters. In this scheme, the keys or base names of most widgets are usually machine-generated and meaningless, rather than simply the US English version. When an app is launched, all versions substitute the localized names that appear.

To make GUI scripts that travel, instead of passing the US English version of a name to the localized string command, you have to pass the now largely meaningless key. That makes for pretty ugly and unhelpful code, plus a tedious process of looking up strings files to find them. And they can change, even when the label that appears when you run the app remains the same.

Fortunately, the issue can be scripted around. The code below has a main handler that takes a string, the language it is in, the language you want it in, the app or framework it is found in, and the name of the .strings file. It also contains some simpler handlers based on this. The simplest, localizedStringForEnglish:inBundle:, can be used the way localized string used to be used.

The script works by looking up the string you pass in the .strings file for its language, gets the key used for it, then uses that key to look up the value in the destination language’s .strings file. The contents of the .strings files is cached in a property, to provide a modest performance boost when performing multiple translations. it would be sensible to store it as a script library.

The process is not foolproof: it’s possible that multiple items in unrelated dialog use the same translation in one language but not another, and the script just uses the first it finds. But it should cope with most cases.

Take 2:

use AppleScript version "2.5" -- macOS 10.11 or later
use framework "Foundation"
use scripting additions
property dictCache : missing value -- we cache strings dictionaries here to speed multiple look-ups a bit

-- use this if you have the English name and it is in the standard Localized.strings file
on localizedStringForEnglish:enString inBundle:fileOrNSURL
	set langID to current application's NSLocale's currentLocale()'s localeIdentifier()
	return my localizedStringFor:enString fromTable:"" inBundle:fileOrNSURL destLang:langID sourceLang:"en"
end localizedStringForEnglish:inBundle:

-- use this if you want to pass a non-English name and it is in the standard Localized.strings file; langID should contain the ISO locale identifier for the string (eg, "en_GB", "fr", "fr_FR")
on localizedStringFor:baseString inBundle:fileOrNSURL sourceLang:langID
	return my localizedStringFor:baseString fromTable:"" inBundle:fileOrNSURL destLang:"" sourceLang:langID
end localizedStringFor:inBundle:sourceLang:

-- use this when writing scripts if you just want to find out the English translation on a non-English system and the value is in the standard Localized.strings file
on enStringFor:baseString inBundle:fileOrNSURL
	set langID to current application's NSLocale's currentLocale()'s localeIdentifier()
	return my localizedStringFor:baseString fromTable:"" inBundle:fileOrNSURL destLang:"" sourceLang:langID
end enStringFor:inBundle:

-- use this if you also want to specify a particular .strings file. A destLangCode of "" means the current locale, and a stringsFileName of "" means the default Localizable.strings file
on localizedStringFor:baseString fromTable:stringsFileName inBundle:fileOrNSURL destLang:destLangCode sourceLang:sourceLangCode
	if stringsFileName is "" then set stringsFileName to "Localizable"
	set theBundle to current application's NSBundle's bundleWithURL:fileOrNSURL
	set sourceLangString to current application's NSString's stringWithString:sourceLangCode
	-- make key to search for cached values
	set cacheKey to current application's NSString's stringWithFormat_("%@_%@_%@", theBundle's bundleIdentifier(), stringsFileName, sourceLangCode)
	if my dictCache is missing value then set my dictCache to current application's NSMutableDictionary's dictionary()
	-- get source strings values
	set sourceDict to dictCache's objectForKey:cacheKey
	if sourceDict = missing value then
		set sourceURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:sourceLangString
		if sourceURL is missing value and (sourceLangString's containsString:"_") as boolean then
			-- try stripping off country-specific part
			set sourceLangString to sourceLangString's substringToIndex:2
			set sourceURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:sourceLangString
		end if
		if sourceURL is missing value then
			-- try long name for localization
			set sourceLangString to (current application's NSLocale's localeWithLocaleIdentifier:"en")'s localizedStringForLocaleIdentifier:sourceLangString
			set sourceURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:sourceLangString
		end if
		if sourceURL is missing value then error "No " & sourceLangCode & " localization found"
		set theData to current application's NSData's alloc()'s initWithContentsOfURL:sourceURL
		if theData is missing value then error "No " & sourceLangCode & " localization found"
		set sourceDict to (current application's NSPropertyListSerialization's propertyListWithData:theData options:0 format:0 |error|:(missing value))
		dictCache's setObject:sourceDict forKey:cacheKey
	end if
	-- make key to search for cached values
	set cacheKey to current application's NSString's stringWithFormat_("%@_%@_%@", theBundle's bundleIdentifier(), stringsFileName, destLangCode)
	-- get dest strings values
	set destDict to dictCache's objectForKey:cacheKey
	if destDict = missing value then
		if destLangCode is "" then
			set destLangString to current application's NSLocale's currentLocale()'s localeIdentifier()
		else
			set destLangString to current application's NSString's stringWithString:destLangCode
		end if
		set localURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString
		if localURL is missing value and (destLangString's containsString:"_") as boolean then
			-- try stripping off country-specific part
			set destLangString to destLangString's substringToIndex:2
			set localURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString
		end if
		if localURL is missing value then
			-- try long name for localization
			set destLangString to (current application's NSLocale's localeWithLocaleIdentifier:"en")'s localizedStringForLocaleIdentifier:destLangString
			set localURL to theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString
		end if
		if localURL is missing value then error "No " & destLangCode & " localization found"
		
		set theData to current application's NSData's alloc()'s initWithContentsOfURL:localURL
		if theData is missing value then error "No " & destLangCode & " localization found"
		set destDict to (current application's NSPropertyListSerialization's propertyListWithData:theData options:0 format:0 |error|:(missing value))
		dictCache's setObject:destDict forKey:cacheKey
	end if
	-- convert from source to dest
	set destValue to destDict's objectForKey:((sourceDict's allKeysForObject:baseString)'s firstObject())
	if destValue is not missing value then set destValue to destValue as text
	return destValue
end localizedStringFor:fromTable:inBundle:destLang:sourceLang:

-- example:
set theURL to path to application "Finder"
set deComments to my localizedStringFor:"Comments" fromTable:"" inBundle:theURL destLang:"de" sourceLang:"en"
set localComments to my localizedStringForEnglish:"Comments" inBundle:theURL
set enComments to my localizedStringFor:deComments fromTable:"" inBundle:theURL destLang:"en" sourceLang:"de"
return {deComments, localComments, enComments}
--> {"Kommentare", "Comments", "Comments"}
2 Likes

The above is all and good, but there’s also the issue of which .strings file a value appears in. When writing scripts, you can use this script to search through all .strings files in an application of framework, until it finds a match — it will then return the translated string, plus the name of the .strings file it was found in.

Take 2:

use AppleScript version "2.5" -- macOS 10.11 or later
use framework "Foundation"
use scripting additions
property dictCache : missing value -- we cache strings dictionaries here to speed multiple lok-ups a bit

-- use this if you want to search all available .strings files. Returns a list containing the localized string plus the name of the .strings file it's in (less extension), or missing value if it is not found.
on localizedStringFor:baseString inBundle:fileOrNSURL destLang:destLangCode sourceLang:sourceLangCode
	set theBundle to current application's NSBundle's bundleWithURL:fileOrNSURL
	set sourceLangString to current application's NSString's stringWithString:sourceLangCode
	set destLangString to current application's NSString's stringWithString:destLangCode
	-- get source strings values
	set theURLs to theBundle's URLsForResourcesWithExtension:"strings" subdirectory:"" localization:sourceLangString
	if theURLs's |count|() < 2 and (sourceLangString's containsString:"_") as boolean then
		-- try stripping off country-specific part
		set sourceLangString to sourceLangString's substringToIndex:2
		set theURLs to theBundle's URLsForResourcesWithExtension:"strings" subdirectory:"" localization:sourceLangString
	end if
	if theURLs's |count|() < 2 then
		-- try long name for localization
		set sourceLangString to (current application's NSLocale's localeWithLocaleIdentifier:"en")'s localizedStringForLocaleIdentifier:sourceLangString
		set theURLs to theBundle's URLsForResourcesWithExtension:"strings" subdirectory:"" localization:sourceLangString
	end if
	if theURLs's |count|() < 2 then error "No " & sourceLangCode & " localization found"
	repeat with sourceURL in theURLs
		-- skip unlocalized file
		if not (sourceURL's URLByDeletingLastPathComponent()'s lastPathComponent()'s isEqualToString:"Resources") as boolean then
			set theData to (current application's NSData's alloc()'s initWithContentsOfURL:sourceURL)
			if theData is missing value then error "No " & sourceLangCode & " localization found"
			set sourceDict to (current application's NSPropertyListSerialization's propertyListWithData:theData options:0 format:0 |error|:(missing value))
			set theKey to (sourceDict's allKeysForObject:baseString)'s firstObject()
			if theKey is not missing value then
				set stringsFileName to sourceURL's lastPathComponent()'s stringByDeletingPathExtension()
				set localURL to (theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString)
				if localURL is missing value and (destLangString's containsString:"_") as boolean then
					-- try stripping off country-specific part
					set destLangString to (destLangString's substringToIndex:2)
					set localURL to (theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString)
				end if
				if localURL is missing value then
					-- try long name for localization
					set destLangString to ((current application's NSLocale's localeWithLocaleIdentifier:"en")'s localizedStringForLocaleIdentifier:destLangString)
					set localURL to (theBundle's URLForResource:stringsFileName withExtension:"strings" subdirectory:"" localization:destLangString)
				end if
				if localURL is missing value then error "No " & destLangCode & " localization found"
				
				set theData to (current application's NSData's alloc()'s initWithContentsOfURL:localURL)
				if theData is missing value then error "No " & destLangCode & " localization found"
				set destDict to (current application's NSPropertyListSerialization's propertyListWithData:theData options:0 format:0 |error|:(missing value))
				set destValue to (destDict's objectForKey:theKey)
				if destValue is not missing value then return {destValue as text, stringsFileName as text}
				return missing value
			end if
		end if
	end repeat
	return missing value
end localizedStringFor:inBundle:destLang:sourceLang:

-- example:
set theURL to path to application "Finder"
my localizedStringFor:"System Preferences" inBundle:theURL destLang:"fr" sourceLang:"en"
--> {"Préférences Système", "SimpleGrouping"}
2 Likes

Hi Shane.

I’m getting the scripted error in localizedStringFor:fromTable:inBundle:destLang:sourceLang: from all three calls in the first script. The second script’s returning missing value.

Script Debugger 7.0.10; macOS 10.14.6; en_GB.

Variable values leading up to the error in the first script:
theBundle : NSBundle </System/Library/CoreServices/Finder.app> (not yet loaded)
sourceLangString : (NSString) “en” or (NSString) “de”
cacheKey : (NSString) “com.apple.finder_Localizable_en” or (NSString) “com.apple.finder_Localizable_de”
dictCache : (NSDictionary) {}
sourceURL : missing value
theData : missing value

(This site’s “Open in Script Debugger” button seems to have gone again!)

Hmmm. What happens if you just run this:

use AppleScript version "2.5" -- macOS 10.11 or later
use framework "Foundation"
use scripting additions

set theURL to path to application "Finder"
set theBundle to current application's NSBundle's bundleWithURL:theURL
set sourceURL to theBundle's URLForResource:"Localizable" withExtension:"strings" subdirectory:"" localization:"en"
missing value

On looking into my Finder’s Resources folder, I see that none of the .lproj folders there are named “en.lproj” or “de.lproj”, although most of the others are named in that fashion. I have “English.lproj”, “en_GB.lproj”, en_AU.lproj", and “German.lproj”. There are also full names for Dutch, Italian, Japanese, French, Spanish, and “Base”. Substituting the “.lproj”-less forms of these names into your first script makes it work.

Ah, so they were still using the now-discouraged full localization names. That explains it — now to think about a workaround.

Nigel, you might see if the modified version above works there.

Hi Shane.

Yes. Both scripts seem to work very well here now. So far they’ve been accepting language input in a variety of forms as follows:

• Any language code for whose language an “lproj” folder exists in the Resources folder.
• Any language_COUNTRY code combination for whose language an “lproj” folder exists, but the country code may be any random sequence of characters.
• Any language name, in English, corresponding to an “lproj” folder named in this way in the Resources folder.
• Any of the above in any combination of cases.

Thanks, Nigel. While it’s nice they accept all those forms, ideally they should just work reliably with the two-letter ISO codes, so the user doesn’t have to know what names are used.