xref: /minix/lib/libc/time/tzselect.ksh (revision 0a6a1f1d)
1#! /bin/bash
2#
3#	$NetBSD: tzselect.ksh,v 1.14 2015/06/21 16:06:51 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.
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 <http://www.gnu.org/software/bash/bash.html>
23#	Korn Shell <http://www.kornshell.com/>
24#	Public Domain Korn Shell <http://www.cs.mun.ca/~michael/pdksh/>
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) <http://www.gnu.org/software/gawk/>
35#	mawk <http://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 = "[^-+,0-9][^-+,0-9][^-+,0-9]+"
352				time = "[0-2]?[0-9](:[0-5][0-9](:[0-5][0-9])?)?"
353				offset = "[-+]?" time
354				date = "(J?[0-9]+|M[0-9]+\\.[0-9]+\\.[0-9]+)"
355				datetime = "," date "(/" time ")?"
356				tzpattern = "^(:.*|" tzname offset "(" tzname \
357				  "(" offset ")?(" datetime datetime ")?)?)$"
358				if (TZ ~ tzpattern) exit 1
359				exit 0
360			}'
361		do
362		    say >&2 "'$TZ' is not a conforming Posix time zone string."
363		done
364		TZ_for_date=$TZ;;
365	*)
366		case $continent in
367		coord)
368		    case $coord in
369		    '')
370			echo >&2 'Please enter coordinates' \
371				'in ISO 6709 notation.'
372			echo >&2 'For example, +4042-07403 stands for'
373			echo >&2 '40 degrees 42 minutes north,' \
374				'74 degrees 3 minutes west.'
375			read coord;;
376		    esac
377		    distance_table=`$AWK \
378			    -v coord="$coord" \
379			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
380			    "$output_distances" <"$TZ_ZONE_TABLE" |
381		      sort -n |
382		      sed "${location_limit}q"
383		    `
384		    regions=`say "$distance_table" | $AWK '
385		      BEGIN { FS = "\t" }
386		      { print $NF }
387		    '`
388		    echo >&2 'Please select one of the following' \
389			    'time zone regions,'
390		    echo >&2 'listed roughly in increasing order' \
391			    "of distance from $coord".
392		    doselect $regions
393		    region=$select_result
394		    TZ=`say "$distance_table" | $AWK -v region="$region" '
395		      BEGIN { FS="\t" }
396		      $NF == region { print $4 }
397		    '`
398		    ;;
399		*)
400		# Get list of names of countries in the continent or ocean.
401		countries=`$AWK \
402			-v continent="$continent" \
403			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
404		'
405			BEGIN { FS = "\t" }
406			/^#/ { next }
407			$3 ~ ("^" continent "/") {
408			    ncc = split($1, cc, /,/)
409			    for (i = 1; i <= ncc; i++)
410				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
411			}
412			END {
413				while (getline <TZ_COUNTRY_TABLE) {
414					if ($0 !~ /^#/) cc_name[$1] = $2
415				}
416				for (i = 1; i <= ccs; i++) {
417					country = cc_list[i]
418					if (cc_name[country]) {
419					  country = cc_name[country]
420					}
421					print country
422				}
423			}
424		' <"$TZ_ZONE_TABLE" | sort -f`
425
426
427		# If there's more than one country, ask the user which one.
428		case $countries in
429		*"$newline"*)
430			echo >&2 'Please select a country' \
431				'whose clocks agree with yours.'
432			doselect $countries
433			country=$select_result;;
434		*)
435			country=$countries
436		esac
437
438
439		# Get list of names of time zone rule regions in the country.
440		regions=`$AWK \
441			-v country="$country" \
442			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
443		'
444			BEGIN {
445				FS = "\t"
446				cc = country
447				while (getline <TZ_COUNTRY_TABLE) {
448					if ($0 !~ /^#/  &&  country == $2) {
449						cc = $1
450						break
451					}
452				}
453			}
454			/^#/ { next }
455			$1 ~ cc { print $4 }
456		' <"$TZ_ZONE_TABLE"`
457
458
459		# If there's more than one region, ask the user which one.
460		case $regions in
461		*"$newline"*)
462			echo >&2 'Please select one of the following' \
463				'time zone regions.'
464			doselect $regions
465			region=$select_result;;
466		*)
467			region=$regions
468		esac
469
470		# Determine TZ from country and region.
471		TZ=`$AWK \
472			-v country="$country" \
473			-v region="$region" \
474			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
475		'
476			BEGIN {
477				FS = "\t"
478				cc = country
479				while (getline <TZ_COUNTRY_TABLE) {
480					if ($0 !~ /^#/  &&  country == $2) {
481						cc = $1
482						break
483					}
484				}
485			}
486			/^#/ { next }
487			$1 ~ cc && $4 == region { print $3 }
488		' <"$TZ_ZONE_TABLE"`
489		esac
490
491		# Make sure the corresponding zoneinfo file exists.
492		TZ_for_date=$TZDIR/$TZ
493		<"$TZ_for_date" || {
494			say >&2 "$0: time zone files are not set up correctly"
495			exit 1
496		}
497	esac
498
499
500	# Use the proposed TZ to output the current date relative to UTC.
501	# Loop until they agree in seconds.
502	# Give up after 8 unsuccessful tries.
503
504	extra_info=
505	for i in 1 2 3 4 5 6 7 8
506	do
507		TZdate=`LANG=C TZ="$TZ_for_date" date`
508		UTdate=`LANG=C TZ=UTC0 date`
509		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
510		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
511		case $TZsec in
512		$UTsec)
513			extra_info="
514Local time is now:	$TZdate.
515Universal Time is now:	$UTdate."
516			break
517		esac
518	done
519
520
521	# Output TZ info and ask the user to confirm.
522
523	echo >&2 ""
524	echo >&2 "The following information has been given:"
525	echo >&2 ""
526	case $country%$region%$coord in
527	?*%?*%)	say >&2 "	$country$newline	$region";;
528	?*%%)	say >&2 "	$country";;
529	%?*%?*) say >&2 "	coord $coord$newline	$region";;
530	%%?*)	say >&2 "	coord $coord";;
531	*)	say >&2 "	TZ='$TZ'"
532	esac
533	say >&2 ""
534	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
535	say >&2 "Is the above information OK?"
536
537	doselect Yes No
538	ok=$select_result
539	case $ok in
540	Yes) break
541	esac
542do coord=
543done
544
545case $SHELL in
546*csh) file=.login line="setenv TZ '$TZ'";;
547*) file=.profile line="TZ='$TZ'; export TZ"
548esac
549
550say >&2 "
551You can make this change permanent for yourself by appending the line
552	$line
553to the file '$file' in your home directory; then log out and log in again.
554
555Here is that TZ value again, this time on standard output so that you
556can use the $0 command in shell scripts:"
557
558say "$TZ"
559