tzselect.ksh revision 1.22 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.22 2023/09/16 18:40:26 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 # 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, with trailing newline.
46 # Safer than 'echo', which can mishandle '\' or leading '-'.
47 say() {
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
58 coord=
59 location_limit=10
60 zonetabtype=zone1970
61
62 usage="Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
63 Select a timezone interactively.
64
65 Options:
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
84 Report 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 nicer 'select' builtin if
89 # available, falling back on a portable substitute otherwise.
90 if
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
97 then
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 '
111 else
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 }
144 fi
145
146 while getopts c:n:t:-: opt
147 do
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
164 done
165
166 shift `expr $OPTIND - 1`
167 case $# in
168 0) ;;
169 *) say >&2 "$0: $1: unknown argument"; exit 1 ;;
170 esac
171
172 # Make sure the tables are readable.
173 TZ_COUNTRY_TABLE=$TZDIR/iso3166.tab
174 TZ_ZONE_TABLE=$TZDIR/$zonetabtype.tab
175 for f in $TZ_COUNTRY_TABLE $TZ_ZONE_TABLE
176 do
177 <"$f" || {
178 say >&2 "$0: time zone files are not set up correctly"
179 exit 1
180 }
181 done
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
199 newline='
200 '
201 IFS=$newline
202
203 # Awk script to output a country list.
204 output_country_list='
205 BEGIN { FS = "\t" }
206 /^#$/ { next }
207 /^#[^@]/ { next }
208 {
209 commentary = $0 ~ /^#@/
210 if (commentary) {
211 col1ccs = substr($1, 3)
212 conts = $2
213 } else {
214 col1ccs = $1
215 conts = $3
216 }
217 ncc = split(col1ccs, cc, /,/)
218 ncont = split(conts, cont, /,/)
219 for (i = 1; i <= ncc; i++) {
220 elsewhere = commentary
221 for (ci = 1; ci <= ncont; ci++) {
222 if (cont[ci] ~ continent_re) {
223 if (!cc_seen[cc[i]]++) cc_list[++ccs] = cc[i]
224 elsewhere = 0
225 }
226 }
227 if (elsewhere) {
228 for (i = 1; i <= ncc; i++) {
229 cc_elsewhere[cc[i]] = 1
230 }
231 }
232 }
233 }
234 END {
235 while (getline <TZ_COUNTRY_TABLE) {
236 if ($0 !~ /^#/) cc_name[$1] = $2
237 }
238 for (i = 1; i <= ccs; i++) {
239 country = cc_list[i]
240 if (cc_elsewhere[country]) continue
241 if (cc_name[country]) {
242 country = cc_name[country]
243 }
244 print country
245 }
246 }
247 '
248
249 # Awk script to read a time zone table and output the same table,
250 # with each row preceded by its distance from 'here'.
251 # If output_times is set, each row is instead preceded by its local time
252 # and any apostrophes are escaped for the shell.
253 output_distances_or_times='
254 BEGIN {
255 FS = "\t"
256 if (!output_times) {
257 while (getline <TZ_COUNTRY_TABLE)
258 if ($0 ~ /^[^#]/)
259 country[$1] = $2
260 country["US"] = "US" # Otherwise the strings get too long.
261 }
262 }
263 function abs(x) {
264 return x < 0 ? -x : x;
265 }
266 function min(x, y) {
267 return x < y ? x : y;
268 }
269 function convert_coord(coord, deg, minute, ilen, sign, sec) {
270 if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9][0-9][0-9]([^0-9]|$)/) {
271 degminsec = coord
272 intdeg = degminsec < 0 ? -int(-degminsec / 10000) : int(degminsec / 10000)
273 minsec = degminsec - intdeg * 10000
274 intmin = minsec < 0 ? -int(-minsec / 100) : int(minsec / 100)
275 sec = minsec - intmin * 100
276 deg = (intdeg * 3600 + intmin * 60 + sec) / 3600
277 } else if (coord ~ /^[-+]?[0-9]?[0-9][0-9][0-9][0-9]([^0-9]|$)/) {
278 degmin = coord
279 intdeg = degmin < 0 ? -int(-degmin / 100) : int(degmin / 100)
280 minute = degmin - intdeg * 100
281 deg = (intdeg * 60 + minute) / 60
282 } else
283 deg = coord
284 return deg * 0.017453292519943296
285 }
286 function convert_latitude(coord) {
287 match(coord, /..*[-+]/)
288 return convert_coord(substr(coord, 1, RLENGTH - 1))
289 }
290 function convert_longitude(coord) {
291 match(coord, /..*[-+]/)
292 return convert_coord(substr(coord, RLENGTH))
293 }
294 # Great-circle distance between points with given latitude and longitude.
295 # Inputs and output are in radians. This uses the great-circle special
296 # case of the Vicenty formula for distances on ellipsoids.
297 function gcdist(lat1, long1, lat2, long2, dlong, x, y, num, denom) {
298 dlong = long2 - long1
299 x = cos(lat2) * sin(dlong)
300 y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlong)
301 num = sqrt(x * x + y * y)
302 denom = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(dlong)
303 return atan2(num, denom)
304 }
305 # Parallel distance between points with given latitude and longitude.
306 # This is the product of the longitude difference and the cosine
307 # of the latitude of the point that is further from the equator.
308 # I.e., it considers longitudes to be further apart if they are
309 # nearer the equator.
310 function pardist(lat1, long1, lat2, long2) {
311 return abs(long1 - long2) * min(cos(lat1), cos(lat2))
312 }
313 # The distance function is the sum of the great-circle distance and
314 # the parallel distance. It could be weighted.
315 function dist(lat1, long1, lat2, long2) {
316 return gcdist(lat1, long1, lat2, long2) + pardist(lat1, long1, lat2, long2)
317 }
318 BEGIN {
319 coord_lat = convert_latitude(coord)
320 coord_long = convert_longitude(coord)
321 }
322 /^[^#]/ {
323 inline[inlines++] = $0
324 ncc = split($1, cc, /,/)
325 for (i = 1; i <= ncc; i++)
326 cc_used[cc[i]]++
327 }
328 END {
329 for (h = 0; h < inlines; h++) {
330 $0 = inline[h]
331 line = $1 "\t" $2 "\t" $3
332 sep = "\t"
333 ncc = split($1, cc, /,/)
334 split("", item_seen)
335 item_seen[""] = 1
336 for (i = 1; i <= ncc; i++) {
337 item = cc_used[cc[i]] <= 1 ? country[cc[i]] : $4
338 if (item_seen[item]++) continue
339 line = line sep item
340 sep = "; "
341 }
342 if (output_times) {
343 fmt = "TZ='\''%s'\'' date +'\''%d %%Y %%m %%d %%H:%%M %%a %%b\t%s'\''\n"
344 gsub(/'\''/, "&\\\\&&", line)
345 printf fmt, $3, h, line
346 } else {
347 here_lat = convert_latitude($2)
348 here_long = convert_longitude($2)
349 printf "%g\t%s\n", dist(coord_lat, coord_long, here_lat, here_long), line
350 }
351 }
352 }
353 '
354
355 # Begin the main loop. We come back here if the user wants to retry.
356 while
357
358 echo >&2 'Please identify a location' \
359 'so that time zone rules can be set correctly.'
360
361 continent=
362 country=
363 region=
364
365 case $coord in
366 ?*)
367 continent=coord;;
368 '')
369
370 # Ask the user for continent or ocean.
371
372 echo >&2 'Please select a continent, ocean, "coord", "TZ", or "time".'
373
374 quoted_continents=`
375 $AWK '
376 function handle_entry(entry) {
377 entry = substr(entry, 1, index(entry, "/") - 1)
378 if (entry == "America")
379 entry = entry "s"
380 if (entry ~ /^(Arctic|Atlantic|Indian|Pacific)$/)
381 entry = entry " Ocean"
382 printf "'\''%s'\''\n", entry
383 }
384 BEGIN { FS = "\t" }
385 /^[^#]/ {
386 handle_entry($3)
387 }
388 /^#@/ {
389 ncont = split($2, cont, /,/)
390 for (ci = 1; ci <= ncont; ci++) {
391 handle_entry(cont[ci])
392 }
393 }
394 ' <"$TZ_ZONE_TABLE" |
395 sort -u |
396 tr '\n' ' '
397 echo ''
398 `
399
400 eval '
401 doselect '"$quoted_continents"' \
402 "coord - I want to use geographical coordinates." \
403 "TZ - I want to specify the timezone using the Posix TZ format." \
404 "time - I know local time already."
405 continent=$select_result
406 case $continent in
407 Americas) continent=America;;
408 *" "*) continent=`expr "$continent" : '\''\([^ ]*\)'\''`
409 esac
410 '
411 esac
412
413 case $continent in
414 TZ)
415 # Ask the user for a Posix TZ string. Check that it conforms.
416 while
417 echo >&2 'Please enter the desired value' \
418 'of the TZ environment variable.'
419 echo >&2 'For example, AEST-10 is abbreviated' \
420 'AEST and is 10 hours'
421 echo >&2 'ahead (east) of Greenwich,' \
422 'with no daylight saving time.'
423 read TZ
424 $AWK -v TZ="$TZ" 'BEGIN {
425 tzname = "(<[[:alnum:]+-]{3,}>|[[:alpha:]]{3,})"
426 time = "(2[0-4]|[0-1]?[0-9])" \
427 "(:[0-5][0-9](:[0-5][0-9])?)?"
428 offset = "[-+]?" time
429 mdate = "M([1-9]|1[0-2])\\.[1-5]\\.[0-6]"
430 jdate = "((J[1-9]|[0-9]|J?[1-9][0-9]" \
431 "|J?[1-2][0-9][0-9])|J?3[0-5][0-9]|J?36[0-5])"
432 datetime = ",(" mdate "|" jdate ")(/" time ")?"
433 tzpattern = "^(:.*|" tzname offset "(" tzname \
434 "(" offset ")?(" datetime datetime ")?)?)$"
435 if (TZ ~ tzpattern) exit 1
436 exit 0
437 }'
438 do
439 say >&2 "'$TZ' is not a conforming Posix timezone string."
440 done
441 TZ_for_date=$TZ;;
442 *)
443 case $continent in
444 coord)
445 case $coord in
446 '')
447 echo >&2 'Please enter coordinates' \
448 'in ISO 6709 notation.'
449 echo >&2 'For example, +4042-07403 stands for'
450 echo >&2 '40 degrees 42 minutes north,' \
451 '74 degrees 3 minutes west.'
452 read coord;;
453 esac
454 distance_table=`$AWK \
455 -v coord="$coord" \
456 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
457 "$output_distances_or_times" <"$TZ_ZONE_TABLE" |
458 sort -n |
459 sed "${location_limit}q"
460 `
461 regions=`$AWK \
462 -v distance_table="$distance_table" '
463 BEGIN {
464 nlines = split(distance_table, line, /\n/)
465 for (nr = 1; nr <= nlines; nr++) {
466 nf = split(line[nr], f, /\t/)
467 print f[nf]
468 }
469 }
470 '`
471 echo >&2 'Please select one of the following timezones,'
472 echo >&2 'listed roughly in increasing order' \
473 "of distance from $coord".
474 doselect $regions
475 region=$select_result
476 TZ=`$AWK \
477 -v distance_table="$distance_table" \
478 -v region="$region" '
479 BEGIN {
480 nlines = split(distance_table, line, /\n/)
481 for (nr = 1; nr <= nlines; nr++) {
482 nf = split(line[nr], f, /\t/)
483 if (f[nf] == region) {
484 print f[4]
485 }
486 }
487 }
488 '`
489 ;;
490 *)
491 case $continent in
492 time)
493 minute_format='%a %b %d %H:%M'
494 old_minute=`TZ=UTC0 date +"$minute_format"`
495 for i in 1 2 3
496 do
497 time_table_command=`
498 $AWK -v output_times=1 \
499 "$output_distances_or_times" <"$TZ_ZONE_TABLE"
500 `
501 time_table=`eval "$time_table_command"`
502 new_minute=`TZ=UTC0 date +"$minute_format"`
503 case $old_minute in
504 "$new_minute") break;;
505 esac
506 old_minute=$new_minute
507 done
508 echo >&2 "The system says Universal Time is $new_minute."
509 echo >&2 "Assuming that's correct, what is the local time?"
510 eval doselect `
511 say "$time_table" |
512 sort -k2n -k2,5 -k1n |
513 $AWK '{
514 line = $6 " " $7 " " $4 " " $5
515 if (line == oldline) next
516 oldline = line
517 gsub(/'\''/, "&\\\\&&", line)
518 printf "'\''%s'\''\n", line
519 }'
520 `
521 time=$select_result
522 zone_table=`
523 say "$time_table" |
524 $AWK -v time="$time" '{
525 if ($6 " " $7 " " $4 " " $5 == time) {
526 sub(/[^\t]*\t/, "")
527 print
528 }
529 }'
530 `
531 countries=`
532 say "$zone_table" |
533 $AWK \
534 -v continent_re='' \
535 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
536 "$output_country_list" |
537 sort -f
538 `
539 ;;
540 *)
541 zone_table=file
542 # Get list of names of countries in the continent or ocean.
543 countries=`$AWK \
544 -v continent_re="^$continent/" \
545 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
546 "$output_country_list" \
547 <"$TZ_ZONE_TABLE" | sort -f
548 `;;
549 esac
550
551 # If there's more than one country, ask the user which one.
552 case $countries in
553 *"$newline"*)
554 echo >&2 'Please select a country' \
555 'whose clocks agree with yours.'
556 doselect $countries
557 country_result=$select_result
558 country=$select_result;;
559 *)
560 country=$countries
561 esac
562
563
564 # Get list of timezones in the country.
565 regions=`
566 case $zone_table in
567 file) cat -- "$TZ_ZONE_TABLE";;
568 *) say "$zone_table";;
569 esac |
570 $AWK \
571 -v country="$country" \
572 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
573 '
574 BEGIN {
575 FS = "\t"
576 cc = country
577 while (getline <TZ_COUNTRY_TABLE) {
578 if ($0 !~ /^#/ && country == $2) {
579 cc = $1
580 break
581 }
582 }
583 }
584 /^#/ { next }
585 $1 ~ cc { print $4 }
586 '
587 `
588
589
590 # If there's more than one region, ask the user which one.
591 case $regions in
592 *"$newline"*)
593 echo >&2 'Please select one of the following timezones.'
594 doselect $regions
595 region=$select_result
596 esac
597
598 # Determine TZ from country and region.
599 TZ=`
600 case $zone_table in
601 file) cat -- "$TZ_ZONE_TABLE";;
602 *) say "$zone_table";;
603 esac |
604 $AWK \
605 -v country="$country" \
606 -v region="$region" \
607 -v TZ_COUNTRY_TABLE="$TZ_COUNTRY_TABLE" \
608 '
609 BEGIN {
610 FS = "\t"
611 cc = country
612 while (getline <TZ_COUNTRY_TABLE) {
613 if ($0 !~ /^#/ && country == $2) {
614 cc = $1
615 break
616 }
617 }
618 }
619 /^#/ { next }
620 $1 ~ cc && ($4 == region || !region) { print $3 }
621 '
622 `;;
623 esac
624
625 # Make sure the corresponding zoneinfo file exists.
626 TZ_for_date=$TZDIR/$TZ
627 <"$TZ_for_date" || {
628 say >&2 "$0: time zone files are not set up correctly"
629 exit 1
630 }
631 esac
632
633
634 # Use the proposed TZ to output the current date relative to UTC.
635 # Loop until they agree in seconds.
636 # Give up after 8 unsuccessful tries.
637
638 extra_info=
639 for i in 1 2 3 4 5 6 7 8
640 do
641 TZdate=`LANG=C TZ="$TZ_for_date" date`
642 UTdate=`LANG=C TZ=UTC0 date`
643 TZsec=`expr "$TZdate" : '.*:\([0-5][0-9]\)'`
644 UTsec=`expr "$UTdate" : '.*:\([0-5][0-9]\)'`
645 case $TZsec in
646 $UTsec)
647 extra_info="
648 Selected time is now: $TZdate.
649 Universal Time is now: $UTdate."
650 break
651 esac
652 done
653
654
655 # Output TZ info and ask the user to confirm.
656
657 echo >&2 ""
658 echo >&2 "Based on the following information:"
659 echo >&2 ""
660 case $time%$country_result%$region%$coord in
661 ?*%?*%?*%)
662 say >&2 " $time$newline $country_result$newline $region";;
663 ?*%?*%%|?*%%?*%) say >&2 " $time$newline $country_result$region";;
664 ?*%%%) say >&2 " $time";;
665 %?*%?*%) say >&2 " $country_result$newline $region";;
666 %?*%%) say >&2 " $country_result";;
667 %%?*%?*) say >&2 " coord $coord$newline $region";;
668 %%%?*) say >&2 " coord $coord";;
669 *) say >&2 " TZ='$TZ'"
670 esac
671 say >&2 ""
672 say >&2 "TZ='$TZ' will be used.$extra_info"
673 say >&2 "Is the above information OK?"
674
675 doselect Yes No
676 ok=$select_result
677 case $ok in
678 Yes) break
679 esac
680 do coord=
681 done
682
683 case $SHELL in
684 *csh) file=.login line="setenv TZ '$TZ'";;
685 *) file=.profile line="TZ='$TZ'; export TZ"
686 esac
687
688 test -t 1 && say >&2 "
689 You can make this change permanent for yourself by appending the line
690 $line
691 to the file '$file' in your home directory; then log out and log in again.
692
693 Here is that TZ value again, this time on standard output so that you
694 can use the $0 command in shell scripts:"
695
696 say "$TZ"
697