Local time for any latitude/longitude location

I found that getting the local time for any arbitrary location programmatically was more challenging than it might seem at first glance. The following handler takes as input any latitude and longitude location in decimal form and returns the current local time for that location as an Applescript date.

The handler effects its actions via a shell script that (1) gets raw timezone and daylight savings time data from the Google Maps API, (2) parses the result with the Python json module, and (3) converts the data to local time with Bash’s date command.

The latitude and longitude values should be supplied to the handler as text strings rather than integer or real numbers to avoid the possibility of their being represented in exponential notation (which will fail) when converted to text by the handler.


on localTime(latitudeInDecimalForm, longitudeInDecimalForm)
	set {localMonth, localDay, localYear, localHour, localMinute, localSecond} to (do shell script "" & ¬
		"epochTime=$(date +%s)" & linefeed & ¬
		"x=$(curl -LNsf \"https://maps.googleapis.com/maps/api/timezone/json?location=" & latitudeInDecimalForm & "," & longitudeInDecimalForm & "&" & "timestamp=$epochTime\" | python -c '" & linefeed & ¬
		"import sys, json" & linefeed & ¬
		"y=json.load(sys.stdin)" & linefeed & ¬
		"print(y[\"dstOffset\"])" & linefeed & ¬
		"print(y[\"rawOffset\"])" & linefeed & ¬
		"sys.exit()" & linefeed & ¬
		"')" & linefeed & ¬
		"dstOffset=$(sed -En \"1p\" <<<\"$x\")" & linefeed & ¬
		"rawOffset=$(sed -En \"2p\" <<<\"$x\")" & linefeed & ¬
		"TZ=\"Etc/GMT\" date -j -r $(($epochTime + dstOffset + rawOffset)) \"+%m %d %Y %k %M %S\"")'s words
	copy (current date) to localDateTime
	tell localDateTime to set {its month, its day, its year, its hours, its minutes, its seconds} to {localMonth, localDay, localYear, localHour, localMinute, localSecond}
	return localDateTime
end localTime

Examples:


localTime("37.79735", "-122.465891") -- San Francisco, California: date "Tuesday, September 5, 2017 at 7:40:06 AM"
localTime("40.735662", "-73.989327") -- New York, New York: date "Tuesday, September 5, 2017 at 10:40:06 AM"
localTime("51.506767", "-0.12907") -- London, England: date "Tuesday, September 5, 2017 at 3:40:06 PM",
localTime("35.729599", "139.73346") -- Tokyo, Japan: date "Tuesday, September 5, 2017 at 11:40:06 PM"

Note: In the curl command, “&” & “timestamp” is represented as two text strings to avoid potential html decoding problems when displayed by web browsers, but it can be condensed into a single text string after copying the code into Script Editor.

FYI, here’s an alternative approach using AppleScriptObjC:

use AppleScript version "2.4" --  (10.10) or later
use framework "Foundation"
use scripting additions

