Home | History | Annotate | Line # | Download | only in time
tzselect.ksh revision 1.21
      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 #
      8 PKGVERSION='(tzcode) '
      9 TZVERSION=see_Makefile
     10 REPORT_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 '-'.
     47 say() {
     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 
     58 coord=
     59 location_limit=10
     60 zonetabtype=zone1970
     61 
     62 usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
     63 Select a timezone interactively.
     64 
     65 Options:
     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 
     84 Report 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.
     90 if
     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
     97 then
     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   '
    111 else
    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   }
    144 fi
    145 
    146 while getopts c:n:t:-: opt
    147 do
    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
    164 done
    165 
    166 shift `expr $OPTIND - 1`
    167 case $# in
    168 0) ;;
    169 *) say >&2 "$0: $1: unknown argument"; exit 1 ;;
    170 esac
    171 
    172 # Make sure the tables are readable.
    173 TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
    174 TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
    175 for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
    176 do
    177 	<"$f" || {
    178 		say >&2 "$0: time zone files are not set up correctly"
    179 		exit 1
    180 	}
    181 done
    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 
    199 newline='
    200 '
    201 IFS=$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'.
    206 output_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.
    290 while
    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="
    545 Selected time is now:	$TZdate.
    546 Universal 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
    573 do coord=
    574 done
    575 
    576 case $SHELL in
    577 *csh) file=.login line="setenv TZ '$TZ'";;
    578 *) file=.profile line="TZ='$TZ'; export TZ"
    579 esac
    580 
    581 test -t 1 && say >&2 "
    582 You can make this change permanent for yourself by appending the line
    583 	$line
    584 to the file '$file' in your home directory; then log out and log in again.
    585 
    586 Here is that TZ value again, this time on standard output so that you
    587 can use the $0 command in shell scripts:"
    588 
    589 say "$TZ"
    590