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