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