• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/bin/bash
2# Ask the user about the time zone, and output the resulting TZ value to stdout.
3# Interact with the user via stderr and stdin.
4
5PKGVERSION='(tzcode) '
6TZVERSION=see_Makefile
7REPORT_BUGS_TO=tz@iana.org
8
9# Contributed by Paul Eggert.  This file is in the public domain.
10
11# Porting notes:
12#
13# This script requires a Posix-like shell and prefers the extension of a
14# 'select' statement.  The 'select' statement was introduced in the
15# Korn shell and is available in Bash and other shell implementations.
16# If your host lacks both Bash and the Korn shell, you can get their
17# source from one of these locations:
18#
19#	Bash <https://www.gnu.org/software/bash/>
20#	Korn Shell <http://www.kornshell.com/>
21#	MirBSD Korn Shell <http://www.mirbsd.org/mksh.htm>
22#
23# For portability to Solaris 10 /bin/sh (supported by Oracle through
24# January 2024) this script avoids some POSIX features and common
25# extensions, such as $(...) (which works sometimes but not others),
26# $((...)), ! CMD, ${#ID}, ${ID##PAT}, ${ID%%PAT}, and $10.
27
28#
29# This script also uses several features of modern awk programs.
30# If your host lacks awk, or has an old awk that does not conform to Posix,
31# you can use either of the following free programs instead:
32#
33#	Gawk (GNU awk) <https://www.gnu.org/software/gawk/>
34#	mawk <https://invisible-island.net/mawk/>
35
36
37# Specify default values for environment variables if they are unset.
38: ${AWK=awk}
39: ${TZDIR=`pwd`}
40
41# Output one argument as-is to standard output.
42# Safer than 'echo', which can mishandle '\' or leading '-'.
43say() {
44    printf '%s\n' "$1"
45}
46
47# Check for awk Posix compliance.
48($AWK -v x=y 'BEGIN { exit 123 }') </dev/null >/dev/null 2>&1
49[ $? = 123 ] || {
50	say >&2 "$0: Sorry, your '$AWK' program is not Posix compatible."
51	exit 1
52}
53
54coord=
55location_limit=10
56zonetabtype=zone1970
57
58usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
59Select a timezone interactively.
60
61Options:
62
63  -c COORD
64    Instead of asking for continent and then country and then city,
65    ask for selection from time zones whose largest cities
66    are closest to the location with geographical coordinates COORD.
67    COORD should use ISO 6709 notation, for example, '-c +4852+00220'
68    for Paris (in degrees and minutes, North and East), or
69    '-c -35-058' for Buenos Aires (in degrees, South and West).
70
71  -n LIMIT
72    Display at most LIMIT locations when -c is used (default $location_limit).
73
74  --version
75    Output version information.
76
77  --help
78    Output this help.
79
80Report bugs to $REPORT_BUGS_TO."
81
82# Ask the user to select from the function's arguments,
83# and assign the selected argument to the variable 'select_result'.
84# Exit on EOF or I/O error.  Use the shell's 'select' builtin if available,
85# falling back on a less-nice but portable substitute otherwise.
86if
87  case $BASH_VERSION in
88  ?*) : ;;
89  '')
90    # '; exit' should be redundant, but Dash doesn't properly fail without it.
91    (eval 'set --; select x; do break; done; exit') </dev/null 2>/dev/null
92  esac
93then
94  # Do this inside 'eval', as otherwise the shell might exit when parsing it
95  # even though it is never executed.
96  eval '
97    doselect() {
98      select select_result
99      do
100	case $select_result in
101	"") echo >&2 "Please enter a number in range." ;;
102	?*) break
103	esac
104      done || exit
105    }
106  '
107else
108  doselect() {
109    # Field width of the prompt numbers.
110    select_width=`expr $# : '.*'`
111
112    select_i=
113
114    while :
115    do
116      case $select_i in
117      '')
118	select_i=0
119	for select_word
120	do
121	  select_i=`expr $select_i + 1`
122	  printf >&2 "%${select_width}d) %s\\n" $select_i "$select_word"
123	done ;;
124      *[!0-9]*)
125	echo >&2 'Please enter a number in range.' ;;
126      *)
127	if test 1 -le $select_i && test $select_i -le $#; then
128	  shift `expr $select_i - 1`
129	  select_result=$1
130	  break
131	fi
132	echo >&2 'Please enter a number in range.'
133      esac
134
135      # Prompt and read input.
136      printf >&2 %s "${PS3-#? }"
137      read select_i || exit
138    done
139  }
140fi
141
142while getopts c:n:t:-: opt
143do
144    case $opt$OPTARG in
145    c*)
146	coord=$OPTARG ;;
147    n*)
148	location_limit=$OPTARG ;;
149    t*) # Undocumented option, used for developer testing.
150	zonetabtype=$OPTARG ;;
151    -help)
152	exec echo "$usage" ;;
153    -version)
154	exec echo "tzselect $PKGVERSION$TZVERSION" ;;
155    -*)
156	say >&2 "$0: -$opt$OPTARG: unknown option; try '$0 --help'"; exit 1 ;;
157    *)
158	say >&2 "$0: try '$0 --help'"; exit 1 ;;
159    esac
160done
161
162shift `expr $OPTIND - 1`
163case $# in
1640) ;;
165*) say >&2 "$0: $1: unknown argument"; exit 1 ;;
166esac
167
168# Make sure the tables are readable.
169TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
170TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
171for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
172do
173	<"$f" || {
174		say >&2 "$0: time zone files are not set up correctly"
175		exit 1
176	}
177done
178
179# If the current locale does not support UTF-8, convert data to current
180# locale's format if possible, as the shell aligns columns better that way.
181# Check the UTF-8 of U+12345 CUNEIFORM SIGN URU TIMES KI.
182$AWK 'BEGIN { u12345 = "\360\222\215\205"; exit length(u12345) != 1 }' || {
183    { tmp=`(mktemp -d) 2>/dev/null` || {
184	tmp=${TMPDIR-/tmp}/tzselect.$$ &&
185	(umask 77 && mkdir -- "$tmp")
186    };} &&
187    trap 'status=$?; rm -fr -- "$tmp"; exit $status' 0 HUP INT PIPE TERM &&
188    (iconv -f UTF-8 -t //TRANSLIT <"$TZ_COUNTRY_TABLE" >$tmp/iso3166.tab) \
189        2>/dev/null &&
190    TZ_COUNTRY_TABLE=$tmp/iso3166.tab &&
191    iconv -f UTF-8 -t //TRANSLIT <"$TZ_ZONE_TABLE" >$tmp/$zonetabtype.tab &&
192    TZ_ZONE_TABLE=$tmp/$zonetabtype.tab
193}
194
195newline='
196'
197IFS=$newline
198
199
200# Awk script to read a time zone table and output the same table,
201# with each column preceded by its distance from 'here'.
202output_distances='
203  BEGIN {
204    FS = "\t"
205    while (getline <TZ_COUNTRY_TABLE)
206      if ($0 ~ /^[^#]/)
207        country[$1] = $2
208    country["US"] = "US" # Otherwise the strings get too long.
209  }
210  function abs(x) {
211    return x < 0 ? -x : x;
212  }
213  function min(x, y) {
214    return x < y ? x : y;
215  }
216  function convert_coord(coord, deg, minute, ilen, sign, sec) {
217    if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
218      degminsec = coord
219      intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
220      minsec = degminsec - intdeg * 10000
221      intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
222      sec = minsec - intmin * 100
223      deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
224    } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
225      degmin = coord
226      intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
227      minute = degmin - intdeg * 100
228      deg = (intdeg * 60 + minute) / 60
229    } else
230      deg = coord
231    return deg * 0.017453292519943296
232  }
233  function convert_latitude(coord) {
234    match(coord, /..*[-+]/)
235    return convert_coord(substr(coord, 1, RLENGTH - 1))
236  }
237  function convert_longitude(coord) {
238    match(coord, /..*[-+]/)
239    return convert_coord(substr(coord, RLENGTH))
240  }
241  # Great-circle distance between points with given latitude and longitude.
242  # Inputs and output are in radians.  This uses the great-circle special
243  # case of the Vicenty formula for distances on ellipsoids.
244  function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
245    dlong = long2 - long1
246    x = cos(lat2) * sin(dlong)
247    y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
248    num = sqrt(x * x + y * y)
249    denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
250    return atan2(num, denom)
251  }
252  # Parallel distance between points with given latitude and longitude.
253  # This is the product of the longitude difference and the cosine
254  # of the latitude of the point that is further from the equator.
255  # I.e., it considers longitudes to be further apart if they are
256  # nearer the equator.
257  function pardist(lat1, long1, lat2, long2) {
258    return abs(long1 - long2) * min(cos(lat1), cos(lat2))
259  }
260  # The distance function is the sum of the great-circle distance and
261  # the parallel distance.  It could be weighted.
262  function dist(lat1, long1, lat2, long2) {
263    return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
264  }
265  BEGIN {
266    coord_lat = convert_latitude(coord)
267    coord_long = convert_longitude(coord)
268  }
269  /^[^#]/ {
270    here_lat = convert_latitude($2)
271    here_long = convert_longitude($2)
272    line = $1 "\t" $2 "\t" $3
273    sep = "\t"
274    ncc = split($1, cc, /,/)
275    for (i = 1; i <= ncc; i++) {
276      line = line sep country[cc[i]]
277      sep = ", "
278    }
279    if (NF == 4)
280      line = line " - " $4
281    printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line
282  }
283'
284
285# Begin the main loop.  We come back here if the user wants to retry.
286while
287
288	echo >&2 'Please identify a location' \
289		'so that time zone rules can be set correctly.'
290
291	continent=
292	country=
293	region=
294
295	case $coord in
296	?*)
297		continent=coord;;
298	'')
299
300	# Ask the user for continent or ocean.
301
302	echo >&2 'Please select a continent, ocean, "coord", or "TZ".'
303
304        quoted_continents=`
305	  $AWK '
306	    BEGIN { FS = "\t" }
307	    /^[^#]/ {
308              entry = substr($3, 1, index($3, "/") - 1)
309              if (entry == "America")
310		entry = entry "s"
311              if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/)
312		entry = entry " Ocean"
313              printf "'\''%s'\''\n", entry
314            }
315          ' <"$TZ_ZONE_TABLE" |
316	  sort -u |
317	  tr '\n' ' '
318	  echo ''
319	`
320
321	eval '
322	    doselect '"$quoted_continents"' \
323		"coord - I want to use geographical coordinates." \
324		"TZ - I want to specify the timezone using the Posix TZ format."
325	    continent=$select_result
326	    case $continent in
327	    Americas) continent=America;;
328	    *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''`
329	    esac
330	'
331	esac
332
333	case $continent in
334	TZ)
335		# Ask the user for a Posix TZ string.  Check that it conforms.
336		while
337			echo >&2 'Please enter the desired value' \
338				'of the TZ environment variable.'
339			echo >&2 'For example, AEST-10 is abbreviated' \
340				'AEST and is 10 hours'
341			echo >&2 'ahead (east) of Greenwich,' \
342				'with no daylight saving time.'
343			read TZ
344			$AWK -v TZ="$TZ" 'BEGIN {
345				tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
346				time = "(2[0-4]|[0-1]?[0-9])" \
347				  "(:[0-5][0-9](:[0-5][0-9])?)?"
348				offset = "[-+]?" time
349				mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
350				jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
351				  "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
352				datetime = ",(" mdate "|" jdate ")(/" time ")?"
353				tzpattern = "^(:.*|" tzname offset "(" tzname \
354				  "(" offset ")?(" datetime datetime ")?)?)$"
355				if (TZ ~ tzpattern) exit 1
356				exit 0
357			}'
358		do
359		    say >&2 "'$TZ' is not a conforming Posix timezone string."
360		done
361		TZ_for_date=$TZ;;
362	*)
363		case $continent in
364		coord)
365		    case $coord in
366		    '')
367			echo >&2 'Please enter coordinates' \
368				'in ISO 6709 notation.'
369			echo >&2 'For example, +4042-07403 stands for'
370			echo >&2 '40 degrees 42 minutes north,' \
371				'74 degrees 3 minutes west.'
372			read coord;;
373		    esac
374		    distance_table=`$AWK \
375			    -v coord="$coord" \
376			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
377			    "$output_distances" <"$TZ_ZONE_TABLE" |
378		      sort -n |
379		      sed "${location_limit}q"
380		    `
381		    regions=`say "$distance_table" | $AWK '
382		      BEGIN { FS = "\t" }
383		      { print $NF }
384		    '`
385		    echo >&2 'Please select one of the following timezones,' \
386		    echo >&2 'listed roughly in increasing order' \
387			    "of distance from $coord".
388		    doselect $regions
389		    region=$select_result
390		    TZ=`say "$distance_table" | $AWK -v region="$region" '
391		      BEGIN { FS="\t" }
392		      $NF == region { print $4 }
393		    '`
394		    ;;
395		*)
396		# Get list of names of countries in the continent or ocean.
397		countries=`$AWK \
398			-v continent="$continent" \
399			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
400		'
401			BEGIN { FS = "\t" }
402			/^#/ { next }
403			$3 ~ ("^" continent "/") {
404			    ncc = split($1, cc, /,/)
405			    for (i = 1; i <= ncc; i++)
406				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
407			}
408			END {
409				while (getline <TZ_COUNTRY_TABLE) {
410					if ($0 !~ /^#/) cc_name[$1] = $2
411				}
412				for (i = 1; i <= ccs; i++) {
413					country = cc_list[i]
414					if (cc_name[country]) {
415					  country = cc_name[country]
416					}
417					print country
418				}
419			}
420		' <"$TZ_ZONE_TABLE" | sort -f`
421
422
423		# If there's more than one country, ask the user which one.
424		case $countries in
425		*"$newline"*)
426			echo >&2 'Please select a country' \
427				'whose clocks agree with yours.'
428			doselect $countries
429			country=$select_result;;
430		*)
431			country=$countries
432		esac
433
434
435		# Get list of timezones in the country.
436		regions=`$AWK \
437			-v country="$country" \
438			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
439		'
440			BEGIN {
441				FS = "\t"
442				cc = country
443				while (getline <TZ_COUNTRY_TABLE) {
444					if ($0 !~ /^#/  &&  country == $2) {
445						cc = $1
446						break
447					}
448				}
449			}
450			/^#/ { next }
451			$1 ~ cc { print $4 }
452		' <"$TZ_ZONE_TABLE"`
453
454
455		# If there's more than one region, ask the user which one.
456		case $regions in
457		*"$newline"*)
458			echo >&2 'Please select one of the following timezones.'
459			doselect $regions
460			region=$select_result;;
461		*)
462			region=$regions
463		esac
464
465		# Determine TZ from country and region.
466		TZ=`$AWK \
467			-v country="$country" \
468			-v region="$region" \
469			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
470		'
471			BEGIN {
472				FS = "\t"
473				cc = country
474				while (getline <TZ_COUNTRY_TABLE) {
475					if ($0 !~ /^#/  &&  country == $2) {
476						cc = $1
477						break
478					}
479				}
480			}
481			/^#/ { next }
482			$1 ~ cc && $4 == region { print $3 }
483		' <"$TZ_ZONE_TABLE"`
484		esac
485
486		# Make sure the corresponding zoneinfo file exists.
487		TZ_for_date=$TZDIR/$TZ
488		<"$TZ_for_date" || {
489			say >&2 "$0: time zone files are not set up correctly"
490			exit 1
491		}
492	esac
493
494
495	# Use the proposed TZ to output the current date relative to UTC.
496	# Loop until they agree in seconds.
497	# Give up after 8 unsuccessful tries.
498
499	extra_info=
500	for i in 1 2 3 4 5 6 7 8
501	do
502		TZdate=`LANG=C TZ="$TZ_for_date" date`
503		UTdate=`LANG=C TZ=UTC0 date`
504		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
505		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
506		case $TZsec in
507		$UTsec)
508			extra_info="
509Selected time is now:	$TZdate.
510Universal Time is now:	$UTdate."
511			break
512		esac
513	done
514
515
516	# Output TZ info and ask the user to confirm.
517
518	echo >&2 ""
519	echo >&2 "The following information has been given:"
520	echo >&2 ""
521	case $country%$region%$coord in
522	?*%?*%)	say >&2 "	$country$newline	$region";;
523	?*%%)	say >&2 "	$country";;
524	%?*%?*) say >&2 "	coord $coord$newline	$region";;
525	%%?*)	say >&2 "	coord $coord";;
526	*)	say >&2 "	TZ='$TZ'"
527	esac
528	say >&2 ""
529	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
530	say >&2 "Is the above information OK?"
531
532	doselect Yes No
533	ok=$select_result
534	case $ok in
535	Yes) break
536	esac
537do coord=
538done
539
540case $SHELL in
541*csh) file=.login line="setenv TZ '$TZ'";;
542*) file=.profile line="TZ='$TZ'; export TZ"
543esac
544
545test -t 1 && say >&2 "
546You can make this change permanent for yourself by appending the line
547	$line
548to the file '$file' in your home directory; then log out and log in again.
549
550Here is that TZ value again, this time on standard output so that you
551can use the $0 command in shell scripts:"
552
553say "$TZ"
554