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