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.23 2024/02/17 14:54:47 christos Exp $ 7# 8PKGVERSION='(tzcode) ' 9TZVERSION=see_Makefile 10REPORT_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# nawk <https://github.com/onetrueawk/awk> 39 40 41# This script does not want path expansion. 42set -f 43 44# Specify default values for environment variables if they are unset. 45: ${AWK=awk} 46: ${PWD=`pwd`} 47: ${TZDIR=$PWD} 48 49# Output one argument as-is to standard output, with trailing newline. 50# Safer than 'echo', which can mishandle '\' or leading '-'. 51say() { 52 printf '%s\n' "$1" 53} 54 55# Check for awk Posix compliance. 56($AWK -v x=y 'BEGIN { exit 123 }') </dev/null >/dev/null 2>&1 57[ $? = 123 ] || { 58 say >&2 "$0: Sorry, your '$AWK' program is not Posix compatible." 59 exit 1 60} 61 62coord= 63location_limit=10 64zonetabtype=zone1970 65 66usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT] 67Select a timezone interactively. 68 69Options: 70 71 -c COORD 72 Instead of asking for continent and then country and then city, 73 ask for selection from time zones whose largest cities 74 are closest to the location with geographical coordinates COORD. 75 COORD should use ISO 6709 notation, for example, '-c +4852+00220' 76 for Paris (in degrees and minutes, North and East), or 77 '-c -35-058' for Buenos Aires (in degrees, South and West). 78 79 -n LIMIT 80 Display at most LIMIT locations when -c is used (default $location_limit). 81 82 --version 83 Output version information. 84 85 --help 86 Output this help. 87 88Report bugs to $REPORT_BUGS_TO." 89 90# Ask the user to select from the function's arguments, 91# and assign the selected argument to the variable 'select_result'. 92# Exit on EOF or I/O error. Use the shell's nicer 'select' builtin if 93# available, falling back on a portable substitute otherwise. 94if 95 case $BASH_VERSION in 96 ?*) : ;; 97 '') 98 # '; exit' should be redundant, but Dash doesn't properly fail without it. 99 (eval 'set --; select x; do break; done; exit') </dev/null 2>/dev/null 100 esac 101then 102 # Do this inside 'eval', as otherwise the shell might exit when parsing it 103 # even though it is never executed. 104 eval ' 105 doselect() { 106 select select_result 107 do 108 case $select_result in 109 "") echo >&2 "Please enter a number in range." ;; 110 ?*) break 111 esac 112 done || exit 113 } 114 ' 115else 116 doselect() { 117 # Field width of the prompt numbers. 118 print_nargs_length="BEGIN {print length(\"$#\");}" 119 select_width=`$AWK "$print_nargs_length"` 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=`$AWK "BEGIN { print $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 `$AWK "BEGIN { print $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 } 149fi 150 151while getopts c:n:t:-: opt 152do 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 169done 170 171shift `$AWK "BEGIN { print $OPTIND - 1 }"` 172case $# in 1730) ;; 174*) say >&2 "$0: $1: unknown argument"; exit 1 ;; 175esac 176 177# Make sure the tables are readable. 178TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab 179TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab 180for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE 181do 182 <"$f" || { 183 say >&2 "$0: time zone files are not set up correctly" 184 exit 1 185 } 186done 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 204newline=' 205' 206IFS=$newline 207 208# Awk script to output a country list. 209output_country_list=' 210 BEGIN { FS = "\t" } 211 /^#$/ { next } 212 /^#[^@]/ { next } 213 { 214 commentary = $0 ~ /^#@/ 215 if (commentary) { 216 col1ccs = substr($1, 3) 217 conts = $2 218 } else { 219 col1ccs = $1 220 conts = $3 221 } 222 ncc = split(col1ccs, cc, /,/) 223 ncont = split(conts, cont, /,/) 224 for (i = 1; i <= ncc; i++) { 225 elsewhere = commentary 226 for (ci = 1; ci <= ncont; ci++) { 227 if (cont[ci] ~ continent_re) { 228 if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i] 229 elsewhere = 0 230 } 231 } 232 if (elsewhere) { 233 for (i = 1; i <= ncc; i++) { 234 cc_elsewhere[cc[i]] = 1 235 } 236 } 237 } 238 } 239 END { 240 while (getline <TZ_COUNTRY_TABLE) { 241 if ($0 !~ /^#/) cc_name[$1] = $2 242 } 243 for (i = 1; i <= ccs; i++) { 244 country = cc_list[i] 245 if (cc_elsewhere[country]) continue 246 if (cc_name[country]) { 247 country = cc_name[country] 248 } 249 print country 250 } 251 } 252' 253 254# Awk script to read a time zone table and output the same table, 255# with each row preceded by its distance from 'here'. 256# If output_times is set, each row is instead preceded by its local time 257# and any apostrophes are escaped for the shell. 258output_distances_or_times=' 259 BEGIN { 260 FS = "\t" 261 if (!output_times) { 262 while (getline <TZ_COUNTRY_TABLE) 263 if ($0 ~ /^[^#]/) 264 country[$1] = $2 265 country["US"] = "US" # Otherwise the strings get too long. 266 } 267 } 268 function abs(x) { 269 return x < 0 ? -x : x; 270 } 271 function min(x, y) { 272 return x < y ? x : y; 273 } 274 function convert_coord(coord, deg, minute, ilen, sign, sec) { 275 if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) { 276 degminsec = coord 277 intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000) 278 minsec = degminsec - intdeg * 10000 279 intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100) 280 sec = minsec - intmin * 100 281 deg = (intdeg * 3600 + intmin * 60 + sec) / 3600 282 } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) { 283 degmin = coord 284 intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100) 285 minute = degmin - intdeg * 100 286 deg = (intdeg * 60 + minute) / 60 287 } else 288 deg = coord 289 return deg * 0.017453292519943296 290 } 291 function convert_latitude(coord) { 292 match(coord, /..*[-+]/) 293 return convert_coord(substr(coord, 1, RLENGTH - 1)) 294 } 295 function convert_longitude(coord) { 296 match(coord, /..*[-+]/) 297 return convert_coord(substr(coord, RLENGTH)) 298 } 299 # Great-circle distance between points with given latitude and longitude. 300 # Inputs and output are in radians. This uses the great-circle special 301 # case of the Vicenty formula for distances on ellipsoids. 302 function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) { 303 dlong = long2 - long1 304 x = cos(lat2) * sin(dlong) 305 y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong) 306 num = sqrt(x * x + y * y) 307 denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong) 308 return atan2(num, denom) 309 } 310 # Parallel distance between points with given latitude and longitude. 311 # This is the product of the longitude difference and the cosine 312 # of the latitude of the point that is further from the equator. 313 # I.e., it considers longitudes to be further apart if they are 314 # nearer the equator. 315 function pardist(lat1, long1, lat2, long2) { 316 return abs(long1 - long2) * min(cos(lat1), cos(lat2)) 317 } 318 # The distance function is the sum of the great-circle distance and 319 # the parallel distance. It could be weighted. 320 function dist(lat1, long1, lat2, long2) { 321 return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2) 322 } 323 BEGIN { 324 coord_lat = convert_latitude(coord) 325 coord_long = convert_longitude(coord) 326 } 327 /^[^#]/ { 328 inline[inlines++] = $0 329 ncc = split($1, cc, /,/) 330 for (i = 1; i <= ncc; i++) 331 cc_used[cc[i]]++ 332 } 333 END { 334 for (h = 0; h < inlines; h++) { 335 $0 = inline[h] 336 line = $1 "\t" $2 "\t" $3 337 sep = "\t" 338 ncc = split($1, cc, /,/) 339 split("", item_seen) 340 item_seen[""] = 1 341 for (i = 1; i <= ncc; i++) { 342 item = cc_used[cc[i]] <= 1 ? country[cc[i]] : $4 343 if (item_seen[item]++) continue 344 line = line sep item 345 sep = "; " 346 } 347 if (output_times) { 348 fmt = "TZ='\''%s'\'' date +'\''%d %%Y %%m %%d %%H:%%M %%a %%b\t%s'\''\n" 349 gsub(/'\''/, "&\\\\&&", line) 350 printf fmt, $3, h, line 351 } else { 352 here_lat = convert_latitude($2) 353 here_long = convert_longitude($2) 354 printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line 355 } 356 } 357 } 358' 359 360# Begin the main loop. We come back here if the user wants to retry. 361while 362 363 echo >&2 'Please identify a location' \ 364 'so that time zone rules can be set correctly.' 365 366 continent= 367 country= 368 region= 369 370 case $coord in 371 ?*) 372 continent=coord;; 373 '') 374 375 # Ask the user for continent or ocean. 376 377 echo >&2 'Please select a continent, ocean, "coord", "TZ", or "time".' 378 379 quoted_continents=` 380 $AWK ' 381 function handle_entry(entry) { 382 entry = substr(entry, 1, index(entry, "/") - 1) 383 if (entry == "America") 384 entry = entry "s" 385 if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/) 386 entry = entry " Ocean" 387 printf "'\''%s'\''\n", entry 388 } 389 BEGIN { FS = "\t" } 390 /^[^#]/ { 391 handle_entry($3) 392 } 393 /^#@/ { 394 ncont = split($2, cont, /,/) 395 for (ci = 1; ci <= ncont; ci++) { 396 handle_entry(cont[ci]) 397 } 398 } 399 ' <"$TZ_ZONE_TABLE" | 400 sort -u | 401 tr '\n' ' ' 402 echo '' 403 ` 404 405 eval ' 406 doselect '"$quoted_continents"' \ 407 "coord - I want to use geographical coordinates." \ 408 "TZ - I want to specify the timezone using a POSIX.1-2017 TZ string." \ 409 "time - I know local time already." 410 continent=$select_result 411 case $continent in 412 Americas) continent=America;; 413 *) 414 # Get the first word of $continent. Path expansion is disabled 415 # so this works even with "*", which should not happen. 416 IFS=" " 417 for continent in $continent ""; do break; done 418 IFS=$newline;; 419 esac 420 ' 421 esac 422 423 case $continent in 424 TZ) 425 # Ask the user for a POSIX.1-2017 TZ string. Check that it conforms. 426 while 427 echo >&2 'Please enter the desired value' \ 428 'of the TZ environment variable.' 429 echo >&2 'For example, AEST-10 is abbreviated' \ 430 'AEST and is 10 hours' 431 echo >&2 'ahead (east) of Greenwich,' \ 432 'with no daylight saving time.' 433 read TZ 434 $AWK -v TZ="$TZ" 'BEGIN { 435 tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})" 436 time = "(2[0-4]|[0-1]?[0-9])" \ 437 "(:[0-5][0-9](:[0-5][0-9])?)?" 438 offset = "[-+]?" time 439 mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]" 440 jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \ 441 "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])" 442 datetime = ",(" mdate "|" jdate ")(/" time ")?" 443 tzpattern = "^(:.*|" tzname offset "(" tzname \ 444 "(" offset ")?(" datetime datetime ")?)?)$" 445 if (TZ ~ tzpattern) exit 1 446 exit 0 447 }' 448 do 449 say >&2 "'$tz' is not a conforming POSIX.1-2017 timezone string." 450 done 451 TZ_for_date=$TZ;; 452 *) 453 case $continent in 454 coord) 455 case $coord in 456 '') 457 echo >&2 'Please enter coordinates' \ 458 'in ISO 6709 notation.' 459 echo >&2 'For example, +4042-07403 stands for' 460 echo >&2 '40 degrees 42 minutes north,' \ 461 '74 degrees 3 minutes west.' 462 read coord;; 463 esac 464 distance_table=`$AWK \ 465 -v coord="$coord" \ 466 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 467 "$output_distances_or_times" <"$TZ_ZONE_TABLE" | 468 sort -n | 469 $AWK "{print} NR == $location_limit { exit }" 470 ` 471 regions=`$AWK \ 472 -v distance_table="$distance_table" ' 473 BEGIN { 474 nlines = split(distance_table, line, /\n/) 475 for (nr = 1; nr <= nlines; nr++) { 476 nf = split(line[nr], f, /\t/) 477 print f[nf] 478 } 479 } 480 '` 481 echo >&2 'Please select one of the following timezones,' 482 echo >&2 'listed roughly in increasing order' \ 483 "of distance from $coord". 484 doselect $regions 485 region=$select_result 486 TZ=`$AWK \ 487 -v distance_table="$distance_table" \ 488 -v region="$region" ' 489 BEGIN { 490 nlines = split(distance_table, line, /\n/) 491 for (nr = 1; nr <= nlines; nr++) { 492 nf = split(line[nr], f, /\t/) 493 if (f[nf] == region) { 494 print f[4] 495 } 496 } 497 } 498 '` 499 ;; 500 *) 501 case $continent in 502 time) 503 minute_format='%a %b %d %H:%M' 504 old_minute=`TZ=UTC0 date +"$minute_format"` 505 for i in 1 2 3 506 do 507 time_table_command=` 508 $AWK -v output_times=1 \ 509 "$output_distances_or_times" <"$TZ_ZONE_TABLE" 510 ` 511 time_table=`eval "$time_table_command"` 512 new_minute=`TZ=UTC0 date +"$minute_format"` 513 case $old_minute in 514 "$new_minute") break;; 515 esac 516 old_minute=$new_minute 517 done 518 echo >&2 "The system says Universal Time is $new_minute." 519 echo >&2 "Assuming that's correct, what is the local time?" 520 eval doselect ` 521 say "$time_table" | 522 sort -k2n -k2,5 -k1n | 523 $AWK '{ 524 line = $6 " " $7 " " $4 " " $5 525 if (line == oldline) next 526 oldline = line 527 gsub(/'\''/, "&\\\\&&", line) 528 printf "'\''%s'\''\n", line 529 }' 530 ` 531 time=$select_result 532 zone_table=` 533 say "$time_table" | 534 $AWK -v time="$time" '{ 535 if ($6 " " $7 " " $4 " " $5 == time) { 536 sub(/[^\t]*\t/, "") 537 print 538 } 539 }' 540 ` 541 countries=` 542 say "$zone_table" | 543 $AWK \ 544 -v continent_re='' \ 545 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 546 "$output_country_list" | 547 sort -f 548 ` 549 ;; 550 *) 551 zone_table=file 552 # Get list of names of countries in the continent or ocean. 553 countries=`$AWK \ 554 -v continent_re="^$continent/" \ 555 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 556 "$output_country_list" \ 557 <"$TZ_ZONE_TABLE" | sort -f 558 `;; 559 esac 560 561 # If there's more than one country, ask the user which one. 562 case $countries in 563 *"$newline"*) 564 echo >&2 'Please select a country' \ 565 'whose clocks agree with yours.' 566 doselect $countries 567 country_result=$select_result 568 country=$select_result;; 569 *) 570 country=$countries 571 esac 572 573 574 # Get list of timezones in the country. 575 regions=` 576 case $zone_table in 577 file) cat -- "$TZ_ZONE_TABLE";; 578 *) say "$zone_table";; 579 esac | 580 $AWK \ 581 -v country="$country" \ 582 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 583 ' 584 BEGIN { 585 FS = "\t" 586 cc = country 587 while (getline <TZ_COUNTRY_TABLE) { 588 if ($0 !~ /^#/ && country == $2) { 589 cc = $1 590 break 591 } 592 } 593 } 594 /^#/ { next } 595 $1 ~ cc { print $4 } 596 ' 597 ` 598 599 600 # If there's more than one region, ask the user which one. 601 case $regions in 602 *"$newline"*) 603 echo >&2 'Please select one of the following timezones.' 604 doselect $regions 605 region=$select_result 606 esac 607 608 # Determine TZ from country and region. 609 TZ=` 610 case $zone_table in 611 file) cat -- "$TZ_ZONE_TABLE";; 612 *) say "$zone_table";; 613 esac | 614 $AWK \ 615 -v country="$country" \ 616 -v region="$region" \ 617 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 618 ' 619 BEGIN { 620 FS = "\t" 621 cc = country 622 while (getline <TZ_COUNTRY_TABLE) { 623 if ($0 !~ /^#/ && country == $2) { 624 cc = $1 625 break 626 } 627 } 628 } 629 /^#/ { next } 630 $1 ~ cc && ($4 == region || !region) { print $3 } 631 ' 632 `;; 633 esac 634 635 # Make sure the corresponding zoneinfo file exists. 636 TZ_for_date=$TZDIR/$TZ 637 <"$TZ_for_date" || { 638 say >&2 "$0: time zone files are not set up correctly" 639 exit 1 640 } 641 esac 642 643 644 # Use the proposed TZ to output the current date relative to UTC. 645 # Loop until they agree in seconds. 646 # Give up after 8 unsuccessful tries. 647 648 extra_info= 649 for i in 1 2 3 4 5 6 7 8 650 do 651 TZdate=`LANG=C TZ="$TZ_for_date" date` 652 UTdate=`LANG=C TZ=UTC0 date` 653 if $AWK ' 654 function getsecs(d) { 655 return match(d, /.*:[0-5][0-9]/) ? substr(d, RLENGTH - 1, 2) : "" 656 } 657 BEGIN { exit getsecs(ARGV[1]) != getsecs(ARGV[2]) } 658 ' ="$TZdate" ="$UTdate" 659 then 660 extra_info=" 661Selected time is now: $TZdate. 662Universal Time is now: $UTdate." 663 break 664 fi 665 done 666 667 668 # Output TZ info and ask the user to confirm. 669 670 echo >&2 "" 671 echo >&2 "Based on the following information:" 672 echo >&2 "" 673 case $time%$country_result%$region%$coord in 674 ?*%?*%?*%) 675 say >&2 " $time$newline $country_result$newline $region";; 676 ?*%?*%%|?*%%?*%) say >&2 " $time$newline $country_result$region";; 677 ?*%%%) say >&2 " $time";; 678 %?*%?*%) say >&2 " $country_result$newline $region";; 679 %?*%%) say >&2 " $country_result";; 680 %%?*%?*) say >&2 " coord $coord$newline $region";; 681 %%%?*) say >&2 " coord $coord";; 682 *) say >&2 " TZ='$TZ'" 683 esac 684 say >&2 "" 685 say >&2 "TZ='$TZ' will be used.$extra_info" 686 say >&2 "Is the above information OK?" 687 688 doselect Yes No 689 ok=$select_result 690 case $ok in 691 Yes) break 692 esac 693do coord= 694done 695 696case $SHELL in 697*csh) file=.login line="setenv TZ '$TZ'";; 698*) file=.profile line="TZ='$TZ'; export TZ" 699esac 700 701test -t 1 && say >&2 " 702You can make this change permanent for yourself by appending the line 703 $line 704to the file '$file' in your home directory; then log out and log in again. 705 706Here is that TZ value again, this time on standard output so that you 707can use the $0 command in shell scripts:" 708 709say "$TZ" 710