Home | History | Annotate | Line # | Download | only in time
tzselect.ksh revision 1.17.4.1
      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.17.4.1 2019/06/10 22:05:22 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 <https://www.mirbsd.org/mksh.htm>
     25 #
     26 # For portability to Solaris 9 /bin/sh this script avoids some POSIX
     27 # features and common extensions, such as $(...) (which works sometimes
     28 # but not others), $((...)), and $10.
     29 #
     30 # This script also uses several features of modern awk programs.
     31 # If your host lacks awk, or has an old awk that does not conform to Posix,
     32 # you can use either of the following free programs instead:
     33 #
     34 #	Gawk (GNU awk) <https://www.gnu.org/software/gawk/>
     35 #	mawk <https://invisible-island.net/mawk/>
     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.
     43 # Safer than 'echo', which can mishandle '\' or leading '-'.
     44 say() {
     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 
     55 coord=
     56 location_limit=10
     57 zonetabtype=zone1970
     58 
     59 usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
     60 Select a timezone interactively.
     61 
     62 Options:
     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 
     81 Report 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 'select' builtin if available,
     86 # falling back on a less-nice but portable substitute otherwise.
     87 if
     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
     94 then
     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 
    108     # Work around a bug in bash 1.14.7 and earlier, where $PS3 is sent to stdout.
    109     case $BASH_VERSION in
    110     [01].*)
    111       case `echo 1 | (select x in x; do break; done) 2>/dev/null` in
    112       ?*) PS3=
    113       esac
    114     esac
    115   '
    116 else
    117   doselect() {
    118     # Field width of the prompt numbers.
    119     select_width=`expr $# : '.*'`
    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=`expr $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 `expr $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   }
    149 fi
    150 
    151 while getopts c:n:t:-: opt
    152 do
    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
    169 done
    170 
    171 shift `expr $OPTIND - 1`
    172 case $# in
    173 0) ;;
    174 *) say >&2 "$0: $1: unknown argument"; exit 1 ;;
    175 esac
    176 
    177 # Make sure the tables are readable.
    178 TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
    179 TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
    180 for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
    181 do
    182 	<"$f" || {
    183 		say >&2 "$0: time zone files are not set up correctly"
    184 		exit 1
    185 	}
    186 done
    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 newline='
    204 '
    205 IFS=$newline
    206 
    207 
    208 # Awk script to read a time zone table and output the same table,
    209 # with each column preceded by its distance from 'here'.
    210 output_distances='
    211   BEGIN {
    212     FS = "\t"
    213     while (getline <TZ_COUNTRY_TABLE)
    214       if ($0 ~ /^[^#]/)
    215         country[$1] = $2
    216     country["US"] = "US" # Otherwise the strings get too long.
    217   }
    218   function abs(x) {
    219     return x < 0 ? -x : x;
    220   }
    221   function min(x, y) {
    222     return x < y ? x : y;
    223   }
    224   function convert_coord(coord, deg, minute, ilen, sign, sec) {
    225     if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
    226       degminsec = coord
    227       intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
    228       minsec = degminsec - intdeg * 10000
    229       intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
    230       sec = minsec - intmin * 100
    231       deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
    232     } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
    233       degmin = coord
    234       intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
    235       minute = degmin - intdeg * 100
    236       deg = (intdeg * 60 + minute) / 60
    237     } else
    238       deg = coord
    239     return deg * 0.017453292519943296
    240   }
    241   function convert_latitude(coord) {
    242     match(coord, /..*[-+]/)
    243     return convert_coord(substr(coord, 1, RLENGTH - 1))
    244   }
    245   function convert_longitude(coord) {
    246     match(coord, /..*[-+]/)
    247     return convert_coord(substr(coord, RLENGTH))
    248   }
    249   # Great-circle distance between points with given latitude and longitude.
    250   # Inputs and output are in radians.  This uses the great-circle special
    251   # case of the Vicenty formula for distances on ellipsoids.
    252   function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
    253     dlong = long2 - long1
    254     x = cos(lat2) * sin(dlong)
    255     y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
    256     num = sqrt(x * x + y * y)
    257     denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
    258     return atan2(num, denom)
    259   }
    260   # Parallel distance between points with given latitude and longitude.
    261   # This is the product of the longitude difference and the cosine
    262   # of the latitude of the point that is further from the equator.
    263   # I.e., it considers longitudes to be further apart if they are
    264   # nearer the equator.
    265   function pardist(lat1, long1, lat2, long2) {
    266     return abs(long1 - long2) * min(cos(lat1), cos(lat2))
    267   }
    268   # The distance function is the sum of the great-circle distance and
    269   # the parallel distance.  It could be weighted.
    270   function dist(lat1, long1, lat2, long2) {
    271     return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
    272   }
    273   BEGIN {
    274     coord_lat = convert_latitude(coord)
    275     coord_long = convert_longitude(coord)
    276   }
    277   /^[^#]/ {
    278     here_lat = convert_latitude($2)
    279     here_long = convert_longitude($2)
    280     line = $1 "\t" $2 "\t" $3
    281     sep = "\t"
    282     ncc = split($1, cc, /,/)
    283     for (i = 1; i <= ncc; i++) {
    284       line = line sep country[cc[i]]
    285       sep = ", "
    286     }
    287     if (NF == 4)
    288       line = line " - " $4
    289     printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line
    290   }
    291 '
    292 
    293 # Begin the main loop.  We come back here if the user wants to retry.
    294 while
    295 
    296 	echo >&2 'Please identify a location' \
    297 		'so that time zone rules can be set correctly.'
    298 
    299 	continent=
    300 	country=
    301 	region=
    302 
    303 	case $coord in
    304 	?*)
    305 		continent=coord;;
    306 	'')
    307 
    308 	# Ask the user for continent or ocean.
    309 
    310 	echo >&2 'Please select a continent, ocean, "coord", or "TZ".'
    311 
    312         quoted_continents=`
    313 	  $AWK '
    314 	    BEGIN { FS = "\t" }
    315 	    /^[^#]/ {
    316               entry = substr($3, 1, index($3, "/") - 1)
    317               if (entry == "America")
    318 		entry = entry "s"
    319               if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/)
    320 		entry = entry " Ocean"
    321               printf "'\''%s'\''\n", entry
    322             }
    323           ' <"$TZ_ZONE_TABLE" |
    324 	  sort -u |
    325 	  tr '\n' ' '
    326 	  echo ''
    327 	`
    328 
    329 	eval '
    330 	    doselect '"$quoted_continents"' \
    331 		"coord - I want to use geographical coordinates." \
    332 		"TZ - I want to specify the timezone using the Posix TZ format."
    333 	    continent=$select_result
    334 	    case $continent in
    335 	    Americas) continent=America;;
    336 	    *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''`
    337 	    esac
    338 	'
    339 	esac
    340 
    341 	case $continent in
    342 	TZ)
    343 		# Ask the user for a Posix TZ string.  Check that it conforms.
    344 		while
    345 			echo >&2 'Please enter the desired value' \
    346 				'of the TZ environment variable.'
    347 			echo >&2 'For example, AEST-10 is abbreviated' \
    348 				'AEST and is 10 hours'
    349 			echo >&2 'ahead (east) of Greenwich,' \
    350 				'with no daylight saving time.'
    351 			read TZ
    352 			$AWK -v TZ="$TZ" 'BEGIN {
    353 				tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
    354 				time = "(2[0-4]|[0-1]?[0-9])" \
    355 				  "(:[0-5][0-9](:[0-5][0-9])?)?"
    356 				offset = "[-+]?" time
    357 				mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
    358 				jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
    359 				  "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
    360 				datetime = ",(" mdate "|" jdate ")(/" time ")?"
    361 				tzpattern = "^(:.*|" tzname offset "(" tzname \
    362 				  "(" offset ")?(" datetime datetime ")?)?)$"
    363 				if (TZ ~ tzpattern) exit 1
    364 				exit 0
    365 			}'
    366 		do
    367 		    say >&2 "'$TZ' is not a conforming Posix timezone string."
    368 		done
    369 		TZ_for_date=$TZ;;
    370 	*)
    371 		case $continent in
    372 		coord)
    373 		    case $coord in
    374 		    '')
    375 			echo >&2 'Please enter coordinates' \
    376 				'in ISO 6709 notation.'
    377 			echo >&2 'For example, +4042-07403 stands for'
    378 			echo >&2 '40 degrees 42 minutes north,' \
    379 				'74 degrees 3 minutes west.'
    380 			read coord;;
    381 		    esac
    382 		    distance_table=`$AWK \
    383 			    -v coord="$coord" \
    384 			    -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    385 			    "$output_distances" <"$TZ_ZONE_TABLE" |
    386 		      sort -n |
    387 		      sed "${location_limit}q"
    388 		    `
    389 		    regions=`say "$distance_table" | $AWK '
    390 		      BEGIN { FS = "\t" }
    391 		      { print $NF }
    392 		    '`
    393 		    echo >&2 'Please select one of the following timezones,' \
    394 		    echo >&2 'listed roughly in increasing order' \
    395 			    "of distance from $coord".
    396 		    doselect $regions
    397 		    region=$select_result
    398 		    TZ=`say "$distance_table" | $AWK -v region="$region" '
    399 		      BEGIN { FS="\t" }
    400 		      $NF == region { print $4 }
    401 		    '`
    402 		    ;;
    403 		*)
    404 		# Get list of names of countries in the continent or ocean.
    405 		countries=`$AWK \
    406 			-v continent="$continent" \
    407 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    408 		'
    409 			BEGIN { FS = "\t" }
    410 			/^#/ { next }
    411 			$3 ~ ("^" continent "/") {
    412 			    ncc = split($1, cc, /,/)
    413 			    for (i = 1; i <= ncc; i++)
    414 				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
    415 			}
    416 			END {
    417 				while (getline <TZ_COUNTRY_TABLE) {
    418 					if ($0 !~ /^#/) cc_name[$1] = $2
    419 				}
    420 				for (i = 1; i <= ccs; i++) {
    421 					country = cc_list[i]
    422 					if (cc_name[country]) {
    423 					  country = cc_name[country]
    424 					}
    425 					print country
    426 				}
    427 			}
    428 		' <"$TZ_ZONE_TABLE" | sort -f`
    429 
    430 
    431 		# If there's more than one country, ask the user which one.
    432 		case $countries in
    433 		*"$newline"*)
    434 			echo >&2 'Please select a country' \
    435 				'whose clocks agree with yours.'
    436 			doselect $countries
    437 			country=$select_result;;
    438 		*)
    439 			country=$countries
    440 		esac
    441 
    442 
    443 		# Get list of timezones in the country.
    444 		regions=`$AWK \
    445 			-v country="$country" \
    446 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    447 		'
    448 			BEGIN {
    449 				FS = "\t"
    450 				cc = country
    451 				while (getline <TZ_COUNTRY_TABLE) {
    452 					if ($0 !~ /^#/  &&  country == $2) {
    453 						cc = $1
    454 						break
    455 					}
    456 				}
    457 			}
    458 			/^#/ { next }
    459 			$1 ~ cc { print $4 }
    460 		' <"$TZ_ZONE_TABLE"`
    461 
    462 
    463 		# If there's more than one region, ask the user which one.
    464 		case $regions in
    465 		*"$newline"*)
    466 			echo >&2 'Please select one of the following timezones.'
    467 			doselect $regions
    468 			region=$select_result;;
    469 		*)
    470 			region=$regions
    471 		esac
    472 
    473 		# Determine TZ from country and region.
    474 		TZ=`$AWK \
    475 			-v country="$country" \
    476 			-v region="$region" \
    477 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    478 		'
    479 			BEGIN {
    480 				FS = "\t"
    481 				cc = country
    482 				while (getline <TZ_COUNTRY_TABLE) {
    483 					if ($0 !~ /^#/  &&  country == $2) {
    484 						cc = $1
    485 						break
    486 					}
    487 				}
    488 			}
    489 			/^#/ { next }
    490 			$1 ~ cc && $4 == region { print $3 }
    491 		' <"$TZ_ZONE_TABLE"`
    492 		esac
    493 
    494 		# Make sure the corresponding zoneinfo file exists.
    495 		TZ_for_date=$TZDIR/$TZ
    496 		<"$TZ_for_date" || {
    497 			say >&2 "$0: time zone files are not set up correctly"
    498 			exit 1
    499 		}
    500 	esac
    501 
    502 
    503 	# Use the proposed TZ to output the current date relative to UTC.
    504 	# Loop until they agree in seconds.
    505 	# Give up after 8 unsuccessful tries.
    506 
    507 	extra_info=
    508 	for i in 1 2 3 4 5 6 7 8
    509 	do
    510 		TZdate=`LANG=C TZ="$TZ_for_date" date`
    511 		UTdate=`LANG=C TZ=UTC0 date`
    512 		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
    513 		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
    514 		case $TZsec in
    515 		$UTsec)
    516 			extra_info="
    517 Selected time is now:	$TZdate.
    518 Universal Time is now:	$UTdate."
    519 			break
    520 		esac
    521 	done
    522 
    523 
    524 	# Output TZ info and ask the user to confirm.
    525 
    526 	echo >&2 ""
    527 	echo >&2 "The following information has been given:"
    528 	echo >&2 ""
    529 	case $country%$region%$coord in
    530 	?*%?*%)	say >&2 "	$country$newline	$region";;
    531 	?*%%)	say >&2 "	$country";;
    532 	%?*%?*) say >&2 "	coord $coord$newline	$region";;
    533 	%%?*)	say >&2 "	coord $coord";;
    534 	*)	say >&2 "	TZ='$TZ'"
    535 	esac
    536 	say >&2 ""
    537 	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
    538 	say >&2 "Is the above information OK?"
    539 
    540 	doselect Yes No
    541 	ok=$select_result
    542 	case $ok in
    543 	Yes) break
    544 	esac
    545 do coord=
    546 done
    547 
    548 case $SHELL in
    549 *csh) file=.login line="setenv TZ '$TZ'";;
    550 *) file=.profile line="TZ='$TZ'; export TZ"
    551 esac
    552 
    553 test -t 1 && say >&2 "
    554 You can make this change permanent for yourself by appending the line
    555 	$line
    556 to the file '$file' in your home directory; then log out and log in again.
    557 
    558 Here is that TZ value again, this time on standard output so that you
    559 can use the $0 command in shell scripts:"
    560 
    561 say "$TZ"
    562