xref: /freebsd/contrib/tzcode/tzselect.ksh (revision 9768746b)
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#	nawk <https://github.com/onetrueawk/awk>
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  '
108else
109  doselect() {
110    # Field width of the prompt numbers.
111    select_width=`expr $# : '.*'`
112
113    select_i=
114
115    while :
116    do
117      case $select_i in
118      '')
119	select_i=0
120	for select_word
121	do
122	  select_i=`expr $select_i + 1`
123	  printf >&2 "%${select_width}d) %s\\n" $select_i "$select_word"
124	done ;;
125      *[!0-9]*)
126	echo >&2 'Please enter a number in range.' ;;
127      *)
128	if test 1 -le $select_i && test $select_i -le $#; then
129	  shift `expr $select_i - 1`
130	  select_result=$1
131	  break
132	fi
133	echo >&2 'Please enter a number in range.'
134      esac
135
136      # Prompt and read input.
137      printf >&2 %s "${PS3-#? }"
138      read select_i || exit
139    done
140  }
141fi
142
143while getopts c:n:t:-: opt
144do
145    case $opt$OPTARG in
146    c*)
147	coord=$OPTARG ;;
148    n*)
149	location_limit=$OPTARG ;;
150    t*) # Undocumented option, used for developer testing.
151	zonetabtype=$OPTARG ;;
152    -help)
153	exec echo "$usage" ;;
154    -version)
155	exec echo "tzselect $PKGVERSION$TZVERSION" ;;
156    -*)
157	say >&2 "$0: -$opt$OPTARG: unknown option; try '$0 --help'"; exit 1 ;;
158    *)
159	say >&2 "$0: try '$0 --help'"; exit 1 ;;
160    esac
161done
162
163shift `expr $OPTIND - 1`
164case $# in
1650) ;;
166*) say >&2 "$0: $1: unknown argument"; exit 1 ;;
167esac
168
169# Make sure the tables are readable.
170TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
171TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
172for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
173do
174	<"$f" || {
175		say >&2 "$0: time zone files are not set up correctly"
176		exit 1
177	}
178done
179
180# If the current locale does not support UTF-8, convert data to current
181# locale's format if possible, as the shell aligns columns better that way.
182# Check the UTF-8 of U+12345 CUNEIFORM SIGN URU TIMES KI.
183$AWK 'BEGIN { u12345 = "\360\222\215\205"; exit length(u12345) != 1 }' || {
184    { tmp=`(mktemp -d) 2>/dev/null` || {
185	tmp=${TMPDIR-/tmp}/tzselect.$$ &&
186	(umask 77 && mkdir -- "$tmp")
187    };} &&
188    trap 'status=$?; rm -fr -- "$tmp"; exit $status' 0 HUP INT PIPE TERM &&
189    (iconv -f UTF-8 -t //TRANSLIT <"$TZ_COUNTRY_TABLE" >$tmp/iso3166.tab) \
190        2>/dev/null &&
191    TZ_COUNTRY_TABLE=$tmp/iso3166.tab &&
192    iconv -f UTF-8 -t //TRANSLIT <"$TZ_ZONE_TABLE" >$tmp/$zonetabtype.tab &&
193    TZ_ZONE_TABLE=$tmp/$zonetabtype.tab
194}
195
196newline='
197'
198IFS=$newline
199
200
201# Awk script to read a time zone table and output the same table,
202# with each column preceded by its distance from 'here'.
203output_distances='
204  BEGIN {
205    FS = "\t"
206    while (getline <TZ_COUNTRY_TABLE)
207      if ($0 ~ /^[^#]/)
208        country[$1] = $2
209    country["US"] = "US" # Otherwise the strings get too long.
210  }
211  function abs(x) {
212    return x < 0 ? -x : x;
213  }
214  function min(x, y) {
215    return x < y ? x : y;
216  }
217  function convert_coord(coord, deg, minute, ilen, sign, sec) {
218    if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
219      degminsec = coord
220      intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
221      minsec = degminsec - intdeg * 10000
222      intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
223      sec = minsec - intmin * 100
224      deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
225    } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
226      degmin = coord
227      intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
228      minute = degmin - intdeg * 100
229      deg = (intdeg * 60 + minute) / 60
230    } else
231      deg = coord
232    return deg * 0.017453292519943296
233  }
234  function convert_latitude(coord) {
235    match(coord, /..*[-+]/)
236    return convert_coord(substr(coord, 1, RLENGTH - 1))
237  }
238  function convert_longitude(coord) {
239    match(coord, /..*[-+]/)
240    return convert_coord(substr(coord, RLENGTH))
241  }
242  # Great-circle distance between points with given latitude and longitude.
243  # Inputs and output are in radians.  This uses the great-circle special
244  # case of the Vicenty formula for distances on ellipsoids.
245  function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
246    dlong = long2 - long1
247    x = cos(lat2) * sin(dlong)
248    y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
249    num = sqrt(x * x + y * y)
250    denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
251    return atan2(num, denom)
252  }
253  # Parallel distance between points with given latitude and longitude.
254  # This is the product of the longitude difference and the cosine
255  # of the latitude of the point that is further from the equator.
256  # I.e., it considers longitudes to be further apart if they are
257  # nearer the equator.
258  function pardist(lat1, long1, lat2, long2) {
259    return abs(long1 - long2) * min(cos(lat1), cos(lat2))
260  }
261  # The distance function is the sum of the great-circle distance and
262  # the parallel distance.  It could be weighted.
263  function dist(lat1, long1, lat2, long2) {
264    return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
265  }
266  BEGIN {
267    coord_lat = convert_latitude(coord)
268    coord_long = convert_longitude(coord)
269  }
270  /^[^#]/ {
271    here_lat = convert_latitude($2)
272    here_long = convert_longitude($2)
273    line = $1 "\t" $2 "\t" $3
274    sep = "\t"
275    ncc = split($1, cc, /,/)
276    for (i = 1; i <= ncc; i++) {
277      line = line sep country[cc[i]]
278      sep = ", "
279    }
280    if (NF == 4)
281      line = line " - " $4
282    printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line
283  }
284'
285
286# Begin the main loop.  We come back here if the user wants to retry.
287while
288
289	echo >&2 'Please identify a location' \
290		'so that time zone rules can be set correctly.'
291
292	continent=
293	country=
294	region=
295
296	case $coord in
297	?*)
298		continent=coord;;
299	'')
300
301	# Ask the user for continent or ocean.
302
303	echo >&2 'Please select a continent, ocean, "coord", or "TZ".'
304
305        quoted_continents=`
306	  $AWK '
307	    function handle_entry(entry) {
308	      entry = substr(entry, 1, index(entry, "/") - 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	    BEGIN { FS = "\t" }
316	    /^[^#]/ {
317              handle_entry($3)
318            }
319	    /^#@/ {
320	      ncont = split($2, cont, /,/)
321	      for (ci = 1; ci <= ncont; ci++) {
322	        handle_entry(cont[ci])
323	      }
324	    }
325          ' <"$TZ_ZONE_TABLE" |
326	  sort -u |
327	  tr '\n' ' '
328	  echo ''
329	`
330
331	eval '
332	    doselect '"$quoted_continents"' \
333		"coord - I want to use geographical coordinates." \
334		"TZ - I want to specify the timezone using the Posix TZ format."
335	    continent=$select_result
336	    case $continent in
337	    Americas) continent=America;;
338	    *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''`
339	    esac
340	'
341	esac
342
343	case $continent in
344	TZ)
345		# Ask the user for a Posix TZ string.  Check that it conforms.
346		while
347			echo >&2 'Please enter the desired value' \
348				'of the TZ environment variable.'
349			echo >&2 'For example, AEST-10 is abbreviated' \
350				'AEST and is 10 hours'
351			echo >&2 'ahead (east) of Greenwich,' \
352				'with no daylight saving time.'
353			read TZ
354			$AWK -v TZ="$TZ" 'BEGIN {
355				tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
356				time = "(2[0-4]|[0-1]?[0-9])" \
357				  "(:[0-5][0-9](:[0-5][0-9])?)?"
358				offset = "[-+]?" time
359				mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
360				jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
361				  "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
362				datetime = ",(" mdate "|" jdate ")(/" time ")?"
363				tzpattern = "^(:.*|" tzname offset "(" tzname \
364				  "(" offset ")?(" datetime datetime ")?)?)$"
365				if (TZ ~ tzpattern) exit 1
366				exit 0
367			}'
368		do
369		    say >&2 "'$TZ' is not a conforming Posix timezone string."
370		done
371		TZ_for_date=$TZ;;
372	*)
373		case $continent in
374		coord)
375		    case $coord in
376		    '')
377			echo >&2 'Please enter coordinates' \
378				'in ISO 6709 notation.'
379			echo >&2 'For example, +4042-07403 stands for'
380			echo >&2 '40 degrees 42 minutes north,' \
381				'74 degrees 3 minutes west.'
382			read coord;;
383		    esac
384		    distance_table=`$AWK \
385			    -v coord="$coord" \
386			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
387			    "$output_distances" <"$TZ_ZONE_TABLE" |
388		      sort -n |
389		      sed "${location_limit}q"
390		    `
391		    regions=`say "$distance_table" | $AWK '
392		      BEGIN { FS = "\t" }
393		      { print $NF }
394		    '`
395		    echo >&2 'Please select one of the following timezones,' \
396		    echo >&2 'listed roughly in increasing order' \
397			    "of distance from $coord".
398		    doselect $regions
399		    region=$select_result
400		    TZ=`say "$distance_table" | $AWK -v region="$region" '
401		      BEGIN { FS="\t" }
402		      $NF == region { print $4 }
403		    '`
404		    ;;
405		*)
406		# Get list of names of countries in the continent or ocean.
407		countries=`$AWK \
408			-v continent_re="^$continent/" \
409			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
410		'
411			BEGIN { FS = "\t" }
412			/^#$/ { next }
413			/^#[^@]/ { next }
414			{
415			  commentary = $0 ~ /^#@/
416			  if (commentary) {
417			    col1ccs = substr($1, 3)
418			    conts = $2
419			  } else {
420			    col1ccs = $1
421			    conts = $3
422			  }
423			  ncc = split(col1ccs, cc, /,/)
424			  ncont = split(conts, cont, /,/)
425			  for (i = 1; i <= ncc; i++) {
426			    elsewhere = commentary
427			    for (ci = 1; ci <= ncont; ci++) {
428			      if (cont[ci] ~ continent_re) {
429				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
430				elsewhere = 0
431			      }
432			    }
433			    if (elsewhere) {
434			      for (i = 1; i <= ncc; i++) {
435			        cc_elsewhere[cc[i]] = 1
436			      }
437			    }
438			  }
439			}
440			END {
441				while (getline <TZ_COUNTRY_TABLE) {
442					if ($0 !~ /^#/) cc_name[$1] = $2
443				}
444				for (i = 1; i <= ccs; i++) {
445					country = cc_list[i]
446					if (cc_elsewhere[country]) continue
447					if (cc_name[country]) {
448					  country = cc_name[country]
449					}
450					print country
451				}
452			}
453		' <"$TZ_ZONE_TABLE" | sort -f`
454
455
456		# If there's more than one country, ask the user which one.
457		case $countries in
458		*"$newline"*)
459			echo >&2 'Please select a country' \
460				'whose clocks agree with yours.'
461			doselect $countries
462			country=$select_result;;
463		*)
464			country=$countries
465		esac
466
467
468		# Get list of timezones in the country.
469		regions=`$AWK \
470			-v country="$country" \
471			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
472		'
473			BEGIN {
474				FS = "\t"
475				cc = country
476				while (getline <TZ_COUNTRY_TABLE) {
477					if ($0 !~ /^#/  &&  country == $2) {
478						cc = $1
479						break
480					}
481				}
482			}
483			/^#/ { next }
484			$1 ~ cc { print $4 }
485		' <"$TZ_ZONE_TABLE"`
486
487
488		# If there's more than one region, ask the user which one.
489		case $regions in
490		*"$newline"*)
491			echo >&2 'Please select one of the following timezones.'
492			doselect $regions
493			region=$select_result;;
494		*)
495			region=$regions
496		esac
497
498		# Determine TZ from country and region.
499		TZ=`$AWK \
500			-v country="$country" \
501			-v region="$region" \
502			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
503		'
504			BEGIN {
505				FS = "\t"
506				cc = country
507				while (getline <TZ_COUNTRY_TABLE) {
508					if ($0 !~ /^#/  &&  country == $2) {
509						cc = $1
510						break
511					}
512				}
513			}
514			/^#/ { next }
515			$1 ~ cc && $4 == region { print $3 }
516		' <"$TZ_ZONE_TABLE"`
517		esac
518
519		# Make sure the corresponding zoneinfo file exists.
520		TZ_for_date=$TZDIR/$TZ
521		<"$TZ_for_date" || {
522			say >&2 "$0: time zone files are not set up correctly"
523			exit 1
524		}
525	esac
526
527
528	# Use the proposed TZ to output the current date relative to UTC.
529	# Loop until they agree in seconds.
530	# Give up after 8 unsuccessful tries.
531
532	extra_info=
533	for i in 1 2 3 4 5 6 7 8
534	do
535		TZdate=`LANG=C TZ="$TZ_for_date" date`
536		UTdate=`LANG=C TZ=UTC0 date`
537		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
538		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
539		case $TZsec in
540		$UTsec)
541			extra_info="
542Selected time is now:	$TZdate.
543Universal Time is now:	$UTdate."
544			break
545		esac
546	done
547
548
549	# Output TZ info and ask the user to confirm.
550
551	echo >&2 ""
552	echo >&2 "The following information has been given:"
553	echo >&2 ""
554	case $country%$region%$coord in
555	?*%?*%)	say >&2 "	$country$newline	$region";;
556	?*%%)	say >&2 "	$country";;
557	%?*%?*) say >&2 "	coord $coord$newline	$region";;
558	%%?*)	say >&2 "	coord $coord";;
559	*)	say >&2 "	TZ='$TZ'"
560	esac
561	say >&2 ""
562	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
563	say >&2 "Is the above information OK?"
564
565	doselect Yes No
566	ok=$select_result
567	case $ok in
568	Yes) break
569	esac
570do coord=
571done
572
573case $SHELL in
574*csh) file=.login line="setenv TZ '$TZ'";;
575*) file=.profile line="TZ='$TZ'; export TZ"
576esac
577
578test -t 1 && say >&2 "
579You can make this change permanent for yourself by appending the line
580	$line
581to the file '$file' in your home directory; then log out and log in again.
582
583Here is that TZ value again, this time on standard output so that you
584can use the $0 command in shell scripts:"
585
586say "$TZ"
587