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