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.
7 REPORT_BUGS_TO
=tz@iana.org
9 # Contributed by Paul Eggert. This file is in the public domain.
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:
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>
23 # For portability to Solaris 10 /bin/sh (supported by Oracle through
24 # January 2027) this script avoids some POSIX features and common
25 # extensions, such as $(...), $((...)), ! CMD, unquoted ^, ${#ID},
26 # ${ID##PAT}, ${ID%%PAT}, and $10. Although some of these constructs
27 # work sometimes, it's simpler to avoid them entirely.
29 # This script also uses several features of POSIX awk.
30 # If your host lacks awk, or has an old awk that does not conform to POSIX,
31 # you can use any of the following free programs instead:
33 # Gawk (GNU awk) <https://www.gnu.org/software/gawk/>
34 # mawk <https://invisible-island.net/mawk/>
35 # nawk <https://github.com/onetrueawk/awk>
37 # Because 'awk "VAR=VALUE" ...' and 'awk -v "VAR=VALUE" ...' are not portable
38 # if VALUE contains \, ", or newline, awk scripts in this file use:
39 # awk 'BEGIN { VAR = substr(ARGV[1], 2); ARGV[1] = "" } ...' ="VALUE"
40 # The substr avoids problems when VALUE is of the form X=Y and would be
41 # misinterpreted as an assignment.
43 # This script does not want path expansion.
46 # Specify default values for environment variables if they are unset.
51 # Output one argument as-is to standard output, with trailing newline.
52 # Safer than 'echo', which can mishandle '\' or leading '-'.
57 # Check for awk POSIX compliance.
58 ($AWK -v x
=y
'BEGIN { exit 123 }') <>/dev
/null
>&0 2>&0
60 say
>&2 "$0: Sorry, your '$AWK' program is not POSIX compatible."
68 usage
="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
69 Select a timezone interactively.
74 Instead of asking for continent and then country and then city,
75 ask for selection from time zones whose largest cities
76 are closest to the location with geographical coordinates COORD.
77 COORD should use ISO 6709 notation, for example, '-c +4852+00220'
78 for Paris (in degrees and minutes, North and East), or
79 '-c -35-058' for Buenos Aires (in degrees, South and West).
82 Display at most LIMIT locations when -c is used (default $location_limit).
85 Output version information.
90 Report bugs to $REPORT_BUGS_TO."
92 # Ask the user to select from the function's arguments,
93 # and assign the selected argument to the variable 'select_result'.
94 # Exit on EOF or I/O error. Use the shell's nicer 'select' builtin if
95 # available, falling back on a portable substitute otherwise.
100 # '; exit' should be redundant, but Dash doesn't properly fail without it.
101 (eval 'set --; select x; do break; done; exit') <>/dev
/null
2>&0
104 # Do this inside 'eval', as otherwise the shell might exit when parsing it
105 # even though it is never executed.
110 case $select_result in
111 "") echo >&2 "Please enter a number in range.";;
119 # Field width of the prompt numbers.
120 print_nargs_length
="BEGIN {print length(\"$#\");}"
121 select_width
=`$AWK "$print_nargs_length"`
132 select_i
=`$AWK "BEGIN { print $select_i + 1 }"`
133 printf >&2 "%${select_width}d) %s\\n" $select_i "$select_word"
136 echo >&2 'Please enter a number in range.';;
138 if test 1 -le $select_i && test $select_i -le $#; then
139 shift `$AWK "BEGIN { print $select_i - 1 }"`
143 echo >&2 'Please enter a number in range.'
146 # Prompt and read input.
147 printf >&2 %s
"${PS3-#? }"
148 read select_i ||
exit
153 while getopts c
:n
:t
:-: opt
159 location_limit
=$OPTARG;;
160 t
*) # Undocumented option, used for developer testing.
161 zonetabtype
=$OPTARG;;
165 exec echo "tzselect $PKGVERSION$TZVERSION";;
167 say
>&2 "$0: -$opt$OPTARG: unknown option; try '$0 --help'"; exit 1;;
169 say
>&2 "$0: try '$0 --help'"; exit 1
173 shift `$AWK "BEGIN { print $OPTIND - 1 }"`
176 *) say
>&2 "$0: $1: unknown argument"; exit 1
179 # translit=true to try transliteration.
180 # This is false if U+12345 CUNEIFORM SIGN URU TIMES KI has length 1
181 # which means awk (and presumably the shell) do not need transliteration.
182 if $AWK 'BEGIN { u12345 = "\360\222\215\205"; exit length(u12345) == 1 }'; then
188 # Read into shell variable $1 the contents of file $2.
189 # Convert to the current locale's encoding if possible,
190 # as the shell aligns columns better that way.
191 # If GNU iconv's //TRANSLIT does not work, fall back on POSIXish iconv;
192 # if that does not work, fall back on 'cat'.
195 eval "$1=\`(iconv -f UTF-8 -t //TRANSLIT) 2>/dev/null <\"\$2\"\`" ||
196 eval "$1=\`(iconv -f UTF-8) 2>/dev/null <\"\$2\"\`"
198 eval "$1=\`cat <\"\$2\"\`" ||
{
199 say
>&2 "$0: time zone files are not set up correctly"
203 read_file TZ_COUNTRY_TABLE
"$TZDIR/iso3166.tab"
204 read_file TZ_ZONETABTYPE_TABLE
"$TZDIR/$zonetabtype.tab"
211 # Awk script to output a country list.
212 output_country_list
='
214 continent_re = substr(ARGV[1], 2)
215 TZ_COUNTRY_TABLE = substr(ARGV[2], 2)
216 TZ_ZONE_TABLE = substr(ARGV[3], 2)
217 ARGV[1] = ARGV[2] = ARGV[3] = ""
219 nlines = split(TZ_ZONE_TABLE, line, /\n/)
220 for (iline = 1; iline <= nlines; iline++) {
222 commentary = $0 ~ /^#@/
226 col1ccs = substr($1, 3)
232 ncc = split(col1ccs, cc, /,/)
233 ncont = split(conts, cont, /,/)
234 for (i = 1; i <= ncc; i++) {
235 elsewhere = commentary
236 for (ci = 1; ci <= ncont; ci++) {
237 if (cont[ci] ~ continent_re) {
238 if (!cc_seen[cc[i]]++)
239 cc_list[++ccs] = cc[i]
244 for (i = 1; i <= ncc; i++)
245 cc_elsewhere[cc[i]] = 1
248 nlines = split(TZ_COUNTRY_TABLE, line, /\n/)
249 for (i = 1; i <= nlines; i++) {
254 for (i = 1; i <= ccs; i++) {
256 if (cc_elsewhere[country])
258 if (cc_name[country])
259 country = cc_name[country]
265 # Awk script to process a time zone table and output the same table,
266 # with each row preceded by its distance from 'here'.
267 # If output_times is set, each row is instead preceded by its local time
268 # and any apostrophes are escaped for the shell.
269 output_distances_or_times
='
271 coord = substr(ARGV[1], 2)
272 TZ_COUNTRY_TABLE = substr(ARGV[2], 2)
273 TZ_ZONE_TABLE = substr(ARGV[3], 2)
274 ARGV[1] = ARGV[2] = ARGV[3] = ""
277 nlines = split(TZ_COUNTRY_TABLE, line, /\n/)
278 for (i = 1; i <= nlines; i++) {
284 country["US"] = "US" # Otherwise the strings get too long.
288 return x < 0 ? -x : x;
291 return x < y ? x : y;
293 function convert_coord(coord, deg, minute, ilen, sign, sec) {
294 if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
296 intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
297 minsec = degminsec - intdeg * 10000
298 intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
299 sec = minsec - intmin * 100
300 deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
301 } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
303 intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
304 minute = degmin - intdeg * 100
305 deg = (intdeg * 60 + minute) / 60
308 return deg * 0.017453292519943296
310 function convert_latitude(coord) {
311 match(coord, /..*[-+]/)
312 return convert_coord(substr(coord, 1, RLENGTH - 1))
314 function convert_longitude(coord) {
315 match(coord, /..*[-+]/)
316 return convert_coord(substr(coord, RLENGTH))
318 # Great-circle distance between points with given latitude and longitude.
319 # Inputs and output are in radians. This uses the great-circle special
320 # case of the Vicenty formula for distances on ellipsoids.
321 function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
322 dlong = long2 - long1
323 x = cos(lat2) * sin(dlong)
324 y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
325 num = sqrt(x * x + y * y)
326 denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
327 return atan2(num, denom)
329 # Parallel distance between points with given latitude and longitude.
330 # This is the product of the longitude difference and the cosine
331 # of the latitude of the point that is further from the equator.
332 # I.e., it considers longitudes to be further apart if they are
333 # nearer the equator.
334 function pardist(lat1, long1, lat2, long2) {
335 return abs(long1 - long2) * min(cos(lat1), cos(lat2))
337 # The distance function is the sum of the great-circle distance and
338 # the parallel distance. It could be weighted.
339 function dist(lat1, long1, lat2, long2) {
340 return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
343 coord_lat = convert_latitude(coord)
344 coord_long = convert_longitude(coord)
345 nlines = split(TZ_ZONE_TABLE, line, /\n/)
346 for (h = 1; h <= nlines; h++) {
350 inline[inlines++] = $0
351 ncc = split($1, cc, /,/)
352 for (i = 1; i <= ncc; i++)
355 for (h = 0; h < inlines; h++) {
357 outline = $1 "\t" $2 "\t" $3
359 ncc = split($1, cc, /,/)
362 for (i = 1; i <= ncc; i++) {
363 item = cc_used[cc[i]] <= 1 ? country[cc[i]] : $4
364 if (item_seen[item]++)
366 outline = outline sep item
370 fmt = "TZ='\''%s'\'' date +'\''%d %%Y %%m %%d %%H:%%M %%a %%b\t%s'\''\n"
371 gsub(/'\''/, "&\\\\&&", outline)
372 printf fmt, $3, h, outline
374 here_lat = convert_latitude($2)
375 here_long = convert_longitude($2)
376 printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), \
383 # Begin the main loop. We come back here if the user wants to retry.
386 echo >&2 'Please identify a location' \
387 'so that time zone rules can be set correctly.'
394 TZ_ZONE_TABLE
=$TZ_ZONETABTYPE_TABLE
401 # Ask the user for continent or ocean.
404 'Please select a continent, ocean, "coord", "TZ", "time", or "now".'
408 function handle_entry(entry) {
409 entry = substr(entry, 1, index(entry, "/") - 1)
410 if (entry == "America")
412 if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/)
413 entry = entry " Ocean"
414 printf "'\''%s'\''\n", entry
417 TZ_ZONETABTYPE_TABLE = substr(ARGV[1], 2)
420 nlines = split(TZ_ZONETABTYPE_TABLE, line, /\n/)
421 for (i = 1; i <= nlines; i++) {
425 else if ($0 ~ /^#@/) {
426 ncont = split($2, cont, /,/)
427 for (ci = 1; ci <= ncont; ci++)
428 handle_entry(cont[ci])
432 ' ="$TZ_ZONETABTYPE_TABLE" |
439 doselect '"$quoted_continents"' \
440 "coord - I want to use geographical coordinates." \
441 "TZ - I want to specify the timezone using a POSIX.1-2017 TZ string." \
442 "time - I know local time already." \
443 "now - Like \"time\", but configure only for timestamps from now on."
444 continent=$select_result
446 Americas) continent=America;;
448 # Get the first word of $continent. Path expansion is disabled
449 # so this works even with "*", which should not happen.
451 for continent in $continent ""; do break; done
454 case $zonetabtype,$continent in
457 ${TZ_ZONENOW_TABLE:+:} read_file TZ_ZONENOW_TABLE "$TZDIR/zonenow.tab"
458 TZ_ZONE_TABLE=$TZ_ZONENOW_TABLE
465 # Ask the user for a POSIX.1-2017 TZ string. Check that it conforms.
466 check_POSIX_TZ_string
='
468 tz = substr(ARGV[1], 2)
470 tzname = ("(<[[:alnum:]+-][[:alnum:]+-][[:alnum:]+-]+>" \
471 "|[[:alpha:]][[:alpha:]][[:alpha:]]+)")
472 time = ("(2[0-4]|[0-1]?[0-9])" \
473 "(:[0-5][0-9](:[0-5][0-9])?)?")
474 offset = "[-+]?" time
475 mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
476 jdate = ("((J[1-9]|[0-9]|J?[1-9][0-9]" \
477 "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])")
478 datetime = ",(" mdate "|" jdate ")(/" time ")?"
479 tzpattern = ("^(:.*|" tzname offset "(" tzname \
480 "(" offset ")?(" datetime datetime ")?)?)$")
486 echo >&2 'Please enter the desired value' \
487 'of the TZ environment variable.'
488 echo >&2 'For example, AEST-10 is abbreviated' \
489 'AEST and is 10 hours'
490 echo >&2 'ahead (east) of Greenwich,' \
491 'with no daylight saving time.'
493 $AWK "$check_POSIX_TZ_string" ="$tz"
495 say
>&2 "'$tz' is not a conforming POSIX.1-2017 timezone string."
503 echo >&2 'Please enter coordinates' \
504 'in ISO 6709 notation.'
505 echo >&2 'For example, +4042-07403 stands for'
506 echo >&2 '40 degrees 42 minutes north,' \
507 '74 degrees 3 minutes west.'
512 "$output_distances_or_times" \
513 ="$coord" ="$TZ_COUNTRY_TABLE" ="$TZ_ZONE_TABLE" |
515 $AWK "{print} NR == $location_limit { exit }"
520 distance_table = substr(ARGV[1], 2)
522 nlines = split(distance_table, line, /\n/)
523 for (nr = 1; nr <= nlines; nr++) {
524 nf = split(line[nr], f, /\t/)
530 echo >&2 'Please select one of the following timezones,'
531 echo >&2 'listed roughly in increasing order' \
532 "of distance from $coord".
534 region
=$select_result
538 distance_table = substr(ARGV[1], 2)
539 region = substr(ARGV[2], 2)
540 ARGV[1] = ARGV[2] = ""
541 nlines = split(distance_table, line, /\n/)
542 for (nr = 1; nr <= nlines; nr++) {
543 nf = split(line[nr], f, /\t/)
548 ' ="$distance_table" ="$region"
553 minute_format
='%a %b %d %H:%M'
554 old_minute
=`TZ=UTC0 date +"$minute_format"`
560 "$output_distances_or_times" \
561 = = ="$TZ_ZONE_TABLE"
563 time_table
=`eval "$time_table_command"`
564 new_minute
=`TZ=UTC0 date +"$minute_format"`
568 old_minute
=$new_minute
570 echo >&2 "The system says Universal Time is $new_minute."
571 echo >&2 "Assuming that's correct, what is the local time?"
572 sorted_table
=`say "$time_table" | sort -k2n -k2,5 -k1n` ||
{
573 say
>&2 "$0: cannot sort time table"
579 sorted_table = substr(ARGV[1], 2)
581 nlines = split(sorted_table, line, /\n/)
582 for (i = 1; i <= nlines; i++) {
584 outline = $6 " " $7 " " $4 " " $5
585 if (outline == oldline)
588 gsub(/'\''/, "&\\\\&&", outline)
589 printf "'\''%s'\''\n", outline
599 time = substr(ARGV[1], 2)
600 time_table = substr(ARGV[2], 2)
601 ARGV[1] = ARGV[2] = ""
602 nlines = split(time_table, line, /\n/)
603 for (i = 1; i <= nlines; i++) {
605 if ($6 " " $7 " " $4 " " $5 == time) {
611 ' ="$time" ="$time_table"
615 "$output_country_list" \
616 ="$continent_re" ="$TZ_COUNTRY_TABLE" ="$zone_table" |
621 continent_re
="^$continent/"
622 zone_table
=$TZ_ZONE_TABLE
625 # Get list of names of countries in the continent or ocean.
628 "$output_country_list" \
629 ="$continent_re" ="$TZ_COUNTRY_TABLE" ="$zone_table" |
632 # If all zone table entries have comments, and there are
633 # at most 22 entries, asked based on those comments.
634 # This fits the prompt onto old-fashioned 24-line screens.
638 TZ_ZONE_TABLE = substr(ARGV[1], 2)
641 nlines = split(TZ_ZONE_TABLE, line, /\n/)
642 for (i = 1; i <= nlines; i++) {
644 if ($0 ~ /^[^#]/ && !missing_comment) {
646 comment[++inlines] = $4
651 if (!missing_comment && inlines <= 22)
652 for (i = 1; i <= inlines; i++)
658 # If there's more than one country, ask the user which one.
661 echo >&2 'Please select a country' \
662 'whose clocks agree with yours.'
664 country_result
=$select_result
665 country
=$select_result;;
671 # Get list of timezones in the country.
675 country = substr(ARGV[1], 2)
676 TZ_COUNTRY_TABLE = substr(ARGV[2], 2)
677 TZ_ZONE_TABLE = substr(ARGV[3], 2)
678 ARGV[1] = ARGV[2] = ARGV[3] = ""
681 nlines = split(TZ_COUNTRY_TABLE, line, /\n/)
682 for (i = 1; i <= nlines; i++) {
684 if ($0 !~ /^#/ && country == $2) {
689 nlines = split(TZ_ZONE_TABLE, line, /\n/)
690 for (i = 1; i <= nlines; i++) {
698 ' ="$country" ="$TZ_COUNTRY_TABLE" ="$zone_table"
701 # If there's more than one region, ask the user which one.
704 echo >&2 'Please select one of the following timezones.'
706 region
=$select_result
709 # Determine tz from country and region.
713 country = substr(ARGV[1], 2)
714 region = substr(ARGV[2], 2)
715 TZ_COUNTRY_TABLE = substr(ARGV[3], 2)
716 TZ_ZONE_TABLE = substr(ARGV[4], 2)
717 ARGV[1] = ARGV[2] = ARGV[3] = ARGV[4] = ""
720 nlines = split(TZ_COUNTRY_TABLE, line, /\n/)
721 for (i = 1; i <= nlines; i++) {
723 if ($0 !~ /^#/ && country == $2) {
728 nlines = split(TZ_ZONE_TABLE, line, /\n/)
729 for (i = 1; i <= nlines; i++) {
733 if ($1 ~ cc && ($4 == region || !region))
737 ' ="$country" ="$region" ="$TZ_COUNTRY_TABLE" ="$zone_table"
741 # Make sure the corresponding zoneinfo file exists.
742 TZ_for_date
=$TZDIR/$tz
744 say
>&2 "$0: time zone files are not set up correctly"
750 # Use the proposed TZ to output the current date relative to UTC.
751 # Loop until they agree in seconds.
752 # Give up after 8 unsuccessful tries.
755 for i
in 1 2 3 4 5 6 7 8
757 TZdate
=`LANG=C TZ="$TZ_for_date" date`
758 UTdate
=`LANG=C TZ=UTC0 date`
760 function getsecs(d) {
761 return match(d, /.*:[0-5][0-9]/) ? substr(d, RLENGTH - 1, 2) : ""
763 BEGIN { exit getsecs(ARGV[1]) != getsecs(ARGV[2]) }
764 ' ="$TZdate" ="$UTdate"
767 Selected time is now: $TZdate.
768 Universal Time is now: $UTdate."
774 # Output TZ info and ask the user to confirm.
777 echo >&2 "Based on the following information:"
779 case $time%$country_result%$region%$coord in
781 say
>&2 " $time$newline $country_result$newline $region";;
782 ?
*%?
*%%|?
*%%?
*%) say
>&2 " $time$newline $country_result$region";;
783 ?
*%%%) say
>&2 " $time";;
784 %?
*%?
*%) say
>&2 " $country_result$newline $region";;
785 %?
*%%) say
>&2 " $country_result";;
786 %%?
*%?
*) say
>&2 " coord $coord$newline $region";;
787 %%%?
*) say
>&2 " coord $coord";;
788 *) say
>&2 " TZ='$tz'"
791 say
>&2 "TZ='$tz' will be used.$extra_info"
792 say
>&2 "Is the above information OK?"
803 *csh
) file=.login line
="setenv TZ '$tz'";;
804 *) file=.profile line
="TZ='$tz'; export TZ"
807 test -t 1 && say
>&2 "
808 You can make this change permanent for yourself by appending the line
810 to the file '$file' in your home directory; then log out and log in again.
812 Here is that TZ value again, this time on standard output so that you
813 can use the $0 command in shell scripts:"