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