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