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