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