branch: elpa/datetime commit ecc98a0b2d9b465c30f8417f2f532cc22881064b Author: Paul Pogonyshev <pogonys...@gmail.com> Commit: Paul Pogonyshev <pogonys...@gmail.com>
Fix several issues that would sometimes cause the library to pick wrong timezone name variant (DST vs. normal). --- datetime.el | 6 +++- dev/HarvestData.java | 87 ++++++++++++++++++++++++++++++++++++++++++--------- test/format.el | 30 +++++++++++++++--- timezone-data.extmap | Bin 889713 -> 900579 bytes 4 files changed, 104 insertions(+), 19 deletions(-) diff --git a/datetime.el b/datetime.el index 7a6308347f..6596a4d6e1 100644 --- a/datetime.el +++ b/datetime.el @@ -798,7 +798,11 @@ to this function. day-of-month -1)) (offset-before (plist-get rule :before))) (unless transitions - (push offset-before transitions)) + ;; Preserve our DST "flag" across year boundary. + (push (if (floatp (car (last (aref all-year-transitions (1- year-offset))))) + (float offset-before) + offset-before) + transitions)) (when day-of-week (let ((current-weekday (% (+ year-day (aref datetime--gregorian-first-day-of-year (mod year 400))) 7))) (setq year-day (if (< day-of-month 0) (- year-day (mod (- day-of-week current-weekday) 7)) (+ year-day (mod (- day-of-week current-weekday) 7)))))) diff --git a/dev/HarvestData.java b/dev/HarvestData.java index 7eb866dc55..8735a5d523 100644 --- a/dev/HarvestData.java +++ b/dev/HarvestData.java @@ -298,26 +298,52 @@ public class HarvestData LocalDateTime first = LocalDateTime.ofInstant (transitions.get (0).getInstant (), ZoneOffset.UTC); int base_year = Year.of (first.get (ChronoField.YEAR)).getValue (); long base = Year.of (first.get (ChronoField.YEAR)).atDay (1).atStartOfDay ().toInstant (ZoneOffset.UTC).getEpochSecond (); - int last_offset = transitions.get (0).getOffsetBefore ().getTotalSeconds (); + Offset last_offset = new Offset (transitions.get (0).getOffsetBefore ().getTotalSeconds (), false); List <Object> zone_data = new ArrayList <> (); List <List <Object>> transition_data = new ArrayList <> (); - for (ZoneOffsetTransition transition : transitions) { - int year_offset = (int) ((transition.getInstant ().getEpochSecond () - base) / AVERAGE_SECONDS_IN_YEAR); - if ((transition.getInstant ().getEpochSecond () + 1 - base) % AVERAGE_SECONDS_IN_YEAR < 1) - System.err.printf ("*Warning*: timezone '%s', offset transition at %s would be a potential rounding error\n", timezone.getId (), transition.getInstant ()); + for (int k = 0; k < transitions.size (); k++) { + var transition = transitions.get (k); + var instant = transition.getInstant (); + last_offset = addTransition (timezone, transition_data, instant, base, last_offset, transition.getOffsetAfter ()); + + // Quite a few timezones have DST changes not covered by the exposed transitions (see + // 'standardTransitions' in 'ZoneRules.java', no way to extract those without hacks). + // Because of that, we'd sometimes use invalid DST/non-DST timezone name variant. As a + // workaround, we scan instants between subsequent "official" transitions with a day step + // (should be more than enough) and check if DST changes. If yes, we use binary search to + // finally pinpoint the exact instant of the change. + Instant limit; + if (k + 1 < transitions.size ()) + limit = transitions.get (k + 1).getInstant (); + else if (rules.getTransitionRules ().isEmpty ()) + limit = instant.plusSeconds (86400 * 365 * 25); + else + continue; + + while (true) { + var next = instant.plusSeconds (86400); + if (!next.isBefore (limit)) + break; + + if (!Objects.equals (Offset.at (rules, next), last_offset)) { + var before = instant; + var after = next; - while (year_offset >= transition_data.size ()) - transition_data.add (new ArrayList <> (Arrays.asList (last_offset))); + while (after.getEpochSecond () - before.getEpochSecond () > 1) { + var middle = Instant.ofEpochSecond ((after.getEpochSecond () + before.getEpochSecond ()) / 2); + if (Objects.equals (Offset.at (rules, middle), last_offset)) + before = middle; + else + after = middle; + } - transition_data.get (year_offset).add (transition.getInstant ().getEpochSecond () - (base + year_offset * AVERAGE_SECONDS_IN_YEAR)); - last_offset = transition.getOffsetAfter ().getTotalSeconds (); + last_offset = addTransition (timezone, transition_data, after, base, last_offset, rules.getOffset (next)); + next = after; + } - // Floating-point offset is our internal mark of a transition to DST. - // Java is over-eager to convert ints to float for us, so we format - // them as strings manually now and add '.0' if appropriate. - boolean to_dst = !Objects.equals (transition.getOffsetAfter (), rules.getStandardOffset (transition.getInstant ())); - transition_data.get (year_offset).add (String.format (to_dst ? "%d.0" : "%d", last_offset)); + instant = next; + } } List <Object> transition_rule_data = new ArrayList <> (); @@ -374,6 +400,22 @@ public class HarvestData System.out.println (")"); } + protected static Offset addTransition (ZoneId timezone, List <List <Object>> transition_data, Instant instant, long base, Offset last_offset, ZoneOffset new_offset) + { + int year_offset = (int) ((instant.getEpochSecond () - base) / AVERAGE_SECONDS_IN_YEAR); + if ((instant.getEpochSecond () + 1 - base) % AVERAGE_SECONDS_IN_YEAR < 1) + System.err.printf ("*Warning*: timezone '%s', offset transition at %s would be a potential rounding error\n", timezone.getId (), instant); + + while (year_offset >= transition_data.size ()) + transition_data.add (new ArrayList <> (List.of (last_offset.toLisp ()))); + + transition_data.get (year_offset).add (instant.getEpochSecond () - (base + year_offset * AVERAGE_SECONDS_IN_YEAR)); + + last_offset = new Offset (new_offset.getTotalSeconds (), timezone.getRules ().isDaylightSavings (instant)); + transition_data.get (year_offset).add (last_offset.toLisp ()); + return last_offset; + } + protected static void printTimezoneNameData () throws Exception { @@ -574,4 +616,21 @@ public class HarvestData { return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); } + + + protected static record Offset (int seconds, boolean dst) + { + public static Offset at (ZoneRules rules, Instant instant) + { + return new Offset (rules.getOffset (instant).getTotalSeconds (), rules.isDaylightSavings (instant)); + } + + // Floating-point offset is our internal Lisp-level mark of a transition to DST. + // Java is over-eager to convert ints to float for us, so we format them as + // strings manually now and add '.0' if appropriate. + public Object toLisp () + { + return String.format (dst ? "%d.0" : "%d", seconds); + } + } } diff --git a/test/format.el b/test/format.el index 40d79f8d9f..b8af6663d4 100644 --- a/test/format.el +++ b/test/format.el @@ -115,10 +115,32 @@ (datetime--test-formatter-around-transition 1412438400))) (ert-deftest datetime-formatting-with-timezone-name-1 () - (datetime--test-set-up-formatter 'Europe/Berlin 'en "yyyy-MM-dd HH:mm:ss z" - ;; Rule-based transition on 2014-10-26. Should also result in - ;; timezone name changing between CEST and CET. - (datetime--test-formatter-around-transition 1414285200))) + (dolist (timezone (datetime-list-timezones)) + (datetime--test-set-up-formatter timezone 'en "yyyy-MM-dd HH:mm:ss z" + ;; Rule-based transition on 2014-10-26. In some timezones should also result in name + ;; changing, e.g. between CEST and CET. + (datetime--test-formatter-around-transition 1414285200)))) + +(ert-deftest datetime-formatting-with-timezone-name-2 () + ;; Many timezones had special relations with DST (see comments in 'HarvestData.java'), so + ;; resulting name varies a lot. Make sure we handle all that correctly. Too much to test all + ;; timezones, only some selected. + (dolist (timezone '(Africa/Algiers Africa/Tripoli Africa/Windhoek + America/Anchorage America/Argentina/Buenos_Aires America/Argentina/Ushuaia America/Chihuahua America/Dawson America/Indiana/Knox America/Iqaluit America/Kentucky/Louisville America/Whitehorse + Antarctica/McMurdo Antarctica/Palmer + Asia/Almaty Asia/Ashkhabad Asia/Baku Asia/Bishkek Asia/Damascus Asia/Istanbul Asia/Kamchatka Asia/Omsk Asia/Singapore Asia/Tashkent Asia/Tbilisi Asia/Vladivostok Asia/Yerevan + Atlantic/Azores + Canada/Yukon + Chile/EasterIsland + Europe/Belfast Europe/Kaliningrad Europe/Lisbon Europe/Minsk Europe/Moscow Europe/Simferopol Europe/Tallinn Europe/Warsaw + GB Libya NZ + Pacific/Auckland Pacific/Norfolk + Poland Portugal + US/Alaska US/Aleutian US/Pacific + W-SU)) + (datetime--test-set-up-formatter timezone 'en "yyyy-MM-dd HH:mm:ss z" + ;; Exact numbers don't matter much, we just need to skip a few months each time. + (datetime--test-formatter (mapcar (lambda (k) (* k 7000000)) (number-sequence -300 400)))))) (ert-deftest datetime-formatting-day-periods () (let (times) diff --git a/timezone-data.extmap b/timezone-data.extmap index bf074b459f..731fd92781 100644 Binary files a/timezone-data.extmap and b/timezone-data.extmap differ