tzselect.ksh revision 1.23 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 #
8 PKGVERSION='(tzcode) '
9 TZVERSION=see_Makefile
10 REPORT_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.
42 set -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 '-'.
51 say() {
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
62 coord=
63 location_limit=10
64 zonetabtype=zone1970
65
66 usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
67 Select a timezone interactively.
68
69 Options:
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
88 Report 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.
94 if
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
101 then
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 '
115 else
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 }
149 fi
150
151 while getopts c:n:t:-: opt
152 do
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
169 done
170
171 shift `$AWK "BEGIN { print $OPTIND - 1 }"`
172 case $# in
173 0) ;;
174 *) say >&2 "$0: $1: unknown argument"; exit 1 ;;
175 esac
176
177 # Make sure the tables are readable.
178 TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
179 TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
180 for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
181 do
182 <"$f" || {
183 say >&2 "$0: time zone files are not set up correctly"
184 exit 1
185 }
186 done
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
204 newline='
205 '
206 IFS=$newline
207
208 # Awk script to output a country list.
209 output_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.
258 output_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.
361 while
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="
661 Selected time is now: $TZdate.
662 Universal 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
693 do coord=
694 done
695
696 case $SHELL in
697 *csh) file=.login line="setenv TZ '$TZ'";;
698 *) file=.profile line="TZ='$TZ'; export TZ"
699 esac
700
701 test -t 1 && say >&2 "
702 You can make this change permanent for yourself by appending the line
703 $line
704 to the file '$file' in your home directory; then log out and log in again.
705
706 Here is that TZ value again, this time on standard output so that you
707 can use the $0 command in shell scripts:"
708
709 say "$TZ"
710