on localTime(latitudeInDecimalForm, longitudeInDecimalForm)
	set theDate to current application's NSDate's |date|()
	set timeSeconds to theDate's timeIntervalSince1970()
	set nf to current application's NSNumberFormatter's new()
	nf's setMaximumFractionDigits:0
	set timeStamp to nf's stringFromNumber:timeSeconds
	set urlString to "htt" & "ps://maps.googleapis.com/maps/api/timezone/json?location=" & latitudeInDecimalForm & "," & longitudeInDecimalForm & "&" & "timestamp=" & timeStamp
	set theURL to current application's |NSURL|'s URLWithString:urlString
	set theData to current application's NSData's dataWithContentsOfURL:theURL
	set {theJson, theError} to (current application's NSJSONSerialization's JSONObjectWithData:theData options:0 |error|:(reference))
	if theJson is missing value then error theError's localizedDescription() as text
	set theStatus to theJson's objectForKey:"status" -- check it went OK
	if not (theStatus's isEqualToString:"OK") then error theStatus as text
	set dstOffset to (theJson's objectForKey:"dstOffset") as integer
	set rawOffset to (theJson's objectForKey:"rawOffset") as integer
	set timeZone to current application's NSTimeZone's timeZoneForSecondsFromGMT:(dstOffset + rawOffset)
	set theCalendar to current application's NSCalendar's currentCalendar()
	set comps to theCalendar's componentsInTimeZone:timeZone fromDate:theDate
	tell (current date) to set {theASDate, year, day, its month, day, time} to ¬
		{it, comps's |year|(), 1, comps's |month|(), comps's |day|(), (comps's hour()) * hours + (comps's minute()) * minutes + (comps's |second|())}
	return theASDate
end localTime

Shane,

I copied your version of the routine and called it with:

localTime(“37.79735”, “-122.465891”)

It compiles correctly, but when run the line that begins

set {theJson, theError} to (current application’s

returns the following error

error “data parameter is nil” number -10000

with

JSONObjectWithData:theData options:0 |error|:(reference)

hilighted.

I’d love to have a working version of this. Thanks in advance for your help.

Browser: Firefox 50.0
Operating System: Mac OS X (10.10)

The forum software automatically added url tags around the url string. I’ve split the string after “htt” to stop it happening.

I see the JSON object also has a timeZoneId property, which might be used instead of the two offsets. I don’t know how it would affect performance, but it would simplify the code, especially in the shell script:


on localTime(latitudeInDecimalForm, longitudeInDecimalForm)
	set {localMonth, localDay, localYear, localHour, localMinute, localSecond} to (do shell script "" & ¬
		"epochTime=$(date +%s)" & linefeed & ¬
		"x=$(curl -LNsf \"https://maps.googleapis.com/maps/api/timezone/json?location=" & latitudeInDecimalForm & "," & longitudeInDecimalForm & "&" & "timestamp=$epochTime\" | python -c '" & linefeed & ¬
		"import sys, json" & linefeed & ¬
		"y=json.load(sys.stdin)" & linefeed & ¬
		"print(y[\"timeZoneId\"])" & linefeed & ¬
		"sys.exit()" & linefeed & ¬
		"')" & linefeed & ¬
		"TZ=$x date -jr $epochTime \"+%m %d %Y %k %M %S\"")'s words
	set localDateTime to (current date)
	tell localDateTime to set {its year, its day, its month, its day, its hours, its minutes, its seconds} to {localYear, 1, localMonth, localDay, localHour, localMinute, localSecond}
	return localDateTime
end localTime

In the ASObjC version, these three lines:

set dstOffset to (theJson's objectForKey:"dstOffset") as integer
set rawOffset to (theJson's objectForKey:"rawOffset") as integer
set timeZone to current application's NSTimeZone's timeZoneForSecondsFromGMT:(dstOffset + rawOffset)

… would be replaced with these two:

set timeZoneId to (theJson's objectForKey:"timeZoneId")
set timeZone to current application's NSTimeZone's timeZoneWithName:(timeZoneId)

I was a little nervous about how reliable that would be – but I guess knownTimeZones() means known to the world rather than just some subset known to the OS. Here it returns 437 zones.

Thanks for the AppleScriptObjC solution. I had a feeling one might be coming!

That’s a nice way to pare down the code a bit. Another would be to combine all the shell activities into a single Python script (I kept the two offsets since they have been reduced to a single line of code anyway, although the timeZoneId approach likely could be incorporated into the Python solution):


on localTime(latitudeInDecimalForm, longitudeInDecimalForm)
	set {localMonth, localDay, localYear, localHour, localMinute, localSecond} to (do shell script "" & ¬
		"python -c '" & linefeed & ¬
		"import urllib, json, time" & linefeed & ¬
		"epochTime=int(time.time())" & linefeed & ¬
		"x = json.loads(urllib.urlopen(\"https://maps.googleapis.com/maps/api/timezone/json?location=\"+(\"%.9f\" % " & latitudeInDecimalForm & ")+\",\"+(\"%.9f\" % " & longitudeInDecimalForm & ")+\"&" & "timestamp=\"+str(epochTime)).read())" & linefeed & ¬
		"print(time.strftime(\"%m %d %Y %k %M %S\", time.gmtime(epochTime+int(x[\"dstOffset\"])+int(x[\"rawOffset\"]))))" & linefeed & ¬
		"'")'s words
	tell (current date) to set {localDateTime, its day, its year, its month, its day, its hours, its minutes, its seconds} to {it, 1, localYear, localMonth, localDay, localHour, localMinute, localSecond}
	return localDateTime
end localTime

As an aside, can you kindly explain how the double assignment to its day works in the assignment statement:

 tell localDateTime to set {its year, its day, its month, its day, its hours, its minutes, its seconds} to {localYear, 1, localMonth, localDay, localHour, localMinute, localSecond}

I don’t understand that. Thanks.

[i]Edit notes:

  • This version contains improvements over the shell script solution initially submitted in this thread and is recommended over the initial version.
  • Shane Stanley’s efficient one-line date assignment algorithm was incorporated into the current version of the script.
  • Also, the input parameters are now formatted by the Python script to handle numbers in exponential notation. Thus, they can be entered as numbers (or text strings if desired) without fear of breaking the script (including correction of a previously erroneous formatting specification to its now correct version: "%.9f").[/i]

Hi. Yes.

AppleScript dates overflow into the following month if you set a day which doesn’t exist in their current month or if you set a month which doesn’t have their current day. For instance, supposing you ran your script in the US late in the evening on 31st March to find the local time in Japan. The date there by then would be 1st April. Setting the month of your current date to April would give 31st April, which, since April only has 30 days, would roll over to 1st May. Setting the day to 1 first (or anything up to 28) before setting the month ensures that there’s no overflow, whatever the month. Then once the month’s set, you can safely set the day to a day that you know is in it.

Awesome explanation. Thanks!

I should add that it’s also a good idea to set the year before setting the month and the day to ensure that you don’t get overflow when setting 29th February. It’s not actually an issue here as your script’s never going to set 29th February from a different year, but for the general technique of changing the values of AppleScript dates, it’s worth remembering.

Thanks again. I made the change to the “improved” version of the shell script solution above.

Actually, that line was originally written by Nigel elsewhere.

I’ve also edited my version to include a check for the JSON status value.

Here’s a companion script for getting the coordinates from an address:

use AppleScript version "2.4" --  (10.10) or later
use framework "Foundation"
use scripting additions

on coordsForAddress:addressString
	set addressString to current application's NSString's stringWithString:addressString
	set addressString to addressString's stringByReplacingOccurrencesOfString:space withString:"+"
	set urlString to "htt" & "ps://maps.googleapis.com/maps/api/geocode/json?address=" & addressString
	set theURL to current application's |NSURL|'s URLWithString:urlString
	set theData to current application's NSData's dataWithContentsOfURL:theURL
	set {theJson, theError} to (current application's NSJSONSerialization's JSONObjectWithData:theData options:0 |error|:(reference))
	if theJson is missing value then error theError's localizedDescription() as text
	set theStatus to theJson's objectForKey:"status" -- check it went OK
	if not (theStatus's isEqualToString:"OK") then error theStatus as text
	set theResults to theJson's objectForKey:"results"
	set thePred to current application's NSPredicate's predicateWithFormat:"types CONTAINS 'street_address'"
	set bestResult to (theResults's filteredArrayUsingPredicate:thePred)'s firstObject()
	if bestResult = missing value then set bestResult to theResults's firstObject()
	set theLat to (bestResult's valueForKeyPath:"geometry.location.lat") as real
	set theLong to (bestResult's valueForKeyPath:"geometry.location.lng") as real
	set fullAddress to (bestResult's objectForKey:"formatted_address") as text
	return {theLat, theLong, fullAddress}
end coordsForAddress:

Which means you can combine them like this:

set {latitudeInDecimalForm, longitudeInDecimalForm, fullAddress} to my coordsForAddress:"Akershus Fortress"
set theTime to my localTime(latitudeInDecimalForm, longitudeInDecimalForm)
display dialog "At " & fullAddress & " it's " & (theTime as text)

Updated with info from message #11

Very nice addition.

One slight word of caution. From some random samplings, I found that the Google Maps API will sometimes return the adjacent timezone, not the actual timezone, within a few hundred yards/meters (or even a few miles/kilometers in some cases) of a timezone border. It’s obviously a limitation of the Google Maps API and not of the algorithms presented above. This should rarely be a problem in real-world usage but something to be aware of if the geocode location is extremely close to a timezone border.

Nice scripts, both of you. :slight_smile:

I’ve been fooling around with Shane’s coordinate-finding script this morning to see if the JSON’s results array actually contains more than one result when the input’s deliberately vague. The answer so far is that it doesn’t. With an input of “Stratford”, the array only contains information for Stratford, CT, USA. Nothing for Stratford, Ontario, Canada or any of the Stratfords in England. “St. Petersburg” only gets the Russian city, not the one in Florida. Similarly arbitrary (but reasonable) results are obtained with “Perth”, “Richmond”, “Paris”, and “Aldershot”. More specific input does get the required information, but the array only ever contains one result.

Entering just the name of my village returns details for a numbered building in W Pico Blvd, Los Angeles, California! (Googling the address turns up an establishment of unstated function, but which is open in the evenings and has a library, a lounge, a games room, a bar, and my village’s name!) :slight_smile:

FWIW, I saw the same thing. I half-wondered if using an API key would make a difference.

The additional functionality provided by Shane Stanley’s companion script inspired the following script, which combines all the previously described functionality into a single handler localGeoInfo.

The handler’s input argument is a record that may be coded with lat and lng properties specifying the location’s latitude and longitude in decimal form (as integers, real numbers, exponential numbers, or text representations of the same), or with an addr property specifying the location’s street or similar address as a text string. Error checking is performed on the input argument and the processing of the Google Maps API data.

The handler’s return value is a record with lat, lng, addr, and localtime properties. The first two properties are the input location’s latitude and longitude as real numbers, the next is the location’s address as a text string (as "best-guess"ed by the Google Maps API), and the last is the location’s local time as an Applescript date value. Note in the examples below the slight tweaking of the output location during the processing of the input location by the Google Maps API.

In keeping with my prior entries, the handler is a shell script solution that utilizes Python for downloading and processing of the url data from the Google Maps API, although it could of course be coded in AppleScriptObjC :slight_smile: . Since Python relies on indentation for script parsing, it is important to preserve the leading tabs in the indented Python lines when copying the code.

Examples:


localGeoInfo({lat:40.7593755, lng:-73.9799726}) --> {lat:40.7593755, lng:-73.9799726, addr:"Rockefeller Plaza, 45 Rockefeller Plaza, New York, NY 10111, USA", localtime:date "Thursday, September 7, 2017 at 11:58:20 PM"}

localGeoInfo({addr:"30 Rockefeller Plaza, New York, NY 10112, USA"}) --> {lat:40.7593755, lng:-73.9799726, addr:"Rockefeller Plaza, 45 Rockefeller Plaza, New York, NY 10111, USA", localtime:date "Thursday, September 7, 2017 at 11:58:20 PM"}

Handler:


on localGeoInfo(inputRecord)
	try
		if inputRecord's class ≠ record then error number 999
		tell (inputRecord & {lat:null, lng:null, addr:null}) to set {lat, lng, addr} to {its lat, its lng, its addr}
		try
			{lat as number, lng as number}
			set isLatLng to true
		on error
			if addr's class ≠ text then error number 999
			set isLatLng to false
		end try
		tell (do shell script "" & ¬
			"python -c '" & linefeed & ¬
			"import json, string, time, urllib" & linefeed & ¬
			"epochTime=int(time.time())" & linefeed & ¬
			"isLatLng = " & (isLatLng as integer) & " == 1" & linefeed & ¬
			"theLat = theLng = theAddr = theTime = \"\"" & linefeed & ¬
			"try:" & linefeed & ¬
			tab & "if isLatLng:" & linefeed & ¬
			tab & tab & "theUrl = \"https://maps.googleapis.com/maps/api/geocode/json?latlng=\" + (\"%.9f\" % " & lat & ") + \",\" + (\"%.9f\" % " & lng & ")" & linefeed & ¬
			tab & "else:" & linefeed & ¬
			tab & tab & "theUrl = \"https://maps.googleapis.com/maps/api/geocode/json?address=\" + string.replace(\"" & addr & "\", \" \", \"+\")" & linefeed & ¬
			tab & "x = json.loads(urllib.urlopen(theUrl).read())[\"results\"]" & linefeed & ¬
			tab & "y = x[0]" & linefeed & ¬
			tab & "for z in x:" & linefeed & ¬
			tab & tab & "if \"street_address\" in set(z[\"types\"]):" & linefeed & ¬
			tab & tab & tab & "y = z" & linefeed & ¬
			tab & tab & tab & "break" & linefeed & ¬
			tab & "theLat = y[\"geometry\"][\"location\"][\"lat\"]" & linefeed & ¬
			tab & "theLng = y[\"geometry\"][\"location\"][\"lng\"]" & linefeed & ¬
			tab & "theAddr = y[\"formatted_address\"]" & linefeed & ¬
			tab & "theUrl = \"https://maps.googleapis.com/maps/api/timezone/json?location=\" + str(theLat) + \",\" + str(theLng) + \"&\" + \"timestamp=\" + str(epochTime)" & linefeed & ¬
			tab & "x = json.loads(urllib.urlopen(theUrl).read())" & linefeed & ¬
			tab & "theTime = time.strftime(\"%Y %m %d %k %M %S\", time.gmtime(epochTime+int(x[\"dstOffset\"])+int(x[\"rawOffset\"])))" & linefeed & ¬
			"except: pass" & linefeed & ¬
			"if theLat == theLng == theAddr == theTime == \"\":" & linefeed & ¬
			tab & "if isLatLng: raise Exception(\"Could not get location information for the input latitude and longitude.\")" & linefeed & ¬
			tab & "else: raise Exception(\"Could not get location information for the input address.\")" & linefeed & ¬
			"print(theLat)" & linefeed & ¬
			"print(theLng)" & linefeed & ¬
			"print(theAddr).encode(\"utf-8\")" & linefeed & ¬
			"print(theTime)" & linefeed & ¬
			"'")'s paragraphs to set {theLat, theLng, theAddr, {theYear, theMonth, theDay, theHour, theMinute, theSecond}} to {(item 1) as real, (item 2) as real, item 3, item 4's words}
		tell (current date) to set {theTime, its day, its year, its month, its day, its hours, its minutes, its seconds} to {it, 1, theYear, theMonth, theDay, theHour, theMinute, theSecond}
	on error m number n
		if n = 999 then error "The input argument is invalid." & return & return & "It must be a record in either of the following forms:" & return & return & tab & "{lat:...latitude in decimal form..., lng:...longitude in decimal form...}" & return & tab & tab & "-OR-" & return & tab & "{addr:...address as a text string...}"
		set o to offset of "Exception: " in m
		if o > 0 then error (get m's text (o + 11) thru -1)
		error m number n
	end try
	return {lat:theLat, lng:theLng, addr:theAddr, localtime:theTime}
end localGeoInfo

Edit notes:

  • The script has been modified from that originally submitted. Previously, the latitude, longitude, and address location data was obtained from the first entry of the JSON “results” array. Now, the “results” array is searched in a for loop for an entry whose “types” array includes a “street_address” entry. If one is found, that “results” entry is used for the location data; otherwise, the first “results” array entry is used. This modification should improve the accuracy of the returned location data in certain cases of multiple conflicting location entries.
  • The Python print(theAddr) statement was modified to handle non-ASCII characters in addresses properly.

That said, the results array in the JSON from a geocode query with bmose’s coordinates …

localGeoInfo({lat:40.7593755, lng:-73.9799726}) --> {lat:40.7593755, lng:-73.9799726, addr:"Rockefeller Plaza, 45 Rockefeller Plaza, New York, NY 10111, USA", localtime:date "Thursday, September 7, 2017 at 11:58:20 PM"}

… contains ten results, for: “Rockefeller Plaza, 45 Rockefeller Plaza, New York, NY 10111, USA”, “60 W 50th St, New York, NY 10112, USA”, “GE Building, New York, NY 10112, USA”, “Midtown West, New York, NY, USA”, “Midtown, New York, NY, USA”, “Manhattan, New York, NY, USA”, “New York, NY, USA”, “New York, NY 10112, USA”, “New York County, New York, NY, USA”, and “New York-Northern New Jersey-Long Island, NY-NJ-PA, USA”.

I confirm that when you ask about 30 Rockefeller Plaza, NY 10112, maps.googleapis.com returns information about 45 Rockefeller Plaza, NY 10111.

When I try it here I get two results. The first is 45 Rockefeller Plaza, and the second is 30 Rockefeller Plaza. But the full address for the first is “Rockefeller Plaza, 45 Rockefeller Plaza, New York, NY 10111, USA”, as opposed to “30 Rockefeller Plaza, New York, NY 10112, USA”, so I’m presuming the repeated Rockefeller Plaza is behind the odd ranking.

Wouldn’t want to use it to call a taxi… :slight_smile:

I too have been a bit befuddled by the common occurrence of numerous location entries for a given JSON “results” array. In general, I’ve found that the “results” array entry whose “types” array’s first entry is “street_address” tends to be the address most in line with the address I expect to find. However, after encountering a few “results” arrays without any “street_address” entry, I initially decided to use the first “results” array entry for all queries, since the locations seem to move from the most localized to the most general the farther along the “results” array one goes. Then I tried a street address which I knew would be ambiguous, namely 100 Washington St, Boston, Massachusetts, because there are multiple such streets in Boston’s subdivisions. Once again, the “results” array entry whose “types” array’s first entry is “street_address” was the most “expected” address, i.e., the address in the center of Boston rather than in one of its subdivisions. However, it was not the first “results” array entry! Therefore, I have modified the previously submitted localGeoInfo handler such that it now searches the “results” array for a location entry whose “types” array’s first entry is “street_address”. If one is found, it uses that location; otherwise, it simply uses the “results” array’s first entry.

Addendum: Actually, I modified the code such that “street_address” does not have to be the first “types” array entry but rather may appear anywhere in the “types” array for that location to be selected.