xref: /netbsd-src/lib/libc/time/tzselect.ksh (revision 796c32c94f6e154afc9de0f63da35c91bb739b45)
1#! /bin/bash
2#
3#	$NetBSD: tzselect.ksh,v 1.16 2017/10/24 17:38:17 christos Exp $
4#
5PKGVERSION='(tzcode) '
6TZVERSION=see_Makefile
7REPORT_BUGS_TO=tz@iana.org
8
9# Ask the user about the time zone, and output the resulting TZ value to stdout.
10# Interact with the user via stderr and stdin.
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 time zone 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 time zone 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, GST-10 is a zone named GST' \
348				'that is 10 hours ahead (east) of UTC.'
349			read TZ
350			$AWK -v TZ="$TZ" 'BEGIN {
351				tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
352				time = "(2[0-4]|[0-1]?[0-9])" \
353				  "(:[0-5][0-9](:[0-5][0-9])?)?"
354				offset = "[-+]?" time
355				mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
356				jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
357				  "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
358				datetime = ",(" mdate "|" jdate ")(/" time ")?"
359				tzpattern = "^(:.*|" tzname offset "(" tzname \
360				  "(" offset ")?(" datetime datetime ")?)?)$"
361				if (TZ ~ tzpattern) exit 1
362				exit 0
363			}'
364		do
365		    say >&2 "'$TZ' is not a conforming Posix time zone string."
366		done
367		TZ_for_date=$TZ;;
368	*)
369		case $continent in
370		coord)
371		    case $coord in
372		    '')
373			echo >&2 'Please enter coordinates' \
374				'in ISO 6709 notation.'
375			echo >&2 'For example, +4042-07403 stands for'
376			echo >&2 '40 degrees 42 minutes north,' \
377				'74 degrees 3 minutes west.'
378			read coord;;
379		    esac
380		    distance_table=`$AWK \
381			    -v coord="$coord" \
382			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
383			    "$output_distances" <"$TZ_ZONE_TABLE" |
384		      sort -n |
385		      sed "${location_limit}q"
386		    `
387		    regions=`say "$distance_table" | $AWK '
388		      BEGIN { FS = "\t" }
389		      { print $NF }
390		    '`
391		    echo >&2 'Please select one of the following' \
392			    'time zone regions,'
393		    echo >&2 'listed roughly in increasing order' \
394			    "of distance from $coord".
395		    doselect $regions
396		    region=$select_result
397		    TZ=`say "$distance_table" | $AWK -v region="$region" '
398		      BEGIN { FS="\t" }
399		      $NF == region { print $4 }
400		    '`
401		    ;;
402		*)
403		# Get list of names of countries in the continent or ocean.
404		countries=`$AWK \
405			-v continent="$continent" \
406			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
407		'
408			BEGIN { FS = "\t" }
409			/^#/ { next }
410			$3 ~ ("^" continent "/") {
411			    ncc = split($1, cc, /,/)
412			    for (i = 1; i <= ncc; i++)
413				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
414			}
415			END {
416				while (getline <TZ_COUNTRY_TABLE) {
417					if ($0 !~ /^#/) cc_name[$1] = $2
418				}
419				for (i = 1; i <= ccs; i++) {
420					country = cc_list[i]
421					if (cc_name[country]) {
422					  country = cc_name[country]
423					}
424					print country
425				}
426			}
427		' <"$TZ_ZONE_TABLE" | sort -f`
428
429
430		# If there's more than one country, ask the user which one.
431		case $countries in
432		*"$newline"*)
433			echo >&2 'Please select a country' \
434				'whose clocks agree with yours.'
435			doselect $countries
436			country=$select_result;;
437		*)
438			country=$countries
439		esac
440
441
442		# Get list of names of time zone rule regions in the country.
443		regions=`$AWK \
444			-v country="$country" \
445			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
446		'
447			BEGIN {
448				FS = "\t"
449				cc = country
450				while (getline <TZ_COUNTRY_TABLE) {
451					if ($0 !~ /^#/  &&  country == $2) {
452						cc = $1
453						break
454					}
455				}
456			}
457			/^#/ { next }
458			$1 ~ cc { print $4 }
459		' <"$TZ_ZONE_TABLE"`
460
461
462		# If there's more than one region, ask the user which one.
463		case $regions in
464		*"$newline"*)
465			echo >&2 'Please select one of the following' \
466				'time zone regions.'
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