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. 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 'select' builtin if available, 86# falling back on a less-nice but 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 201# Awk script to read a time zone table and output the same table, 202# with each column preceded by its distance from 'here'. 203output_distances=' 204 BEGIN { 205 FS = "\t" 206 while (getline <TZ_COUNTRY_TABLE) 207 if ($0 ~ /^[^#]/) 208 country[$1] = $2 209 country["US"] = "US" # Otherwise the strings get too long. 210 } 211 function abs(x) { 212 return x < 0 ? -x : x; 213 } 214 function min(x, y) { 215 return x < y ? x : y; 216 } 217 function convert_coord(coord, deg, minute, ilen, sign, sec) { 218 if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) { 219 degminsec = coord 220 intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000) 221 minsec = degminsec - intdeg * 10000 222 intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100) 223 sec = minsec - intmin * 100 224 deg = (intdeg * 3600 + intmin * 60 + sec) / 3600 225 } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) { 226 degmin = coord 227 intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100) 228 minute = degmin - intdeg * 100 229 deg = (intdeg * 60 + minute) / 60 230 } else 231 deg = coord 232 return deg * 0.017453292519943296 233 } 234 function convert_latitude(coord) { 235 match(coord, /..*[-+]/) 236 return convert_coord(substr(coord, 1, RLENGTH - 1)) 237 } 238 function convert_longitude(coord) { 239 match(coord, /..*[-+]/) 240 return convert_coord(substr(coord, RLENGTH)) 241 } 242 # Great-circle distance between points with given latitude and longitude. 243 # Inputs and output are in radians. This uses the great-circle special 244 # case of the Vicenty formula for distances on ellipsoids. 245 function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) { 246 dlong = long2 - long1 247 x = cos(lat2) * sin(dlong) 248 y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong) 249 num = sqrt(x * x + y * y) 250 denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong) 251 return atan2(num, denom) 252 } 253 # Parallel distance between points with given latitude and longitude. 254 # This is the product of the longitude difference and the cosine 255 # of the latitude of the point that is further from the equator. 256 # I.e., it considers longitudes to be further apart if they are 257 # nearer the equator. 258 function pardist(lat1, long1, lat2, long2) { 259 return abs(long1 - long2) * min(cos(lat1), cos(lat2)) 260 } 261 # The distance function is the sum of the great-circle distance and 262 # the parallel distance. It could be weighted. 263 function dist(lat1, long1, lat2, long2) { 264 return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2) 265 } 266 BEGIN { 267 coord_lat = convert_latitude(coord) 268 coord_long = convert_longitude(coord) 269 } 270 /^[^#]/ { 271 here_lat = convert_latitude($2) 272 here_long = convert_longitude($2) 273 line = $1 "\t" $2 "\t" $3 274 sep = "\t" 275 ncc = split($1, cc, /,/) 276 for (i = 1; i <= ncc; i++) { 277 line = line sep country[cc[i]] 278 sep = ", " 279 } 280 if (NF == 4) 281 line = line " - " $4 282 printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line 283 } 284' 285 286# Begin the main loop. We come back here if the user wants to retry. 287while 288 289 echo >&2 'Please identify a location' \ 290 'so that time zone rules can be set correctly.' 291 292 continent= 293 country= 294 region= 295 296 case $coord in 297 ?*) 298 continent=coord;; 299 '') 300 301 # Ask the user for continent or ocean. 302 303 echo >&2 'Please select a continent, ocean, "coord", or "TZ".' 304 305 quoted_continents=` 306 $AWK ' 307 function handle_entry(entry) { 308 entry = substr(entry, 1, index(entry, "/") - 1) 309 if (entry == "America") 310 entry = entry "s" 311 if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/) 312 entry = entry " Ocean" 313 printf "'\''%s'\''\n", entry 314 } 315 BEGIN { FS = "\t" } 316 /^[^#]/ { 317 handle_entry($3) 318 } 319 /^#@/ { 320 ncont = split($2, cont, /,/) 321 for (ci = 1; ci <= ncont; ci++) { 322 handle_entry(cont[ci]) 323 } 324 } 325 ' <"$TZ_ZONE_TABLE" | 326 sort -u | 327 tr '\n' ' ' 328 echo '' 329 ` 330 331 eval ' 332 doselect '"$quoted_continents"' \ 333 "coord - I want to use geographical coordinates." \ 334 "TZ - I want to specify the timezone using the Posix TZ format." 335 continent=$select_result 336 case $continent in 337 Americas) continent=America;; 338 *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''` 339 esac 340 ' 341 esac 342 343 case $continent in 344 TZ) 345 # Ask the user for a Posix TZ string. Check that it conforms. 346 while 347 echo >&2 'Please enter the desired value' \ 348 'of the TZ environment variable.' 349 echo >&2 'For example, AEST-10 is abbreviated' \ 350 'AEST and is 10 hours' 351 echo >&2 'ahead (east) of Greenwich,' \ 352 'with no daylight saving time.' 353 read TZ 354 $AWK -v TZ="$TZ" 'BEGIN { 355 tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})" 356 time = "(2[0-4]|[0-1]?[0-9])" \ 357 "(:[0-5][0-9](:[0-5][0-9])?)?" 358 offset = "[-+]?" time 359 mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]" 360 jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \ 361 "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])" 362 datetime = ",(" mdate "|" jdate ")(/" time ")?" 363 tzpattern = "^(:.*|" tzname offset "(" tzname \ 364 "(" offset ")?(" datetime datetime ")?)?)$" 365 if (TZ ~ tzpattern) exit 1 366 exit 0 367 }' 368 do 369 say >&2 "'$TZ' is not a conforming Posix timezone string." 370 done 371 TZ_for_date=$TZ;; 372 *) 373 case $continent in 374 coord) 375 case $coord in 376 '') 377 echo >&2 'Please enter coordinates' \ 378 'in ISO 6709 notation.' 379 echo >&2 'For example, +4042-07403 stands for' 380 echo >&2 '40 degrees 42 minutes north,' \ 381 '74 degrees 3 minutes west.' 382 read coord;; 383 esac 384 distance_table=`$AWK \ 385 -v coord="$coord" \ 386 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 387 "$output_distances" <"$TZ_ZONE_TABLE" | 388 sort -n | 389 sed "${location_limit}q" 390 ` 391 regions=`say "$distance_table" | $AWK ' 392 BEGIN { FS = "\t" } 393 { print $NF } 394 '` 395 echo >&2 'Please select one of the following timezones,' \ 396 echo >&2 'listed roughly in increasing order' \ 397 "of distance from $coord". 398 doselect $regions 399 region=$select_result 400 TZ=`say "$distance_table" | $AWK -v region="$region" ' 401 BEGIN { FS="\t" } 402 $NF == region { print $4 } 403 '` 404 ;; 405 *) 406 # Get list of names of countries in the continent or ocean. 407 countries=`$AWK \ 408 -v continent_re="^$continent/" \ 409 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 410 ' 411 BEGIN { FS = "\t" } 412 /^#$/ { next } 413 /^#[^@]/ { next } 414 { 415 commentary = $0 ~ /^#@/ 416 if (commentary) { 417 col1ccs = substr($1, 3) 418 conts = $2 419 } else { 420 col1ccs = $1 421 conts = $3 422 } 423 ncc = split(col1ccs, cc, /,/) 424 ncont = split(conts, cont, /,/) 425 for (i = 1; i <= ncc; i++) { 426 elsewhere = commentary 427 for (ci = 1; ci <= ncont; ci++) { 428 if (cont[ci] ~ continent_re) { 429 if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i] 430 elsewhere = 0 431 } 432 } 433 if (elsewhere) { 434 for (i = 1; i <= ncc; i++) { 435 cc_elsewhere[cc[i]] = 1 436 } 437 } 438 } 439 } 440 END { 441 while (getline <TZ_COUNTRY_TABLE) { 442 if ($0 !~ /^#/) cc_name[$1] = $2 443 } 444 for (i = 1; i <= ccs; i++) { 445 country = cc_list[i] 446 if (cc_elsewhere[country]) continue 447 if (cc_name[country]) { 448 country = cc_name[country] 449 } 450 print country 451 } 452 } 453 ' <"$TZ_ZONE_TABLE" | sort -f` 454 455 456 # If there's more than one country, ask the user which one. 457 case $countries in 458 *"$newline"*) 459 echo >&2 'Please select a country' \ 460 'whose clocks agree with yours.' 461 doselect $countries 462 country=$select_result;; 463 *) 464 country=$countries 465 esac 466 467 468 # Get list of timezones in the country. 469 regions=`$AWK \ 470 -v country="$country" \ 471 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 472 ' 473 BEGIN { 474 FS = "\t" 475 cc = country 476 while (getline <TZ_COUNTRY_TABLE) { 477 if ($0 !~ /^#/ && country == $2) { 478 cc = $1 479 break 480 } 481 } 482 } 483 /^#/ { next } 484 $1 ~ cc { print $4 } 485 ' <"$TZ_ZONE_TABLE"` 486 487 488 # If there's more than one region, ask the user which one. 489 case $regions in 490 *"$newline"*) 491 echo >&2 'Please select one of the following timezones.' 492 doselect $regions 493 region=$select_result;; 494 *) 495 region=$regions 496 esac 497 498 # Determine TZ from country and region. 499 TZ=`$AWK \ 500 -v country="$country" \ 501 -v region="$region" \ 502 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \ 503 ' 504 BEGIN { 505 FS = "\t" 506 cc = country 507 while (getline <TZ_COUNTRY_TABLE) { 508 if ($0 !~ /^#/ && country == $2) { 509 cc = $1 510 break 511 } 512 } 513 } 514 /^#/ { next } 515 $1 ~ cc && $4 == region { print $3 } 516 ' <"$TZ_ZONE_TABLE"` 517 esac 518 519 # Make sure the corresponding zoneinfo file exists. 520 TZ_for_date=$TZDIR/$TZ 521 <"$TZ_for_date" || { 522 say >&2 "$0: time zone files are not set up correctly" 523 exit 1 524 } 525 esac 526 527 528 # Use the proposed TZ to output the current date relative to UTC. 529 # Loop until they agree in seconds. 530 # Give up after 8 unsuccessful tries. 531 532 extra_info= 533 for i in 1 2 3 4 5 6 7 8 534 do 535 TZdate=`LANG=C TZ="$TZ_for_date" date` 536 UTdate=`LANG=C TZ=UTC0 date` 537 TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'` 538 UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'` 539 case $TZsec in 540 $UTsec) 541 extra_info=" 542Selected time is now: $TZdate. 543Universal Time is now: $UTdate." 544 break 545 esac 546 done 547 548 549 # Output TZ info and ask the user to confirm. 550 551 echo >&2 "" 552 echo >&2 "The following information has been given:" 553 echo >&2 "" 554 case $country%$region%$coord in 555 ?*%?*%) say >&2 " $country$newline $region";; 556 ?*%%) say >&2 " $country";; 557 %?*%?*) say >&2 " coord $coord$newline $region";; 558 %%?*) say >&2 " coord $coord";; 559 *) say >&2 " TZ='$TZ'" 560 esac 561 say >&2 "" 562 say >&2 "Therefore TZ='$TZ' will be used.$extra_info" 563 say >&2 "Is the above information OK?" 564 565 doselect Yes No 566 ok=$select_result 567 case $ok in 568 Yes) break 569 esac 570do coord= 571done 572 573case $SHELL in 574*csh) file=.login line="setenv TZ '$TZ'";; 575*) file=.profile line="TZ='$TZ'; export TZ" 576esac 577 578test -t 1 && say >&2 " 579You can make this change permanent for yourself by appending the line 580 $line 581to the file '$file' in your home directory; then log out and log in again. 582 583Here is that TZ value again, this time on standard output so that you 584can use the $0 command in shell scripts:" 585 586say "$TZ" 587