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.23 2024/02/17 14:54:47 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# This script does not want path expansion.
42set -f
43
44# Specify default values for environment variables if they are unset.
45: ${AWK=awk}
46: ${PWD=`pwd`}
47: ${TZDIR=$PWD}
48
49# Output one argument as-is to standard output, with trailing newline.
50# Safer than 'echo', which can mishandle '\' or leading '-'.
51say() {
52    printf '%s\n' "$1"
53}
54
55# Check for awk Posix compliance.
56($AWK -v x=y 'BEGIN { exit 123 }') </dev/null >/dev/null 2>&1
57[ $? = 123 ] || {
58	say >&2 "$0: Sorry, your '$AWK' program is not Posix compatible."
59	exit 1
60}
61
62coord=
63location_limit=10
64zonetabtype=zone1970
65
66usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
67Select a timezone interactively.
68
69Options:
70
71  -c COORD
72    Instead of asking for continent and then country and then city,
73    ask for selection from time zones whose largest cities
74    are closest to the location with geographical coordinates COORD.
75    COORD should use ISO 6709 notation, for example, '-c +4852+00220'
76    for Paris (in degrees and minutes, North and East), or
77    '-c -35-058' for Buenos Aires (in degrees, South and West).
78
79  -n LIMIT
80    Display at most LIMIT locations when -c is used (default $location_limit).
81
82  --version
83    Output version information.
84
85  --help
86    Output this help.
87
88Report bugs to $REPORT_BUGS_TO."
89
90# Ask the user to select from the function's arguments,
91# and assign the selected argument to the variable 'select_result'.
92# Exit on EOF or I/O error.  Use the shell's nicer 'select' builtin if
93# available, falling back on a portable substitute otherwise.
94if
95  case $BASH_VERSION in
96  ?*) : ;;
97  '')
98    # '; exit' should be redundant, but Dash doesn't properly fail without it.
99    (eval 'set --; select x; do break; done; exit') </dev/null 2>/dev/null
100  esac
101then
102  # Do this inside 'eval', as otherwise the shell might exit when parsing it
103  # even though it is never executed.
104  eval '
105    doselect() {
106      select select_result
107      do
108	case $select_result in
109	"") echo >&2 "Please enter a number in range." ;;
110	?*) break
111	esac
112      done || exit
113    }
114  '
115else
116  doselect() {
117    # Field width of the prompt numbers.
118    print_nargs_length="BEGIN {print length(\"$#\");}"
119    select_width=`$AWK "$print_nargs_length"`
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=`$AWK "BEGIN { print $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 `$AWK "BEGIN { print $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 `$AWK "BEGIN { print $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}
203
204newline='
205'
206IFS=$newline
207
208# Awk script to output a country list.
209output_country_list='
210  BEGIN { FS = "\t" }
211  /^#$/ { next }
212  /^#[^@]/ { next }
213  {
214    commentary = $0 ~ /^#@/
215    if (commentary) {
216      col1ccs = substr($1, 3)
217      conts = $2
218    } else {
219      col1ccs = $1
220      conts = $3
221    }
222    ncc = split(col1ccs, cc, /,/)
223    ncont = split(conts, cont, /,/)
224    for (i = 1; i <= ncc; i++) {
225      elsewhere = commentary
226      for (ci = 1; ci <= ncont; ci++) {
227	if (cont[ci] ~ continent_re) {
228	  if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
229	  elsewhere = 0
230	}
231      }
232      if (elsewhere) {
233	for (i = 1; i <= ncc; i++) {
234	  cc_elsewhere[cc[i]] = 1
235	}
236      }
237    }
238  }
239  END {
240	  while (getline <TZ_COUNTRY_TABLE) {
241		  if ($0 !~ /^#/) cc_name[$1] = $2
242	  }
243	  for (i = 1; i <= ccs; i++) {
244		  country = cc_list[i]
245		  if (cc_elsewhere[country]) continue
246		  if (cc_name[country]) {
247		    country = cc_name[country]
248		  }
249		  print country
250	  }
251  }
252'
253
254# Awk script to read a time zone table and output the same table,
255# with each row preceded by its distance from 'here'.
256# If output_times is set, each row is instead preceded by its local time
257# and any apostrophes are escaped for the shell.
258output_distances_or_times='
259  BEGIN {
260    FS = "\t"
261    if (!output_times) {
262      while (getline <TZ_COUNTRY_TABLE)
263	if ($0 ~ /^[^#]/)
264	  country[$1] = $2
265      country["US"] = "US" # Otherwise the strings get too long.
266    }
267  }
268  function abs(x) {
269    return x < 0 ? -x : x;
270  }
271  function min(x, y) {
272    return x < y ? x : y;
273  }
274  function convert_coord(coord, deg, minute, ilen, sign, sec) {
275    if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
276      degminsec = coord
277      intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
278      minsec = degminsec - intdeg * 10000
279      intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
280      sec = minsec - intmin * 100
281      deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
282    } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
283      degmin = coord
284      intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
285      minute = degmin - intdeg * 100
286      deg = (intdeg * 60 + minute) / 60
287    } else
288      deg = coord
289    return deg * 0.017453292519943296
290  }
291  function convert_latitude(coord) {
292    match(coord, /..*[-+]/)
293    return convert_coord(substr(coord, 1, RLENGTH - 1))
294  }
295  function convert_longitude(coord) {
296    match(coord, /..*[-+]/)
297    return convert_coord(substr(coord, RLENGTH))
298  }
299  # Great-circle distance between points with given latitude and longitude.
300  # Inputs and output are in radians.  This uses the great-circle special
301  # case of the Vicenty formula for distances on ellipsoids.
302  function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
303    dlong = long2 - long1
304    x = cos(lat2) * sin(dlong)
305    y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
306    num = sqrt(x * x + y * y)
307    denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
308    return atan2(num, denom)
309  }
310  # Parallel distance between points with given latitude and longitude.
311  # This is the product of the longitude difference and the cosine
312  # of the latitude of the point that is further from the equator.
313  # I.e., it considers longitudes to be further apart if they are
314  # nearer the equator.
315  function pardist(lat1, long1, lat2, long2) {
316    return abs(long1 - long2) * min(cos(lat1), cos(lat2))
317  }
318  # The distance function is the sum of the great-circle distance and
319  # the parallel distance.  It could be weighted.
320  function dist(lat1, long1, lat2, long2) {
321    return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
322  }
323  BEGIN {
324    coord_lat = convert_latitude(coord)
325    coord_long = convert_longitude(coord)
326  }
327  /^[^#]/ {
328    inline[inlines++] = $0
329    ncc = split($1, cc, /,/)
330    for (i = 1; i <= ncc; i++)
331      cc_used[cc[i]]++
332  }
333  END {
334   for (h = 0; h < inlines; h++) {
335    $0 = inline[h]
336    line = $1 "\t" $2 "\t" $3
337    sep = "\t"
338    ncc = split($1, cc, /,/)
339    split("", item_seen)
340    item_seen[""] = 1
341    for (i = 1; i <= ncc; i++) {
342      item = cc_used[cc[i]] <= 1 ? country[cc[i]] : $4
343      if (item_seen[item]++) continue
344      line = line sep item
345      sep = "; "
346    }
347    if (output_times) {
348      fmt = "TZ='\''%s'\'' date +'\''%d %%Y %%m %%d %%H:%%M %%a %%b\t%s'\''\n"
349      gsub(/'\''/, "&\\\\&&", line)
350      printf fmt, $3, h, line
351    } else {
352      here_lat = convert_latitude($2)
353      here_long = convert_longitude($2)
354      printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line
355    }
356   }
357  }
358'
359
360# Begin the main loop.  We come back here if the user wants to retry.
361while
362
363	echo >&2 'Please identify a location' \
364		'so that time zone rules can be set correctly.'
365
366	continent=
367	country=
368	region=
369
370	case $coord in
371	?*)
372		continent=coord;;
373	'')
374
375	# Ask the user for continent or ocean.
376
377	echo >&2 'Please select a continent, ocean, "coord", "TZ", or "time".'
378
379        quoted_continents=`
380	  $AWK '
381	    function handle_entry(entry) {
382	      entry = substr(entry, 1, index(entry, "/") - 1)
383	      if (entry == "America")
384	       entry = entry "s"
385	      if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/)
386	       entry = entry " Ocean"
387	      printf "'\''%s'\''\n", entry
388	    }
389	    BEGIN { FS = "\t" }
390	    /^[^#]/ {
391              handle_entry($3)
392            }
393	    /^#@/ {
394	      ncont = split($2, cont, /,/)
395	      for (ci = 1; ci <= ncont; ci++) {
396	        handle_entry(cont[ci])
397	      }
398	    }
399          ' <"$TZ_ZONE_TABLE" |
400	  sort -u |
401	  tr '\n' ' '
402	  echo ''
403	`
404
405	eval '
406	    doselect '"$quoted_continents"' \
407		"coord - I want to use geographical coordinates." \
408		"TZ - I want to specify the timezone using a POSIX.1-2017 TZ string." \
409		"time - I know local time already."
410	    continent=$select_result
411	    case $continent in
412	    Americas) continent=America;;
413	    *)
414		# Get the first word of $continent.  Path expansion is disabled
415		# so this works even with "*", which should not happen.
416		IFS=" "
417		for continent in $continent ""; do break; done
418		IFS=$newline;;
419	    esac
420	'
421	esac
422
423	case $continent in
424	TZ)
425		# Ask the user for a POSIX.1-2017 TZ string.  Check that it conforms.
426		while
427			echo >&2 'Please enter the desired value' \
428				'of the TZ environment variable.'
429			echo >&2 'For example, AEST-10 is abbreviated' \
430				'AEST and is 10 hours'
431			echo >&2 'ahead (east) of Greenwich,' \
432				'with no daylight saving time.'
433			read TZ
434			$AWK -v TZ="$TZ" 'BEGIN {
435				tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
436				time = "(2[0-4]|[0-1]?[0-9])" \
437				  "(:[0-5][0-9](:[0-5][0-9])?)?"
438				offset = "[-+]?" time
439				mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
440				jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
441				  "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
442				datetime = ",(" mdate "|" jdate ")(/" time ")?"
443				tzpattern = "^(:.*|" tzname offset "(" tzname \
444				  "(" offset ")?(" datetime datetime ")?)?)$"
445				if (TZ ~ tzpattern) exit 1
446				exit 0
447			}'
448		do
449		    say >&2 "'$tz' is not a conforming POSIX.1-2017 timezone string."
450		done
451		TZ_for_date=$TZ;;
452	*)
453		case $continent in
454		coord)
455		    case $coord in
456		    '')
457			echo >&2 'Please enter coordinates' \
458				'in ISO 6709 notation.'
459			echo >&2 'For example, +4042-07403 stands for'
460			echo >&2 '40 degrees 42 minutes north,' \
461				'74 degrees 3 minutes west.'
462			read coord;;
463		    esac
464		    distance_table=`$AWK \
465			    -v coord="$coord" \
466			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
467			    "$output_distances_or_times" <"$TZ_ZONE_TABLE" |
468		      sort -n |
469		      $AWK "{print} NR == $location_limit { exit }"
470		    `
471		    regions=`$AWK \
472		      -v distance_table="$distance_table" '
473		      BEGIN {
474		        nlines = split(distance_table, line, /\n/)
475			for (nr = 1; nr <= nlines; nr++) {
476			  nf = split(line[nr], f, /\t/)
477			  print f[nf]
478			}
479		      }
480		    '`
481		    echo >&2 'Please select one of the following timezones,'
482		    echo >&2 'listed roughly in increasing order' \
483			    "of distance from $coord".
484		    doselect $regions
485		    region=$select_result
486		    TZ=`$AWK \
487		      -v distance_table="$distance_table" \
488		      -v region="$region" '
489		      BEGIN {
490		        nlines = split(distance_table, line, /\n/)
491			for (nr = 1; nr <= nlines; nr++) {
492			  nf = split(line[nr], f, /\t/)
493			  if (f[nf] == region) {
494			    print f[4]
495			  }
496			}
497		      }
498		    '`
499		    ;;
500		*)
501		case $continent in
502		time)
503		  minute_format='%a %b %d %H:%M'
504		  old_minute=`TZ=UTC0 date +"$minute_format"`
505		  for i in 1 2 3
506		  do
507		    time_table_command=`
508		      $AWK -v output_times=1 \
509			  "$output_distances_or_times" <"$TZ_ZONE_TABLE"
510		    `
511		    time_table=`eval "$time_table_command"`
512		    new_minute=`TZ=UTC0 date +"$minute_format"`
513		    case $old_minute in
514		    "$new_minute") break;;
515		    esac
516		    old_minute=$new_minute
517		  done
518		  echo >&2 "The system says Universal Time is $new_minute."
519		  echo >&2 "Assuming that's correct, what is the local time?"
520		  eval doselect `
521		    say "$time_table" |
522		    sort -k2n -k2,5 -k1n |
523		    $AWK '{
524		      line = $6 " " $7 " " $4 " " $5
525		      if (line == oldline) next
526		      oldline = line
527		      gsub(/'\''/, "&\\\\&&", line)
528		      printf "'\''%s'\''\n", line
529		    }'
530		  `
531		  time=$select_result
532		  zone_table=`
533		    say "$time_table" |
534		    $AWK -v time="$time" '{
535		      if ($6 " " $7 " " $4 " " $5 == time) {
536			sub(/[^\t]*\t/, "")
537			print
538		      }
539		    }'
540		  `
541		  countries=`
542		     say "$zone_table" |
543		     $AWK \
544			-v continent_re='' \
545			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
546			"$output_country_list" |
547		     sort -f
548		  `
549		  ;;
550		*)
551		  zone_table=file
552		  # Get list of names of countries in the continent or ocean.
553		  countries=`$AWK \
554			-v continent_re="^$continent/" \
555			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
556			"$output_country_list" \
557			<"$TZ_ZONE_TABLE" | sort -f
558		  `;;
559		esac
560
561		# If there's more than one country, ask the user which one.
562		case $countries in
563		*"$newline"*)
564			echo >&2 'Please select a country' \
565				'whose clocks agree with yours.'
566			doselect $countries
567			country_result=$select_result
568			country=$select_result;;
569		*)
570			country=$countries
571		esac
572
573
574		# Get list of timezones in the country.
575		regions=`
576		  case $zone_table in
577		  file) cat -- "$TZ_ZONE_TABLE";;
578		  *) say "$zone_table";;
579		  esac |
580		  $AWK \
581			-v country="$country" \
582			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
583		    '
584			BEGIN {
585				FS = "\t"
586				cc = country
587				while (getline <TZ_COUNTRY_TABLE) {
588					if ($0 !~ /^#/  &&  country == $2) {
589						cc = $1
590						break
591					}
592				}
593			}
594			/^#/ { next }
595			$1 ~ cc { print $4 }
596		    '
597		`
598
599
600		# If there's more than one region, ask the user which one.
601		case $regions in
602		*"$newline"*)
603			echo >&2 'Please select one of the following timezones.'
604			doselect $regions
605			region=$select_result
606		esac
607
608		# Determine TZ from country and region.
609		TZ=`
610		  case $zone_table in
611		  file) cat -- "$TZ_ZONE_TABLE";;
612		  *) say "$zone_table";;
613		  esac |
614		  $AWK \
615			-v country="$country" \
616			-v region="$region" \
617			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
618		    '
619			BEGIN {
620				FS = "\t"
621				cc = country
622				while (getline <TZ_COUNTRY_TABLE) {
623					if ($0 !~ /^#/  &&  country == $2) {
624						cc = $1
625						break
626					}
627				}
628			}
629			/^#/ { next }
630			$1 ~ cc && ($4 == region || !region) { print $3 }
631		    '
632		`;;
633		esac
634
635		# Make sure the corresponding zoneinfo file exists.
636		TZ_for_date=$TZDIR/$TZ
637		<"$TZ_for_date" || {
638			say >&2 "$0: time zone files are not set up correctly"
639			exit 1
640		}
641	esac
642
643
644	# Use the proposed TZ to output the current date relative to UTC.
645	# Loop until they agree in seconds.
646	# Give up after 8 unsuccessful tries.
647
648	extra_info=
649	for i in 1 2 3 4 5 6 7 8
650	do
651		TZdate=`LANG=C TZ="$TZ_for_date" date`
652		UTdate=`LANG=C TZ=UTC0 date`
653		if $AWK '
654		      function getsecs(d) {
655			return match(d, /.*:[0-5][0-9]/) ? substr(d, RLENGTH - 1, 2) : ""
656		      }
657		      BEGIN { exit getsecs(ARGV[1]) != getsecs(ARGV[2]) }
658		   ' ="$TZdate" ="$UTdate"
659		then
660			extra_info="
661Selected time is now:	$TZdate.
662Universal Time is now:	$UTdate."
663			break
664		fi
665	done
666
667
668	# Output TZ info and ask the user to confirm.
669
670	echo >&2 ""
671	echo >&2 "Based on the following information:"
672	echo >&2 ""
673	case $time%$country_result%$region%$coord in
674	?*%?*%?*%)
675	  say >&2 "	$time$newline	$country_result$newline	$region";;
676	?*%?*%%|?*%%?*%) say >&2 "	$time$newline	$country_result$region";;
677	?*%%%) say >&2 "	$time";;
678	%?*%?*%) say >&2 "	$country_result$newline	$region";;
679	%?*%%)	say >&2 "	$country_result";;
680	%%?*%?*) say >&2 "	coord $coord$newline	$region";;
681	%%%?*)	say >&2 "	coord $coord";;
682	*)	say >&2 "	TZ='$TZ'"
683	esac
684	say >&2 ""
685	say >&2 "TZ='$TZ' will be used.$extra_info"
686	say >&2 "Is the above information OK?"
687
688	doselect Yes No
689	ok=$select_result
690	case $ok in
691	Yes) break
692	esac
693do coord=
694done
695
696case $SHELL in
697*csh) file=.login line="setenv TZ '$TZ'";;
698*) file=.profile line="TZ='$TZ'; export TZ"
699esac
700
701test -t 1 && say >&2 "
702You can make this change permanent for yourself by appending the line
703	$line
704to the file '$file' in your home directory; then log out and log in again.
705
706Here is that TZ value again, this time on standard output so that you
707can use the $0 command in shell scripts:"
708
709say "$TZ"
710