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 <http://www.mirbsd.org/mksh.htm> 22# 23# For portability to Solaris 10 /bin/sh (supported by Oracle through 24# January 2024) this script avoids some POSIX features and common 25# extensions, such as $(...) (which works sometimes but not others), 26# $((...)), ! CMD, ${#ID}, ${ID##PAT}, ${ID%%PAT}, and $10. 27 28# 29# This script also uses several features of modern awk programs. 30# If your host lacks awk, or has an old awk that does not conform to Posix, 31# you can use either of the following free programs instead: 32# 33# Gawk (GNU awk) <https://www.gnu.org/software/gawk/> 34# mawk <https://invisible-island.net/mawk/> 35# nawk <https://github.com/onetrueawk/awk> 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, with trailing newline. 43# Safer than 'echo', which can mishandle '\' or leading '-'. 44say() { 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 55coord= 56location_limit=10 57zonetabtype=zone1970 58 59usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT] 60Select a timezone interactively. 61 62Options: 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 81Report 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 nicer 'select' builtin if 86# available, falling back on a portable substitute otherwise. 87if 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 94then 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 ' 108else 109 doselect() { 110 # Field width of the prompt numbers. 111 select_width=`expr $# : '.*'` 112 113 select_i= 114 115 while : 116 do 117 case $select_i in 118 '') 119 select_i=0 120 for select_word 121 do 122 select_i=`expr $select_i + 1` 123 printf >&2 "%${select_width}d) %s\\n" $select_i "$select_word" 124 done ;; 125 *[!0-9]*) 126 echo >&2 'Please enter a number in range.' ;; 127 *) 128 if test 1 -le $select_i && test $select_i -le $#; then 129 shift `expr $select_i - 1` 130 select_result=$1 131 break 132 fi 133 echo >&2 'Please enter a number in range.' 134 esac 135 136 # Prompt and read input. 137 printf >&2 %s "${PS3-#? }" 138 read select_i || exit 139 done 140 } 141fi 142 143while getopts c:n:t:-: opt 144do 145 case $opt$OPTARG in 146 c*) 147 coord=$OPTARG ;; 148 n*) 149 location_limit=$OPTARG ;; 150 t*) # Undocumented option, used for developer testing. 151 zonetabtype=$OPTARG ;; 152 -help) 153 exec echo "$usage" ;; 154 -version) 155 exec echo "tzselect $PKGVERSION$TZVERSION" ;; 156 -*) 157 say >&2 "$0: -$opt$OPTARG: unknown option; try '$0 --help'"; exit 1 ;; 158 *) 159 say >&2 "$0: try '$0 --help'"; exit 1 ;; 160 esac 161done 162 163shift `expr $OPTIND - 1` 164case $# in 1650) ;; 166*) say >&2 "$0: $1: unknown argument"; exit 1 ;; 167esac 168 169# Make sure the tables are readable. 170TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab 171TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab 172for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE 173do 174 <"$f" || { 175 say >&2 "$0: time zone files are not set up correctly" 176 exit 1 177 } 178done 179 180# If the current locale does not support UTF-8, convert data to current 181# locale's format if possible, as the shell aligns columns better that way. 182# Check the UTF-8 of U+12345 CUNEIFORM SIGN URU TIMES KI. 183$AWK 'BEGIN { u12345 = "\360\222\215\205"; exit length(u12345) != 1 }' || { 184 { tmp=`(mktemp -d) 2>/dev/null` || { 185 tmp=${TMPDIR-/tmp}/tzselect.$$ && 186 (umask 77 && mkdir -- "$tmp") 187 };} && 188 trap 'status=$?; rm -fr -- "$tmp"; exit $status' 0 HUP INT PIPE TERM && 189 (iconv -f UTF-8 -t //TRANSLIT <"$TZ_COUNTRY_TABLE" >$tmp/iso3166.tab) \ 190 2>/dev/null && 191 TZ_COUNTRY_TABLE=$tmp/iso3166.tab && 192 iconv -f UTF-8 -t //TRANSLIT <"$TZ_ZONE_TABLE" >$tmp/$zonetabtype.tab && 193 TZ_ZONE_TABLE=$tmp/$zonetabtype.tab 194} 195 196newline=' 197' 198IFS=$newline 199 200# Awk script to output a country list. 201output_country_list=' 202 BEGIN { FS = "\t" } 203 /^#$/ { next } 204 /^#[^@]/ { next } 205 { 206 commentary = $0 ~ /^#@/ 207 if (commentary) { 208 col1ccs = substr($1, 3) 209 conts = $2 210 } else { 211 col1ccs = $1 212 conts = $3 213 } 214 ncc = split(col1ccs, cc, /,/) 215 ncont = split(conts, cont, /,/) 216 for (i = 1; i <= ncc; i++) { 217 elsewhere = commentary 218 for (ci = 1; ci <= ncont; ci++) { 219 if (cont[ci] ~ continent_re) { 220 if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i] 221 elsewhere = 0 222 } 223 } 224 if (elsewhere) { 225 for (i = 1; i <= ncc; i++) { 226 cc_elsewhere[cc[i]] = 1 227 } 228 } 229 } 230 } 231 END { 232 while (getline <TZ_COUNTRY_TABLE) { 233 if ($0 !~ /^#/) cc_name[$1] = $2 234 } 235 for (i = 1; i <= ccs; i++) { 236 country = cc_list[i] 237 if (cc_elsewhere[country]) continue 238 if (cc_name[country]) { 239 country = cc_name[country] 240 } 241 print country 242 } 243 } 244' 245 246# Awk script to read a time zone table and output the same table, 247# with each row preceded by its distance from 'here'. 248# If output_times is set, each row is instead preceded by its local time 249# and any apostrophes are escaped for the shell. 250output_distances_or_times=' 251 BEGIN { 252 FS = "\t" 253 if (!output_times) { 254 while (getline <TZ_COUNTRY_TABLE) 255 if ($0 ~ /^[^#]/) 256 country[$1] = $2 257 country["US"] = "US" # Otherwise the strings get too long. 258 } 259 } 260 function abs(x) { 261 return x < 0 ? -x : x; 262 } 263 function min(x, y) { 264 return x < y ? x : y; 265 } 266 function convert_coord(coord, deg, minute, ilen, sign, sec) { 267 if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) { 268 degminsec = coord 269 intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000) 270 minsec = degminsec - intdeg * 10000 271 intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100) 272 sec = minsec - intmin * 100 273 deg = (intdeg * 3600 + intmin * 60 + sec) / 3600 274 } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) { 275 degmin = coord 276 intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100) 277 minute = degmin - intdeg * 100 278 deg = (intdeg * 60 + minute) / 60 279 } else 280 deg = coord 281 return deg * 0.017453292519943296 282 } 283 function convert_latitude(coord) { 284 match(coord, /..*[-+]/) 285 return convert_coord(substr(coord, 1, RLENGTH - 1)) 286 } 287 function convert_longitude(coord) { 288 match(coord, /..*[-+]/) 289 return convert_coord(substr(coord, RLENGTH)) 290 } 291 # Great-circle distance between points with given latitude and longitude. 292 # Inputs and output are in radians. This uses the great-circle special 293 # case of the Vicenty formula for distances on ellipsoids. 294 function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) { 295 dlong = long2 - long1 296 x = cos(lat2) * sin(dlong) 297 y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong) 298 num = sqrt(x * x + y * y) 299 denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong) 300 return atan2(num, denom) 301 } 302 # Parallel distance between points with given latitude and longitude. 303 # This is the product of the longitude difference and the cosine 304 # of the latitude of the point that is further from the equator. 305 # I.e., it considers longitudes to be further apart if they are 306 # nearer the equator. 307 function pardist(lat1, long1, lat2, long2) { 308 return abs(long1 - long2) * min(cos(lat1), cos(lat2)) 309 } 310 # The distance function is the sum of the great-circle distance and 311 # the parallel distance. It could be weighted. 312 function dist(lat1, long1, lat2, long2) { 313 return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2) 314 } 315 BEGIN { 316 coord_lat = convert_latitude(coord) 317 coord_long = convert_longitude(coord) 318 } 319 /^[^#]/ { 320 inline[inlines++] = $0 321 ncc = split($1, cc, /,/) 322 for (i = 1; i <= ncc; i++) 323 cc_used[cc[i]]++ 324 } 325 END { 326 for (h = 0; h < inlines; h++) { 327 $0 = inline[h] 328 line = $1 "\t" $2 "\t" $3 329 sep = "\t" 330 ncc = split($1, cc, /,/) 331 split("", item_seen) 332 item_seen[""] = 1 333 for (i = 1; i <= ncc; i++) { 334 item = cc_used[cc[i]] <= 1 ? country[cc[i]] : $4 335 if (item_seen[item]++) continue 336 line = line sep item 337 sep = "; " 338 } 339 if (output_times) { 340 fmt = "TZ='\''%s'\'' date +'\''%d %%Y %%m %%d %%H:%%M %%a %%b\t%s'\''\n" 341 gsub(/'\''/, "&\\\\&&", line) 342 printf fmt, $3, h, line 343 } else { 344 here_lat = convert_latitude($2) 345 here_long = convert_longitude($2) 346 printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line 347 } 348 } 349 } 350' 351 352# Begin the main loop. We come back here if the user wants to retry. 353while 354 355 echo >&2 'Please identify a location' \ 356 'so that time zone rules can be set correctly.' 357 358 continent= 359 country= 360 region= 361 362 case $coord in 363 ?*) 364 continent=coord;; 365 '') 366 367 # Ask the user for continent or ocean. 368 369 echo >&2 'Please select a continent, ocean, "coord", "TZ", or "time".' 370 371 quoted_continents=` 372 $AWK ' 373 function handle_entry(entry) { 374 entry = substr(entry, 1, index(entry, "/") - 1) 375 if (entry == "America") 376 entry = entry "s" 377 if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/) 378 entry = entry " Ocean" 379 printf "'\''%s'\''\n", entry 380 } 381 BEGIN { FS = "\t" } 382 /^[^#]/ { 383 handle_entry($3) 384 } 385 /^#@/ { 386 ncont = split($2, cont, /,/) 387 for (ci = 1; ci <= ncont; ci++) { 388 handle_entry(cont[ci]) 389 } 390 } 391 ' <"$TZ_ZONE_TABLE" | 392 sort -u | 393 tr '\n' ' ' 394 echo '' 395 ` 396 397 eval ' 398 doselect '"$quoted_continents"' \ 399 "coord - I want to use geographical coordinates." \ 400 "TZ - I want to specify the timezone using the Posix TZ format." \ 401 "time - I know local time already." 402 continent=$select_result 403 case $continent in 404 Americas) continent=America;; 405 *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''` 406 esac 407 ' 408 esac 409 410 case $continent in 411 TZ) 412 # Ask the user for a Posix TZ string. Check that it conforms. 413 while 414 echo >&2 'Please enter the desired value' \ 415 'of the TZ environment variable.' 416 echo >&2 'For example, AEST-10 is abbreviated' \ 417 'AEST and is 10 hours' 418 echo >&2 'ahead (east) of Greenwich,' \ 419 'with no daylight saving time.' 420 read TZ 421 $AWK -v TZ="$TZ" 'BEGIN { 422 tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})" 423 time = "(2[0-4]|[0-1]?[0-9])" \ 424 "(:[0-5][0-9](:[0-5][0-9])?)?" 425 offset = "[-+]?" time 426 mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]" 427 jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \ 428 "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])" 429 datetime = ",(" mdate "|" jdate ")(/" time ")?" 430 tzpattern = "^(:.*|" tzname offset "(" tzname \ 431 "(" offset ")?(" datetime datetime ")?)?)$" 432 if (TZ ~ tzpattern) exit 1 433 exit 0 434 }' 435 do 436 say >&2 "'$TZ' is not a conforming Posix timezone string." 437 done 438 TZ_for_date=$TZ;; 439 *) 440 case $continent in 441 coord) 442 case $coord in 443 '') 444 echo >&2 'Please enter coordinates' \ 445 'in ISO 6709 notation.' 446 echo >&2 'For example, +4042-07403 stands for' 447 echo >&2 '40 degrees 42 minutes north,' \ 448 '74 degrees 3 minutes west.' 449 read coord;; 450 esac 451 distance_table=`$AWK \ 452 -v coord="$coord" \ 453 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 454 "$output_distances_or_times" <"$TZ_ZONE_TABLE" | 455 sort -n | 456 sed "${location_limit}q" 457 ` 458 regions=`$AWK \ 459 -v distance_table="$distance_table" ' 460 BEGIN { 461 nlines = split(distance_table, line, /\n/) 462 for (nr = 1; nr <= nlines; nr++) { 463 nf = split(line[nr], f, /\t/) 464 print f[nf] 465 } 466 } 467 '` 468 echo >&2 'Please select one of the following timezones,' 469 echo >&2 'listed roughly in increasing order' \ 470 "of distance from $coord". 471 doselect $regions 472 region=$select_result 473 TZ=`$AWK \ 474 -v distance_table="$distance_table" \ 475 -v region="$region" ' 476 BEGIN { 477 nlines = split(distance_table, line, /\n/) 478 for (nr = 1; nr <= nlines; nr++) { 479 nf = split(line[nr], f, /\t/) 480 if (f[nf] == region) { 481 print f[4] 482 } 483 } 484 } 485 '` 486 ;; 487 *) 488 case $continent in 489 time) 490 minute_format='%a %b %d %H:%M' 491 old_minute=`TZ=UTC0 date +"$minute_format"` 492 for i in 1 2 3 493 do 494 time_table_command=` 495 $AWK -v output_times=1 \ 496 "$output_distances_or_times" <"$TZ_ZONE_TABLE" 497 ` 498 time_table=`eval "$time_table_command"` 499 new_minute=`TZ=UTC0 date +"$minute_format"` 500 case $old_minute in 501 "$new_minute") break;; 502 esac 503 old_minute=$new_minute 504 done 505 echo >&2 "The system says Universal Time is $new_minute." 506 echo >&2 "Assuming that's correct, what is the local time?" 507 eval doselect ` 508 say "$time_table" | 509 sort -k2n -k2,5 -k1n | 510 $AWK '{ 511 line = $6 " " $7 " " $4 " " $5 512 if (line == oldline) next 513 oldline = line 514 gsub(/'\''/, "&\\\\&&", line) 515 printf "'\''%s'\''\n", line 516 }' 517 ` 518 time=$select_result 519 zone_table=` 520 say "$time_table" | 521 $AWK -v time="$time" '{ 522 if ($6 " " $7 " " $4 " " $5 == time) { 523 sub(/[^\t]*\t/, "") 524 print 525 } 526 }' 527 ` 528 countries=` 529 say "$zone_table" | 530 $AWK \ 531 -v continent_re='' \ 532 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 533 "$output_country_list" | 534 sort -f 535 ` 536 ;; 537 *) 538 zone_table=file 539 # Get list of names of countries in the continent or ocean. 540 countries=`$AWK \ 541 -v continent_re="^$continent/" \ 542 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 543 "$output_country_list" \ 544 <"$TZ_ZONE_TABLE" | sort -f 545 `;; 546 esac 547 548 # If there's more than one country, ask the user which one. 549 case $countries in 550 *"$newline"*) 551 echo >&2 'Please select a country' \ 552 'whose clocks agree with yours.' 553 doselect $countries 554 country_result=$select_result 555 country=$select_result;; 556 *) 557 country=$countries 558 esac 559 560 561 # Get list of timezones in the country. 562 regions=` 563 case $zone_table in 564 file) cat -- "$TZ_ZONE_TABLE";; 565 *) say "$zone_table";; 566 esac | 567 $AWK \ 568 -v country="$country" \ 569 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 570 ' 571 BEGIN { 572 FS = "\t" 573 cc = country 574 while (getline <TZ_COUNTRY_TABLE) { 575 if ($0 !~ /^#/ && country == $2) { 576 cc = $1 577 break 578 } 579 } 580 } 581 /^#/ { next } 582 $1 ~ cc { print $4 } 583 ' 584 ` 585 586 587 # If there's more than one region, ask the user which one. 588 case $regions in 589 *"$newline"*) 590 echo >&2 'Please select one of the following timezones.' 591 doselect $regions 592 region=$select_result 593 esac 594 595 # Determine TZ from country and region. 596 TZ=` 597 case $zone_table in 598 file) cat -- "$TZ_ZONE_TABLE";; 599 *) say "$zone_table";; 600 esac | 601 $AWK \ 602 -v country="$country" \ 603 -v region="$region" \ 604 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 605 ' 606 BEGIN { 607 FS = "\t" 608 cc = country 609 while (getline <TZ_COUNTRY_TABLE) { 610 if ($0 !~ /^#/ && country == $2) { 611 cc = $1 612 break 613 } 614 } 615 } 616 /^#/ { next } 617 $1 ~ cc && ($4 == region || !region) { print $3 } 618 ' 619 `;; 620 esac 621 622 # Make sure the corresponding zoneinfo file exists. 623 TZ_for_date=$TZDIR/$TZ 624 <"$TZ_for_date" || { 625 say >&2 "$0: time zone files are not set up correctly" 626 exit 1 627 } 628 esac 629 630 631 # Use the proposed TZ to output the current date relative to UTC. 632 # Loop until they agree in seconds. 633 # Give up after 8 unsuccessful tries. 634 635 extra_info= 636 for i in 1 2 3 4 5 6 7 8 637 do 638 TZdate=`LANG=C TZ="$TZ_for_date" date` 639 UTdate=`LANG=C TZ=UTC0 date` 640 TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'` 641 UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'` 642 case $TZsec in 643 $UTsec) 644 extra_info=" 645Selected time is now: $TZdate. 646Universal Time is now: $UTdate." 647 break 648 esac 649 done 650 651 652 # Output TZ info and ask the user to confirm. 653 654 echo >&2 "" 655 echo >&2 "Based on the following information:" 656 echo >&2 "" 657 case $time%$country_result%$region%$coord in 658 ?*%?*%?*%) 659 say >&2 " $time$newline $country_result$newline $region";; 660 ?*%?*%%|?*%%?*%) say >&2 " $time$newline $country_result$region";; 661 ?*%%%) say >&2 " $time";; 662 %?*%?*%) say >&2 " $country_result$newline $region";; 663 %?*%%) say >&2 " $country_result";; 664 %%?*%?*) say >&2 " coord $coord$newline $region";; 665 %%%?*) say >&2 " coord $coord";; 666 *) say >&2 " TZ='$TZ'" 667 esac 668 say >&2 "" 669 say >&2 "TZ='$TZ' will be used.$extra_info" 670 say >&2 "Is the above information OK?" 671 672 doselect Yes No 673 ok=$select_result 674 case $ok in 675 Yes) break 676 esac 677do coord= 678done 679 680case $SHELL in 681*csh) file=.login line="setenv TZ '$TZ'";; 682*) file=.profile line="TZ='$TZ'; export TZ" 683esac 684 685test -t 1 && say >&2 " 686You can make this change permanent for yourself by appending the line 687 $line 688to the file '$file' in your home directory; then log out and log in again. 689 690Here is that TZ value again, this time on standard output so that you 691can use the $0 command in shell scripts:" 692 693say "$TZ" 694