Home | History | Annotate | Line # | Download | only in time
tzselect.ksh revision 1.17
      1 #! /bin/bash
      2 #
      3 #	$NetBSD: tzselect.ksh,v 1.17 2018/01/25 22:48:42 christos Exp $
      4 #
      5 PKGVERSION='(tzcode) '
      6 TZVERSION=see_Makefile
      7 REPORT_BUGS_TO=tz@iana.org
      8 
      9 # Ask the user about the time zone, and output the resulting TZ value to stdout.
     10 # Interact with the user via stderr and stdin.
     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 time zone 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 time zone 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 a zone named AEST' \
    348 				'that 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 time zone 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' \
    394 			    'time zone regions,'
    395 		    echo >&2 'listed roughly in increasing order' \
    396 			    "of distance from $coord".
    397 		    doselect $regions
    398 		    region=$select_result
    399 		    TZ=`say "$distance_table" | $AWK -v region="$region" '
    400 		      BEGIN { FS="\t" }
    401 		      $NF == region { print $4 }
    402 		    '`
    403 		    ;;
    404 		*)
    405 		# Get list of names of countries in the continent or ocean.
    406 		countries=`$AWK \
    407 			-v continent="$continent" \
    408 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    409 		'
    410 			BEGIN { FS = "\t" }
    411 			/^#/ { next }
    412 			$3 ~ ("^" continent "/") {
    413 			    ncc = split($1, cc, /,/)
    414 			    for (i = 1; i <= ncc; i++)
    415 				if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
    416 			}
    417 			END {
    418 				while (getline <TZ_COUNTRY_TABLE) {
    419 					if ($0 !~ /^#/) cc_name[$1] = $2
    420 				}
    421 				for (i = 1; i <= ccs; i++) {
    422 					country = cc_list[i]
    423 					if (cc_name[country]) {
    424 					  country = cc_name[country]
    425 					}
    426 					print country
    427 				}
    428 			}
    429 		' <"$TZ_ZONE_TABLE" | sort -f`
    430 
    431 
    432 		# If there's more than one country, ask the user which one.
    433 		case $countries in
    434 		*"$newline"*)
    435 			echo >&2 'Please select a country' \
    436 				'whose clocks agree with yours.'
    437 			doselect $countries
    438 			country=$select_result;;
    439 		*)
    440 			country=$countries
    441 		esac
    442 
    443 
    444 		# Get list of names of time zone rule regions in the country.
    445 		regions=`$AWK \
    446 			-v country="$country" \
    447 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    448 		'
    449 			BEGIN {
    450 				FS = "\t"
    451 				cc = country
    452 				while (getline <TZ_COUNTRY_TABLE) {
    453 					if ($0 !~ /^#/  &&  country == $2) {
    454 						cc = $1
    455 						break
    456 					}
    457 				}
    458 			}
    459 			/^#/ { next }
    460 			$1 ~ cc { print $4 }
    461 		' <"$TZ_ZONE_TABLE"`
    462 
    463 
    464 		# If there's more than one region, ask the user which one.
    465 		case $regions in
    466 		*"$newline"*)
    467 			echo >&2 'Please select one of the following' \
    468 				'time zone regions.'
    469 			doselect $regions
    470 			region=$select_result;;
    471 		*)
    472 			region=$regions
    473 		esac
    474 
    475 		# Determine TZ from country and region.
    476 		TZ=`$AWK \
    477 			-v country="$country" \
    478 			-v region="$region" \
    479 			-v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
    480 		'
    481 			BEGIN {
    482 				FS = "\t"
    483 				cc = country
    484 				while (getline <TZ_COUNTRY_TABLE) {
    485 					if ($0 !~ /^#/  &&  country == $2) {
    486 						cc = $1
    487 						break
    488 					}
    489 				}
    490 			}
    491 			/^#/ { next }
    492 			$1 ~ cc && $4 == region { print $3 }
    493 		' <"$TZ_ZONE_TABLE"`
    494 		esac
    495 
    496 		# Make sure the corresponding zoneinfo file exists.
    497 		TZ_for_date=$TZDIR/$TZ
    498 		<"$TZ_for_date" || {
    499 			say >&2 "$0: time zone files are not set up correctly"
    500 			exit 1
    501 		}
    502 	esac
    503 
    504 
    505 	# Use the proposed TZ to output the current date relative to UTC.
    506 	# Loop until they agree in seconds.
    507 	# Give up after 8 unsuccessful tries.
    508 
    509 	extra_info=
    510 	for i in 1 2 3 4 5 6 7 8
    511 	do
    512 		TZdate=`LANG=C TZ="$TZ_for_date" date`
    513 		UTdate=`LANG=C TZ=UTC0 date`
    514 		TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
    515 		UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
    516 		case $TZsec in
    517 		$UTsec)
    518 			extra_info="
    519 Selected time is now:	$TZdate.
    520 Universal Time is now:	$UTdate."
    521 			break
    522 		esac
    523 	done
    524 
    525 
    526 	# Output TZ info and ask the user to confirm.
    527 
    528 	echo >&2 ""
    529 	echo >&2 "The following information has been given:"
    530 	echo >&2 ""
    531 	case $country%$region%$coord in
    532 	?*%?*%)	say >&2 "	$country$newline	$region";;
    533 	?*%%)	say >&2 "	$country";;
    534 	%?*%?*) say >&2 "	coord $coord$newline	$region";;
    535 	%%?*)	say >&2 "	coord $coord";;
    536 	*)	say >&2 "	TZ='$TZ'"
    537 	esac
    538 	say >&2 ""
    539 	say >&2 "Therefore TZ='$TZ' will be used.$extra_info"
    540 	say >&2 "Is the above information OK?"
    541 
    542 	doselect Yes No
    543 	ok=$select_result
    544 	case $ok in
    545 	Yes) break
    546 	esac
    547 do coord=
    548 done
    549 
    550 case $SHELL in
    551 *csh) file=.login line="setenv TZ '$TZ'";;
    552 *) file=.profile line="TZ='$TZ'; export TZ"
    553 esac
    554 
    555 test -t 1 && say >&2 "
    556 You can make this change permanent for yourself by appending the line
    557 	$line
    558 to the file '$file' in your home directory; then log out and log in again.
    559 
    560 Here is that TZ value again, this time on standard output so that you
    561 can use the $0 command in shell scripts:"
    562 
    563 say "$TZ"
    564