xref: /netbsd-src/lib/libc/time/tzselect.ksh (revision bdc22b2e01993381dcefeff2bc9b56ca75a4235c)
1#! /bin/bash
2#
3#	$NetBSD: tzselect.ksh,v 1.17 2018/01/25 22:48:42 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, AEST-10 is a zone named AEST' \
348				'that 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 time zone 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' \
394			    'time zone regions,'
395		    echo >&2 'listed roughly in increasing order' \
396			    "of distance from $coord".
397		    doselect $regions
398		    region=$select_result
399		    TZ=`say "$distance_table" | $AWK -v region="$region" '
400		      BEGIN { FS="\t" }
401		      $NF == region { print $4 }
402		    '`
403		    ;;
404		*)
405		# Get list of names of countries in the continent or ocean.
406		countries=`$AWK \
407			-v continent="$continent" \
408			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
409		'
410			BEGIN { FS = "\t" }
411			/^#/ { next }
412			$3 ~ ("^" continent "/") {
413			    ncc = split($1, cc, /,/)
414			    for (i = 1; i <= ncc; i++)
415				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
416			}
417			END {
418				while (getline <TZ_COUNTRY_TABLE) {
419					if ($0 !~ /^#/) cc_name[$1] = $2
420				}
421				for (i = 1; i <= ccs; i++) {
422					country = cc_list[i]
423					if (cc_name[country]) {
424					  country = cc_name[country]
425					}
426					print country
427				}
428			}
429		' <"$TZ_ZONE_TABLE" | sort -f`
430
431
432		# If there's more than one country, ask the user which one.
433		case $countries in
434		*"$newline"*)
435			echo >&2 'Please select a country' \
436				'whose clocks agree with yours.'
437			doselect $countries
438			country=$select_result;;
439		*)
440			country=$countries
441		esac
442
443
444		# Get list of names of time zone rule regions in the country.
445		regions=`$AWK \
446			-v country="$country" \
447			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
448		'
449			BEGIN {
450				FS = "\t"
451				cc = country
452				while (getline <TZ_COUNTRY_TABLE) {
453					if ($0 !~ /^#/  &&  country == $2) {
454						cc = $1
455						break
456					}
457				}
458			}
459			/^#/ { next }
460			$1 ~ cc { print $4 }
461		' <"$TZ_ZONE_TABLE"`
462
463
464		# If there's more than one region, ask the user which one.
465		case $regions in
466		*"$newline"*)
467			echo >&2 'Please select one of the following' \
468				'time zone regions.'
469			doselect $regions
470			region=$select_result;;
471		*)
472			region=$regions
473		esac
474
475		# Determine TZ from country and region.
476		TZ=`$AWK \
477			-v country="$country" \
478			-v region="$region" \
479			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
480		'
481			BEGIN {
482				FS = "\t"
483				cc = country
484				while (getline <TZ_COUNTRY_TABLE) {
485					if ($0 !~ /^#/  &&  country == $2) {
486						cc = $1
487						break
488					}
489				}
490			}
491			/^#/ { next }
492			$1 ~ cc && $4 == region { print $3 }
493		' <"$TZ_ZONE_TABLE"`
494		esac
495
496		# Make sure the corresponding zoneinfo file exists.
497		TZ_for_date=$TZDIR/$TZ
498		<"$TZ_for_date" || {
499			say >&2 "$0: time zone files are not set up correctly"
500			exit 1
501		}
502	esac
503
504
505	# Use the proposed TZ to output the current date relative to UTC.
506	# Loop until they agree in seconds.
507	# Give up after 8 unsuccessful tries.
508
509	extra_info=
510	for i in 1 2 3 4 5 6 7 8
511	do
512		TZdate=`LANG=C TZ="$TZ_for_date" date`
513		UTdate=`LANG=C TZ=UTC0 date`
514		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
515		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
516		case $TZsec in
517		$UTsec)
518			extra_info="
519Selected time is now:	$TZdate.
520Universal Time is now:	$UTdate."
521			break
522		esac
523	done
524
525
526	# Output TZ info and ask the user to confirm.
527
528	echo >&2 ""
529	echo >&2 "The following information has been given:"
530	echo >&2 ""
531	case $country%$region%$coord in
532	?*%?*%)	say >&2 "	$country$newline	$region";;
533	?*%%)	say >&2 "	$country";;
534	%?*%?*) say >&2 "	coord $coord$newline	$region";;
535	%%?*)	say >&2 "	coord $coord";;
536	*)	say >&2 "	TZ='$TZ'"
537	esac
538	say >&2 ""
539	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
540	say >&2 "Is the above information OK?"
541
542	doselect Yes No
543	ok=$select_result
544	case $ok in
545	Yes) break
546	esac
547do coord=
548done
549
550case $SHELL in
551*csh) file=.login line="setenv TZ '$TZ'";;
552*) file=.profile line="TZ='$TZ'; export TZ"
553esac
554
555test -t 1 && say >&2 "
556You can make this change permanent for yourself by appending the line
557	$line
558to the file '$file' in your home directory; then log out and log in again.
559
560Here is that TZ value again, this time on standard output so that you
561can use the $0 command in shell scripts:"
562
563say "$TZ"
564