1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398
|
;;; iso8601.el --- parse ISO 8601 date/time strings -*- lexical-binding:t -*-
;; Copyright (C) 2019-2025 Free Software Foundation, Inc.
;; Keywords: dates
;; This file is part of GNU Emacs.
;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; ISO8601 times basically look like 1985-04-01T15:23:49... Or so
;; you'd think. This is what everybody means when they say "ISO8601",
;; but it's in reality a quite large collection of syntaxes, including
;; week numbers, ordinal dates, durations and intervals. This package
;; has functions for parsing them all.
;;
;; The interface functions are `iso8601-parse', `iso8601-parse-date',
;; `iso8601-parse-time', `iso8601-parse-zone',
;; `iso8601-parse-duration' and `iso8601-parse-interval'. They all
;; return decoded time objects, except the last one, which returns a
;; list of three of them.
;;
;; (iso8601-parse-interval "P1Y2M10DT2H30M/2008W32T153000-01")
;; '((0 0 13 24 5 2007 nil nil -3600)
;; (0 30 15 3 8 2008 nil nil -3600)
;; (0 30 2 10 2 1 nil nil nil))
;;
;;
;; The standard can be found at:
;;
;; https://www.loc.gov/standards/datetime/iso-tc154-wg5_n0038_iso_wd_8601-1_2016-02-16.pdf
;;
;; The Wikipedia page on the standard is also informative:
;;
;; https://en.wikipedia.org/wiki/ISO_8601
;;
;; RFC3339 defines the subset that everybody thinks of as "ISO8601".
;;; Code:
(require 'time-date)
(require 'cl-lib)
(defun iso8601--concat-regexps (regexps)
(mapconcat (lambda (regexp)
(concat "\\(?:"
(string-replace "(" "(?:" regexp)
"\\)"))
regexps "\\|"))
(defconst iso8601--year-match
"\\([+-]?[0-9][0-9][0-9][0-9]\\)")
(defconst iso8601--full-date-match
"\\([+-]?[0-9][0-9][0-9][0-9]\\)-?\\([0-9][0-9]\\)-?\\([0-9][0-9]\\)")
(defconst iso8601--without-day-match
"\\([+-]?[0-9][0-9][0-9][0-9]\\)-\\([0-9][0-9]\\)")
(defconst iso8601--outdated-date-match
"--\\([0-9][0-9]\\)-?\\([0-9][0-9]\\)")
(defconst iso8601--outdated-reduced-precision-date-match
"---?\\([0-9][0-9]\\)")
(defconst iso8601--week-date-match
"\\([+-]?[0-9][0-9][0-9][0-9]\\)-?W\\([0-9][0-9]\\)-?\\([0-9]\\)?")
(defconst iso8601--ordinal-date-match
"\\([+-]?[0-9][0-9][0-9][0-9]\\)-?\\([0-9][0-9][0-9]\\)")
(defconst iso8601--date-match
(iso8601--concat-regexps
(list iso8601--year-match
iso8601--full-date-match
iso8601--without-day-match
iso8601--outdated-date-match
iso8601--outdated-reduced-precision-date-match
iso8601--week-date-match
iso8601--ordinal-date-match)))
(defconst iso8601--time-match
"\\([0-9][0-9]\\):?\\([0-9][0-9]\\)?:?\\([0-9][0-9]\\)?[.,]?\\([0-9]*\\)")
(defconst iso8601--zone-match
"\\(Z\\|\\([+-]\\)\\([0-9][0-9]\\):?\\([0-9][0-9]\\)?\\)")
(defconst iso8601--full-time-match
(concat "\\(" (string-replace "(" "(?:" iso8601--time-match) "\\)"
"\\(" iso8601--zone-match "\\)?"))
(defconst iso8601--combined-match
(concat "\\(" iso8601--date-match "\\)"
"\\(?:T\\("
(string-replace "(" "(?:" iso8601--time-match)
"\\)"
"\\(" iso8601--zone-match "\\)?\\)?"))
(defconst iso8601--duration-full-match
"P\\([0-9]+Y\\)?\\([0-9]+M\\)?\\([0-9]+D\\)?\\(T\\([0-9]+H\\)?\\([0-9]+M\\)?\\([0-9]+S\\)?\\)?")
(defconst iso8601--duration-week-match
"P\\([0-9]+\\)W")
(defconst iso8601--duration-combined-match
(concat "P" iso8601--combined-match))
(defconst iso8601--duration-match
(iso8601--concat-regexps
(list iso8601--duration-full-match
iso8601--duration-week-match
iso8601--duration-combined-match)))
;; "Z" dnd "z" are standard time; nil and [-+][0-9][0-9]... are local time
;; with unknown DST.
(defun iso8601--zone-dst (zone)
(if (= (length zone) 1) nil -1))
(defun iso8601-parse (string &optional form)
"Parse an ISO 8601 date/time string and return a `decode-time' structure.
The ISO 8601 date/time strings look like \"2008-03-02T13:47:30\"
or \"2024-04-05T14:30Z\" or \"2024-04-05T12:30−02:00\",
but shorter, incomplete strings like \"2008-03-02\" are valid, as
well as variants like \"2008W32\" (week number) and
\"2008-234\" (ordinal day number).
Note that, unlike `decode-time', this function does not interpret
the time string, and in particular the time-zone designator or UTC
offset that is part of STRING does not affect the returned value of
date and time, it only affects the last two members of the returned
value. This function simply parses the textual representation of
date and time into separate numerical values, and doesn't care
whether the time is local or UTC.
See `decode-time' for the meaning of FORM."
(if (not (iso8601-valid-p string))
(signal 'wrong-type-argument (list string))
(let* ((date-string (match-string 1 string))
(time-string (match-string 2 string))
(zone-string (match-string 3 string))
(date (iso8601-parse-date date-string)))
;; The time portion is optional.
(when time-string
(let ((time (iso8601-parse-time time-string form)))
(setf (decoded-time-hour date) (decoded-time-hour time))
(setf (decoded-time-minute date) (decoded-time-minute time))
(setf (decoded-time-second date) (decoded-time-second time))))
;; The time zone is optional.
(when zone-string
(setf (decoded-time-zone date)
;; The time zone in decoded times are in seconds.
(* (iso8601-parse-zone zone-string) 60))
(setf (decoded-time-dst date) (iso8601--zone-dst zone-string)))
date)))
(defun iso8601-parse-date (string)
"Parse STRING (in ISO 8601 format) and return a `decode-time' value."
(cond
;; Just a year: [+-]YYYY.
((iso8601--match iso8601--year-match string)
(iso8601--decoded-time
:year (string-to-number string)))
;; Calendar dates: YYYY-MM-DD and variants.
((iso8601--match iso8601--full-date-match string)
(iso8601--decoded-time
:year (string-to-number (match-string 1 string))
:month (match-string 2 string)
:day (match-string 3 string)))
;; Calendar date without day: YYYY-MM.
((iso8601--match iso8601--without-day-match string)
(iso8601--decoded-time
:year (string-to-number string)
:month (match-string 2 string)))
;; Outdated date without year: --MM-DD
((iso8601--match iso8601--outdated-date-match string)
(iso8601--decoded-time
:month (match-string 1 string)
:day (match-string 2 string)))
;; Week dates: YYYY-Www-D
((iso8601--match iso8601--week-date-match string)
(let* ((year (string-to-number string))
(week (string-to-number (match-string 2 string)))
(day-of-week (and (match-string 3 string)
(string-to-number (match-string 3 string))))
(jan-start (decoded-time-weekday
(decode-time
(iso8601--encode-time
(iso8601--decoded-time :year year
:month 1
:day 4)))))
(correction (+ (if (zerop jan-start) 7 jan-start)
3))
(ordinal (+ (* week 7) (or day-of-week 0) (- correction))))
(cond
;; Monday 29 December 2008 is written "2009-W01-1".
((< ordinal 1)
(setq year (1- year)
ordinal (+ ordinal (if (date-leap-year-p year)
366 365))))
;; Sunday 3 January 2010 is written "2009-W53-7".
((> ordinal (if (date-leap-year-p year)
366 365))
(setq ordinal (- ordinal (if (date-leap-year-p year)
366 365))
year (1+ year))))
(let ((month-day (date-ordinal-to-time year ordinal)))
(iso8601--decoded-time :year year
:month (decoded-time-month month-day)
:day (decoded-time-day month-day)))))
;; Ordinal dates: YYYY-DDD
((iso8601--match iso8601--ordinal-date-match string)
(let* ((year (string-to-number (match-string 1 string)))
(ordinal (string-to-number (match-string 2 string)))
(month-day (date-ordinal-to-time year ordinal)))
(iso8601--decoded-time :year year
:month (decoded-time-month month-day)
:day (decoded-time-day month-day))))
;; Obsolete format with implied year: --MM
((iso8601--match "--\\([0-9][0-9]\\)" string)
(iso8601--decoded-time :month (string-to-number (match-string 1 string))))
;; Obsolete format with implied year and month: ---DD
((iso8601--match "---\\([0-9][0-9]\\)" string)
(iso8601--decoded-time :day (string-to-number (match-string 1 string))))
(t
(signal 'wrong-type-argument (list string)))))
(defun iso8601-parse-time (string &optional form)
"Parse STRING, which should be an ISO 8601 time string.
The return value will be a `decode-time' structure with just the
hour/minute/seconds/zone fields filled in.
See `decode-time' for the meaning of FORM."
(if (not (iso8601--match iso8601--full-time-match string))
(signal 'wrong-type-argument (list string))
(let ((time (match-string 1 string))
(zone (match-string 2 string)))
(if (not (iso8601--match iso8601--time-match time))
(signal 'wrong-type-argument (list string))
(let ((hour (string-to-number (match-string 1 time)))
(minute (and (match-string 2 time)
(string-to-number (match-string 2 time))))
(second (and (match-string 3 time)
(string-to-number (match-string 3 time))))
(frac-string (match-string 4 time))
fraction fraction-precision)
(when frac-string
;; Remove trailing zeroes.
(setq frac-string (replace-regexp-in-string "0+\\'" "" frac-string))
(when (length> frac-string 0)
(setq fraction (string-to-number frac-string)
fraction-precision (length frac-string))))
(when (and fraction
(eq form t))
(cond
;; Sub-second time.
(second
(setq second (cons (+ (* second (expt 10 fraction-precision))
fraction)
(expt 10 fraction-precision))))
;; Fractional minute.
(minute
(setq second (iso8601--decimalize fraction 60)))
(hour
;; Fractional hour.
(setq minute (iso8601--decimalize fraction 60)))))
(iso8601--decoded-time :hour hour
:minute (or minute 0)
:second (or second 0)
:dst (iso8601--zone-dst zone)
:zone (and zone
(* 60 (iso8601-parse-zone
zone)))))))))
(defun iso8601--decimalize (fraction base)
(round (* base (/ (float fraction)
(expt 10 (1+ (truncate (log fraction 10))))))))
(defun iso8601-parse-zone (string)
"Parse STRING, which should be an ISO 8601 time zone.
Return the number of minutes."
(if (not (iso8601--match iso8601--zone-match string))
(signal 'wrong-type-argument (list string))
(if (match-string 2 string)
;; HH:MM-ish.
(let ((hour (string-to-number (match-string 3 string)))
(minute (and (match-string 4 string)
(string-to-number (match-string 4 string)))))
(* (if (equal (match-string 2 string) "-")
-1
1)
(+ (* hour 60)
(or minute 0))))
;; "Z".
0)))
(defun iso8601-valid-p (string)
"Say whether STRING is a valid ISO 8601 representation."
(iso8601--match iso8601--combined-match string))
(defun iso8601-parse-duration (string)
"Parse ISO 8601 durations on the form P3Y6M4DT12H30M5S."
(cond
((and (iso8601--match iso8601--duration-full-match string)
;; Just a "P" isn't valid; there has to be at least one
;; element, like P1M.
(> (length (match-string 0 string)) 2))
(iso8601--decoded-time :year (or (match-string 1 string) 0)
:month (or (match-string 2 string) 0)
:day (or (match-string 3 string) 0)
:hour (or (match-string 5 string) 0)
:minute (or (match-string 6 string) 0)
:second (or (match-string 7 string) 0)))
;; PnW: Weeks.
((iso8601--match iso8601--duration-week-match string)
(let ((weeks (string-to-number (match-string 1 string))))
;; Does this make sense? Hm...
(iso8601--decoded-time :day (* weeks 7))))
;; P<date>T<time>
((iso8601--match iso8601--duration-combined-match string)
(iso8601-parse (substring string 1)))
(t
(signal 'wrong-type-argument (list string)))))
(defun iso8601-parse-interval (string)
"Parse ISO 8601 intervals."
(let ((bits (split-string string "/"))
start end duration)
(if (not (= (length bits) 2))
(signal 'wrong-type-argument (list string))
;; The intervals may be an explicit start/end times, or either a
;; start or an end, and an accompanying duration.
(cond
((and (string-match "\\`P" (car bits))
(iso8601-valid-p (cadr bits)))
(setq duration (iso8601-parse-duration (car bits))
end (iso8601-parse (cadr bits))))
((and (string-match "\\`P" (cadr bits))
(iso8601-valid-p (car bits)))
(setq duration (iso8601-parse-duration (cadr bits))
start (iso8601-parse (car bits))))
((and (iso8601-valid-p (car bits))
(iso8601-valid-p (cadr bits)))
(setq start (iso8601-parse (car bits))
end (iso8601-parse (cadr bits))))
(t
(signal 'wrong-type-argument (list string)))))
(unless end
(setq end (decoded-time-add start duration)))
(unless start
(setq start (decoded-time-add end
;; We negate the duration so that
;; we get a subtraction.
(mapcar (lambda (elem)
(if (numberp elem)
(- elem)
elem))
duration))))
(list start end
(or duration
;; FIXME: Support subseconds.
;; FIXME: It makes no sense to decode a time difference
;; according to (decoded-time-zone end), or according to
;; any other time zone for that matter.
(decode-time (time-subtract (iso8601--encode-time end)
(iso8601--encode-time start))
(or (decoded-time-zone end) 0) 'integer)))))
(defun iso8601--match (regexp string)
(string-match (concat "\\`" regexp "\\'") string))
(defun iso8601--value (elem &optional default)
(if (stringp elem)
(string-to-number elem)
(or elem default)))
(cl-defun iso8601--decoded-time (&key second minute hour
day month year
(dst -1) zone)
(list (iso8601--value second)
(iso8601--value minute)
(iso8601--value hour)
(iso8601--value day)
(iso8601--value month)
(iso8601--value year)
nil
dst
zone))
(defun iso8601--encode-time (time)
"Like `encode-time', but fill in nil values in TIME."
(encode-time (decoded-time-set-defaults (copy-sequence time))))
(provide 'iso8601)
;;; iso8601.el ends here
|