tzselect.ksh revision 1.21 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 #
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.
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 'select' builtin if available,
89 # falling back on a less-nice but 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
204 # Awk script to read a time zone table and output the same table,
205 # with each column preceded by its distance from 'here'.
206 output_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.
290 while
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="
545 Selected time is now: $TZdate.
546 Universal 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
573 do coord=
574 done
575
576 case $SHELL in
577 *csh) file=.login line="setenv TZ '$TZ'";;
578 *) file=.profile line="TZ='$TZ'; export TZ"
579 esac
580
581 test -t 1 && say >&2 "
582 You can make this change permanent for yourself by appending the line
583 $line
584 to the file '$file' in your home directory; then log out and log in again.
585
586 Here is that TZ value again, this time on standard output so that you
587 can use the $0 command in shell scripts:"
588
589 say "$TZ"
590