mirror of
https://git.savannah.gnu.org/git/emacs.git
synced 2026-09-10 07:46:51 -04:00
Distinguish UTC vs. UTC+0:00 times in iCalendar library
This fixes a bug discussed on emacs-devel; see the thread at https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html The issue was that the library did not distinguish between UTC time and times that have an offset of 0 but are not in the UTC time zone, e.g. standard times in Europe/London or Europe/Dublin. This led to test failures on systems in these time zones. This fix represents true UTC times by inserting the special value `t' in the zone slot of a decoded time at parse time (which `encode-time' already handles correctly). This distinguishes them from decoded times with an offset of 0 seconds. * lisp/calendar/icalendar-ast.el (icalendar-make-component): Ensure auto-generated DTSTAMP uses the new representation. (icalendar-make-node-from-templates): Fix docstring. * lisp/calendar/icalendar-parser.el (icalendar-read-time) (icalendar-read-date-time): Read iCalendar UTC times to the new representation. (icalendar-print-time): Print the new representation. (icalendar--decoded-time-p, icalendar--decoded-date-time-p) (icalendar-date-time-is-utc-p): Check for the new representation. (icalendar-date-time): Docstring improvement. (icalendar-requires-utc-validator): New validator function to check that property values are in UTC. (icalendar-completed, icalendar-created, icalendar-dtstamp): Use it. * lisp/calendar/icalendar-recur.el (icalendar-recur-tz-decode-time): Decode to new representation; explicitly support this. * lisp/calendar/icalendar-utils.el (icalendar-date-time<) (icalendar-date-time-simultaneous-p): * lisp/calendar/diary-icalendar.el (diary-icalendar-format-time-as-local): Work with the new representation. (diary-icalendar-convert-time-via-strategy) (diary-icalendar-parse-entry): Ensure times decode to new representation. * test/lisp/calendar/icalendar-parser-tests.el (icalendar-parser-test-bad-hyphenated-dates): * test/lisp/calendar/icalendar-recur-tests.el (icalendar-test-recur-find-secondly-interval) (icalendar-test-recur-tz-observance-on): Update tests to use new representation. * test/lisp/calendar/icalendar-parser-tests.el (icalendar-parse-test-utc+0-is-not-utc): New regression test. * test/lisp/calendar/diary-icalendar-tests.el: Update 'to-utc test. * test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all: Update diary representation of UTC times in a recurrence rule.
This commit is contained in:
parent
1b500ce5a5
commit
db6beef260
|
|
@ -1366,7 +1366,7 @@ the iCalendar data."
|
|||
(local-dt (decode-time ts local-tz))
|
||||
(local-str (di:format-time local-dt)))
|
||||
(if (and original-tzname original-offset
|
||||
(not (= original-offset local-offset)))
|
||||
(not (eql original-offset local-offset)))
|
||||
(format "%s (%s)" local-str (di:format-time dt original-tzname))
|
||||
local-str)))))
|
||||
|
||||
|
|
@ -2508,8 +2508,8 @@ zone export strategy requires it."
|
|||
(icr:tz-decode-time (encode-time dt) vtimezone)
|
||||
(icr:tz-set-zone dt vtimezone :error)))
|
||||
((or (eq 'to-utc di:time-zone-export-strategy)
|
||||
(di:-tz-is-utc-p)) ; we're already in UTC, so mark dt as such
|
||||
(decode-time (encode-time dt) t))
|
||||
(di:-tz-is-utc-p))
|
||||
(icr:tz-decode-time (encode-time dt) t)) ; ensure dt is in UTC
|
||||
((eq 'floating di:time-zone-export-strategy)
|
||||
(setf (decoded-time-zone dt) nil)
|
||||
dt)))))
|
||||
|
|
@ -3341,7 +3341,10 @@ recursive calls to this function made by
|
|||
;; Collect the remaining properties:
|
||||
(setq all-props (append (di:parse-summary-and-description) all-props))
|
||||
(setq all-props (append (di:parse-attendees-and-organizer) all-props))
|
||||
(push (ical:make-property ical:dtstamp (decode-time nil t)) all-props)
|
||||
(push
|
||||
(ical:make-property ical:dtstamp
|
||||
(icr:tz-decode-time (current-time) t)) ; ensure UTC
|
||||
all-props)
|
||||
(let ((class (di:parse-class))
|
||||
(location (di:parse-location))
|
||||
(status (di:parse-status))
|
||||
|
|
|
|||
|
|
@ -569,8 +569,13 @@ The resulting syntax node is checked for validity by
|
|||
;; Add templates for required properties automatically if we can:
|
||||
(when (memq type '(ical:vevent ical:vtodo ical:vjournal ical:vfreebusy))
|
||||
(unless (assq 'ical:dtstamp templates)
|
||||
(push '(ical:dtstamp (decode-time nil t))
|
||||
templates))
|
||||
(push
|
||||
'(icalendar-make-property icalendar-dtstamp
|
||||
(let ((stamp (decode-time nil t)))
|
||||
;; Ensure we return UTC, not just :zone 0:
|
||||
(setf (decoded-time-zone stamp) t)
|
||||
stamp))
|
||||
templates))
|
||||
(unless (assq 'ical:uid templates)
|
||||
(push `(ical:uid ,(ical:make-uid templates))
|
||||
templates)))
|
||||
|
|
@ -652,7 +657,6 @@ added to the component or property node.
|
|||
For example, an iCalendar VEVENT could be written like this:
|
||||
|
||||
(icalendar-make-node-from-templates icalendar-vevent
|
||||
(icalendar-dtstamp (decode-time (current-time) 0))
|
||||
(icalendar-uid \"some-unique-id\")
|
||||
(icalendar-summary \"Party\")
|
||||
(icalendar-location \"Robot House\")
|
||||
|
|
|
|||
|
|
@ -896,7 +896,7 @@ S should be a match against rx `icalendar-time'."
|
|||
(second (string-to-number (substring s 4 6)))
|
||||
(utcoffset (if (and (length= s 7)
|
||||
(equal "Z" (substring s 6 7)))
|
||||
0
|
||||
t ; UTC
|
||||
;; unknown/'floating' time zone:
|
||||
nil)))
|
||||
(ical:make-date-time :second second
|
||||
|
|
@ -910,7 +910,7 @@ S should be a match against rx `icalendar-time'."
|
|||
(decoded-time-hour time)
|
||||
(decoded-time-minute time)
|
||||
(decoded-time-second time)
|
||||
(if (eql 0 (decoded-time-zone time))
|
||||
(if (eq t (decoded-time-zone time))
|
||||
"Z" "")))
|
||||
|
||||
(defun ical:-decoded-time-p (val)
|
||||
|
|
@ -923,7 +923,7 @@ for that, see `icalendar--decoded-date-time-p'."
|
|||
(cl-typep (decoded-time-minute val) 'ical:numeric-minute)
|
||||
(cl-typep (decoded-time-hour val) 'ical:numeric-hour)
|
||||
(cl-typep (decoded-time-dst val) '(member t nil -1))
|
||||
(cl-typep (decoded-time-zone val) '(or integer null))))
|
||||
(cl-typep (decoded-time-zone val) '(or integer boolean))))
|
||||
|
||||
(ical:define-type ical:time "TIME"
|
||||
"Type for Time values.
|
||||
|
|
@ -967,7 +967,7 @@ fields and DST are ignored when printed."
|
|||
;; `make-decoded-time':
|
||||
;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
|
||||
(cl-typep (decoded-time-dst val) '(member t nil -1))
|
||||
(cl-typep (decoded-time-zone val) '(or integer null))))
|
||||
(cl-typep (decoded-time-zone val) '(or integer boolean))))
|
||||
|
||||
(defun ical:read-date-time (s)
|
||||
"Read an `icalendar-date-time' from a string S.
|
||||
|
|
@ -982,7 +982,7 @@ S should be a match against rx `icalendar-date-time'."
|
|||
(second (string-to-number (substring s 13 15)))
|
||||
(utcoffset (if (and (length= s 16)
|
||||
(equal "Z" (substring s 15 16)))
|
||||
0
|
||||
t ; UTC
|
||||
;; unknown/'floating' time zone:
|
||||
nil)))
|
||||
(ical:make-date-time :second second
|
||||
|
|
@ -1007,16 +1007,15 @@ S should be a match against rx `icalendar-date-time'."
|
|||
|
||||
(defun ical:date-time-is-utc-p (datetime)
|
||||
"Return non-nil if DATETIME is in UTC time."
|
||||
(let ((offset (decoded-time-zone datetime)))
|
||||
(and offset (= 0 offset))))
|
||||
(eq t (decoded-time-zone datetime)))
|
||||
|
||||
(ical:define-type ical:date-time "DATE-TIME"
|
||||
"Type for Date-Time values.
|
||||
|
||||
When printed, a date-time is a string of digits like:
|
||||
YYYYMMDDTHHMMSS
|
||||
where the 'T' is literal, and separates the date string from the
|
||||
time string.
|
||||
where the 'T' is literal, and separates the date string from the time
|
||||
string. If followed by a 'Z', the string represents a UTC date-time.
|
||||
|
||||
When read, a date-time is a decoded time, i.e. a list in the format
|
||||
(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
|
||||
|
|
@ -2933,6 +2932,7 @@ an `icalendar-vtodo' was actually completed. The value must be an
|
|||
`icalendar-date-time' with a UTC time."
|
||||
ical:date-time
|
||||
:child-spec (:zero-or-more (ical:otherparam))
|
||||
:other-validator ical:requires-utc-validator
|
||||
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
|
||||
|
||||
(ical:define-property ical:dtend "DTEND"
|
||||
|
|
@ -3381,6 +3381,7 @@ initially created an `icalendar-vevent', `icalendar-vtodo', or
|
|||
in UTC time."
|
||||
ical:date-time
|
||||
:child-spec (:zero-or-more (ical:otherparam))
|
||||
:other-validator ical:requires-utc-validator
|
||||
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
|
||||
|
||||
(ical:define-property ical:dtstamp "DTSTAMP"
|
||||
|
|
@ -3404,8 +3405,17 @@ object *representing* that data was created.
|
|||
The value must be in UTC time."
|
||||
ical:date-time
|
||||
:child-spec (:zero-or-more (ical:otherparam))
|
||||
:other-validator ical:requires-utc-validator
|
||||
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
|
||||
|
||||
(defun ical:requires-utc-validator (node)
|
||||
"Validate that a property NODE's value is a UTC date-time"
|
||||
(ical:with-property node nil
|
||||
(unless (ical:date-time-is-utc-p value)
|
||||
(ical:signal-validation-error
|
||||
(format "An `%s's value must be in UTC" (ical:ast-node-type node))
|
||||
:node node))))
|
||||
|
||||
(ical:define-property ical:last-modified "LAST-MODIFIED"
|
||||
"Last Modified timestamp.
|
||||
|
||||
|
|
|
|||
|
|
@ -1971,19 +1971,21 @@ based on whether the observance is an `icalendar-standard' or
|
|||
observance that applies to TS, it is decoded into UTC time.
|
||||
|
||||
VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
|
||||
decoded directly into this UTC offset, and its dst slot is set to -1."
|
||||
decoded directly into this UTC offset, and its dst slot is set to -1.
|
||||
If VTIMEZONE is t, TS is decoded to UTC time."
|
||||
(let* ((observance (when (ical:vtimezone-component-p vtimezone)
|
||||
(car (icr:tz-observance-on ts vtimezone))))
|
||||
(offset (cond (observance (icr:tz-offset-in observance))
|
||||
((cl-typep vtimezone 'ical:utc-offset)
|
||||
vtimezone)
|
||||
(t 0))))
|
||||
(t t)))) ; decode to UTC
|
||||
|
||||
(ical:date-time-variant ; ensures weekday gets set, too
|
||||
(decode-time ts offset)
|
||||
:zone offset
|
||||
:dst (if observance (ical:daylight-component-p observance)
|
||||
-1))))
|
||||
:dst (cond (observance (ical:daylight-component-p observance))
|
||||
((eq t vtimezone) nil) ; UTC
|
||||
(t -1)))))
|
||||
|
||||
(defun icr:tz-set-zone (dt vtimezone &optional nonexistent)
|
||||
"Set the time zone offset and dst flag in DT based on VTIMEZONE.
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ returned from `current-time-zone' is used as the missing offset; if
|
|||
signaled."
|
||||
(let ((zone1 (decoded-time-zone dt1))
|
||||
(zone2 (decoded-time-zone dt2)))
|
||||
(cond ((and (integerp zone1) (integerp zone2))
|
||||
(cond ((and zone1 zone2)
|
||||
(time-less-p (encode-time dt1) (encode-time dt2)))
|
||||
((and (null zone1) (null zone2))
|
||||
(ical:date-time-locally< dt1 dt2))
|
||||
|
|
@ -357,11 +357,11 @@ non-simultaneous if they represent different clock times according to
|
|||
signaled."
|
||||
(let ((zone1 (decoded-time-zone dt1))
|
||||
(zone2 (decoded-time-zone dt2)))
|
||||
(cond ((and (integerp zone1) (integerp zone2))
|
||||
(cond ((and zone1 zone2)
|
||||
(time-equal-p (encode-time dt1) (encode-time dt2)))
|
||||
((and (null zone1) (null zone2))
|
||||
(time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
|
||||
(encode-time (ical:date-time-variant dt2 :zone 0))))
|
||||
(time-equal-p (encode-time (ical:date-time-variant dt1 :zone t))
|
||||
(encode-time (ical:date-time-variant dt2 :zone t))))
|
||||
(t
|
||||
;; Best effort:
|
||||
;; TODO: I'm not convinced this is the right thing to do yet.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
&%%(diary-rrule :rule '((FREQ MONTHLY) (BYDAY ((3 . 1))) (INTERVAL 1))
|
||||
:exclude
|
||||
'((0 46 11 6 1 2016 3 -1 0) (0 46 11 3 2 2016 3 -1 0)
|
||||
(0 46 11 2 3 2016 3 -1 0) (0 46 10 4 5 2016 3 -1 0)
|
||||
(0 46 10 1 6 2016 3 -1 0))
|
||||
'((0 46 11 6 1 2016 3 -1 t) (0 46 11 3 2 2016 3 -1 t)
|
||||
(0 46 11 2 3 2016 3 -1 t) (0 46 10 4 5 2016 3 -1 t)
|
||||
(0 46 10 1 6 2016 3 -1 t))
|
||||
:start '(0 46 12 2 12 2015 3 -1 nil) :duration
|
||||
'(0 14 3 0 nil nil nil -1 nil)) Summary
|
||||
Location: Loc
|
||||
|
|
|
|||
|
|
@ -1153,7 +1153,7 @@ SOURCE, if given, should be a symbol; it is used to name the test."
|
|||
(unwind-protect
|
||||
(ical:with-component (car parsed)
|
||||
((ical:dtstart :first start-node :value start))
|
||||
(should (= 0 (decoded-time-zone start)))
|
||||
(should (ical:date-time-is-utc-p start))
|
||||
(should (= (- 16 2) (decoded-time-hour start)))
|
||||
(should-not (ical:with-param-of start-node 'ical:tzidparam)))
|
||||
;; restore time zone
|
||||
|
|
|
|||
|
|
@ -1962,7 +1962,7 @@ END:VCALENDAR
|
|||
(expected-dtstamp
|
||||
(ical:make-date-time :year 2023 :month 7 :day 30
|
||||
:hour 19 :minute 47 :second 0
|
||||
:zone 0)))
|
||||
:zone t)))
|
||||
(should (not (ical:errors-p)))
|
||||
(should (ical:ast-node-valid-p vcal t))
|
||||
(ical:with-component vcal
|
||||
|
|
@ -2023,6 +2023,41 @@ END:VCALENDAR
|
|||
((ical:sentbyparam :value sent-by))
|
||||
(should (equal sent-by expected-sender))))))))))
|
||||
|
||||
|
||||
;; Tests for bugfixes:
|
||||
(ert-deftest ipt:utc+0-is-not-utc ()
|
||||
"Are UTC times parsed distinctly from times in other zones with 0 offset?"
|
||||
;; An explicit UTC time should parse with `t' in its zone field; this
|
||||
;; distinguishes it from times that merely have a 0 second offset from
|
||||
;; UTC, but might have a defined non-UTC time zone, e.g. in Europe/London
|
||||
;; or Europe/Dublin. Bug discussion:
|
||||
;; https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html
|
||||
(let* ((s-utc "20260101T111111Z")
|
||||
(s-non "20260101T111111")
|
||||
(parsed-utc (ical:ast-node-value
|
||||
(ical:parse-from-string 'ical:date-time s-utc)))
|
||||
(expected-utc (ical:make-date-time :year 2026 :month 1 :day 1
|
||||
:hour 11 :minute 11 :second 11
|
||||
:zone t))
|
||||
(parsed-non (ical:ast-node-value
|
||||
(ical:parse-from-string 'ical:date-time s-non)))
|
||||
(parsed-non-zoned (ical:date-time-variant parsed-non :zone 0))
|
||||
(expected-non (ical:make-date-time :year 2026 :month 1 :day 1
|
||||
:hour 11 :minute 11 :second 11
|
||||
:zone nil))
|
||||
(expected-non-zoned (ical:date-time-variant expected-non :zone 0)))
|
||||
;; Test that the two times are parsed distinctly:
|
||||
(should (equal parsed-utc expected-utc))
|
||||
(should (equal parsed-non expected-non))
|
||||
(should-not (equal (decoded-time-zone parsed-utc)
|
||||
(decoded-time-zone parsed-non)))
|
||||
;; Test that `icalendar-date-time-is-utc-p' distinguishes the two cases:
|
||||
(should (ical:date-time-is-utc-p parsed-utc))
|
||||
(should-not (ical:date-time-is-utc-p parsed-non))
|
||||
(should-not (ical:date-time-is-utc-p parsed-non-zoned))
|
||||
;; but also that `icalendar-date-time-simultaneous-p' does not:
|
||||
(should (ical:date-time-simultaneous-p parsed-utc expected-utc))
|
||||
(should (ical:date-time-simultaneous-p parsed-utc parsed-non-zoned))))
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ END:VTIMEZONE
|
|||
;; Use UTC for the tests with no
|
||||
;; time zone, so that the results
|
||||
;; don't depend on system's local time
|
||||
:zone 0))
|
||||
:zone t))
|
||||
(dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
|
||||
|
||||
;; Year numbers are monotonically increasing in the following test cases,
|
||||
|
|
@ -279,24 +279,24 @@ END:VTIMEZONE
|
|||
|
||||
;; No timezone, just clock time, around a target that doesn't fall on
|
||||
;; an interval boundary:
|
||||
(let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone 0))
|
||||
(let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone t))
|
||||
(expected-int
|
||||
(icr:make-interval
|
||||
(ical:date-time-variant target :second 0 :tz 'preserve)
|
||||
(ical:date-time-variant target :second 1 :tz 'preserve)
|
||||
(ical:date-time-variant target :second 10 :tz 'preserve))))
|
||||
(ical:date-time-variant target :second 0 :dst nil :tz 'preserve)
|
||||
(ical:date-time-variant target :second 1 :dst nil :tz 'preserve)
|
||||
(ical:date-time-variant target :second 10 :dst nil :tz 'preserve))))
|
||||
(should
|
||||
(equal expected-int
|
||||
(icr:find-secondly-interval target dtstart 10))))
|
||||
|
||||
;; No timezone, just clock time, around a target that does fall on
|
||||
;; an interval boundary:
|
||||
(let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone 0))
|
||||
(let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone t))
|
||||
(expected-int
|
||||
(icr:make-interval
|
||||
(ical:date-time-variant target :second 10 :tz 'preserve)
|
||||
(ical:date-time-variant target :second 11 :tz 'preserve)
|
||||
(ical:date-time-variant target :second 20 :tz 'preserve))))
|
||||
(ical:date-time-variant target :second 10 :dst nil :tz 'preserve)
|
||||
(ical:date-time-variant target :second 11 :dst nil :tz 'preserve)
|
||||
(ical:date-time-variant target :second 20 :dst nil :tz 'preserve))))
|
||||
(should
|
||||
(equal expected-int
|
||||
(icr:find-secondly-interval target dtstart 10))))
|
||||
|
|
@ -1334,7 +1334,7 @@ END:VTIMEZONE
|
|||
;; A date matching the end of a STANDARD observance:
|
||||
(let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
|
||||
:hour 6 :minute 0 :second 0
|
||||
:zone 0 :dst nil)) ; UNTIL is in UTC
|
||||
:zone t :dst nil)) ; UNTIL is in UTC
|
||||
(dt (ical:make-date-time :year 2006 :month 10 :day 29
|
||||
:hour 2 :minute 0 :second 0
|
||||
:zone ict:edt :dst t))
|
||||
|
|
@ -1352,7 +1352,7 @@ END:VTIMEZONE
|
|||
;; A date matching the end of a DAYLIGHT observance:
|
||||
(let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
|
||||
:hour 7 :minute 0 :second 0
|
||||
:zone 0 :dst nil)) ; UNTIL is in UTC
|
||||
:zone t :dst nil)) ; UNTIL is in UTC
|
||||
(dt (ical:make-date-time :year 2006 :month 4 :day 2
|
||||
:hour 2 :minute 0 :second 0
|
||||
:zone ict:est :dst nil))
|
||||
|
|
@ -1374,7 +1374,7 @@ END:VTIMEZONE
|
|||
:zone ict:est :dst nil))
|
||||
(end (ical:make-date-time :year 1986 :month 4 :day 27
|
||||
:hour 7 :minute 0 :second 0
|
||||
:zone 0)) ; UNTIL is in UTC
|
||||
:zone t)) ; UNTIL is in UTC
|
||||
(obs/onset (icr:tz-observance-on dt ict:tz-eastern))
|
||||
(obs (car obs/onset))
|
||||
(onset (cadr obs/onset))
|
||||
|
|
@ -2009,7 +2009,7 @@ SOURCE should be a symbol; it is used to name the test."
|
|||
:hour 9 :minute 0 :second 0
|
||||
:zone ict:edt :dst t)
|
||||
:high (ical:make-date-time :year 1997 :month 10 :day 8
|
||||
:hour 0 :minute 0 :second 0 :zone 0)
|
||||
:hour 0 :minute 0 :second 0 :zone t)
|
||||
:members
|
||||
(list
|
||||
;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
|
||||
|
|
@ -2034,7 +2034,7 @@ SOURCE should be a symbol; it is used to name the test."
|
|||
:hour 9 :minute 0 :second 0
|
||||
:zone ict:edt :dst t)
|
||||
:high (ical:make-date-time :year 1997 :month 10 :day 8
|
||||
:hour 0 :minute 0 :second 0 :zone 0)
|
||||
:hour 0 :minute 0 :second 0 :zone t)
|
||||
:members
|
||||
(list
|
||||
;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
|
||||
|
|
|
|||
Loading…
Reference in a new issue