diff --git a/cl-dates.asd b/cl-dates.asd new file mode 100644 index 0000000..bf46495 --- /dev/null +++ b/cl-dates.asd @@ -0,0 +1,60 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-user) + +(defpackage :cl-dates + (:use :cl :asdf)) + +(in-package :cl-dates) + +(defsystem :cl-dates + :version "0.7" + :description "Date-time library for Common Lisp" + :author "Sudhir Shenoy" + :license "BSD" + :serial t + :components ((:file "packages") + (:file "util") + (:file "timezones") + (:file "dates") + (:file "parse-date") + (:file "print-date") + (:file "date-arith"))) + +(defsystem :cl-dates-test + :description "Date-time library tests" + :author "Sudhir Shenoy" + :license "BSD" + :depends-on (:cl-dates) + :serial t + :components ((:file "test-main") + (:file "test-dates") + (:file "test-parse-date"))) + +(defmethod perform ((o test-op) (c (eql (find-system :cl-dates)))) + (operate 'load-op :cl-dates-test) + (funcall (intern (symbol-name :run-all-tests) (find-package :cl-dates-test)))) diff --git a/date-arith.lisp b/date-arith.lisp new file mode 100644 index 0000000..56da85b --- /dev/null +++ b/date-arith.lisp @@ -0,0 +1,97 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +;; Date comparisons - strip off time component +(defun date= (&rest dates) + (every (lambda (a b) (= (jday-number a) (jday-number b))) dates (cdr dates))) + +(defun date/= (&rest dates) + (notevery (lambda (a b) (= (jday-number a) (jday-number b))) dates (cdr dates))) + +(defun date> (&rest dates) + (every (lambda (a b) (> (jday-number a) (jday-number b))) dates (cdr dates))) + +(defun date< (&rest dates) + (every (lambda (a b) (< (jday-number a) (jday-number b))) dates (cdr dates))) + +(defun date>= (&rest dates) + (every (lambda (a b) (>= (jday-number a) (jday-number b))) dates (cdr dates))) + +(defun date<= (&rest dates) + (every (lambda (a b) (<= (jday-number a) (jday-number b))) dates (cdr dates))) + +;; Date arithmetic + +(defun date+ (date days) + "Advance date by given number of days" + (+ date days)) + +(defun date- (date days) + "Retreat date by given number of days" + (- date days)) + +(defun date-diff (dt1 dt2) + "Return (positive) number of days between two dates" + (abs (- (jday-number dt1) (jday-number dt2)))) + +(defun nth-day-of-week (date dow n) + "Returns the nth day of the week e.g., second Saturday of the month in which date falls. +If n is large enough to make the date fall in a future month, the last valid day in +the month is returned." + (multiple-value-bind (yy mm dd h m s) (date->ymd date) + (declare (ignore dd)) + (let ((dt (loop for dd = (ymd->date yy mm 1 h m s) then (1+ dd) + until (eq dow (day-of-week dd)) + finally (return dd)))) + (if (< n 2) + dt + (dotimes (i (1- n) dt) + (let ((next-dt (+ 7 dt))) + (multiple-value-bind (yy1 mm1 dd1 h1 m1 s1) (date->ymd next-dt) + (declare (ignore yy1 dd1 h1 m1 s1)) + (if (/= mm1 mm) + (return dt) + (setf dt next-dt))))))))) + +(defun first-of-next-month (date) + "Returns date for 1st of the following month" + (multiple-value-bind (yy mm dd h m s) (date->ymd date) + (declare (ignore dd)) + (if (= mm 12) + (ymd->date (1+ yy) 1 1 h m s) + (ymd->date yy (1+ m) 1 h m s)))) + +(defun last-day-of-month (date) + "Returns last day in curent month" + (1- (first-of-next-month date))) + +(defun last-day-of-prev-month (date) + "Returns last day of previous month" + (multiple-value-bind (yy mm dd h m s) (date->ymd date) + (declare (ignore dd)) + (1- (ymd->date yy mm 1 h m s)))) diff --git a/dates.lisp b/dates.lisp new file mode 100644 index 0000000..ef3ee4a --- /dev/null +++ b/dates.lisp @@ -0,0 +1,283 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +(defun jday-number (date) + "Returns the Julian day number for the given Julian date" + (floor (+ date 1/2))) + +(defparameter +days-of-week+ #(:monday :tuesday :wednesday :thursday :friday :saturday :sunday)) +(defun day-of-week (date) + "Returns the day of week on which the given Julian date falls" + (aref +days-of-week+ (mod (jday-number date) 7))) + +(defun ymd->date (yy mm dd &optional (hour 0) (min 0) (sec 0) zone) + "Return the Julian date corresponding to the given date and time. No +compensation is made for the Gregorian Calendar introduction. +Note that the Julian date is an integer when the time is noon so a +date without time (midnight) will have a fractional part of 0.5. + +If timezone is specified, it should be either an alphabetic code e.g., \"IST\" +or a numeric offset in fractions of an hour e.g., +5.5" + (let* ((a (floor (* 1/12 (- 14 mm)))) + (b (- yy a)) + (c (floor (/ b 100))) + (day-frac (hms->day-fraction hour min sec)) + (jdate (+ (floor (* 30.6001d0 (+ (* a 12) mm 1))) + (- (floor (* 365.25d0 (+ b 4716))) 1524) + (floor (/ c 4)) + (- c) + 2 + dd + (- day-frac 0.5d0))) + (offset (zone-to-offset zone))) + (- jdate (/ offset 24)))) + +(defun date->ymd (date) + "Returns 6 values corresponding to the given datetime- +year, month, day, hour, minute, second. Second may be a floating +point value but the first five are aways integers" + (let* ((jd (jday-number date)) + (e (floor (/ (- jd 1867216.25d0) 36524.25d0))) + (f (+ 1 jd e (- (floor (/ e 4))))) + (g (+ f 1524)) + (h (floor (/ (- g 122.1d0) 365.25d0))) + (i (floor (* 365.25d0 h))) + (j (floor (/ (- g i) 30.6001d0))) + (dd (- g i (floor (* j 30.6001d0)))) + (mm (- j (* 12 (floor (/ j 14))) 1)) + (yy (+ h (floor (* 1/12 (- 14 mm))) -4716)) + (day-frac (multiple-value-bind (day frac) (truncate date) + (declare (ignore day)) + (if (>= frac 0.5d0) + ;; midnight to noon + (- frac 0.5d0) + (+ frac 0.5d0))))) + (multiple-value-bind (h m s) (day-fraction->hms day-frac) + (values yy mm dd h m s)))) + +(defun valid-date-p (yy mm dd) + "Check that year, month and day form a valid calendar date" + (multiple-value-bind (y m d hr mn sc) (date->ymd (ymd->date yy mm dd)) + (declare (ignore hr mn sc)) + (and (= yy y) (= mm m) (= dd d)))) + +(defun valid-time-p (h m s) + "Check that hour, minute and second are valid numbers" + (and (<= 0 h 23) (<= 0 m 59) (>= s 0) (< s 60))) + +(defun todays-date () + "Return current date-time (UTC)" + (multiple-value-bind (s m h dd mm yy dw dst zone) + (decode-universal-time (get-universal-time) 0) + (declare (ignore h m s dw dst zone)) + (ymd->date yy mm dd))) + +(defun todays-datetime (&optional zone) + "Return current date-time (UTC)" + (let ((offset (zone-to-offset zone))) + (multiple-value-bind (s m h dd mm yy dw dst zone) + (decode-universal-time (get-universal-time) 0) + (declare (ignore dw dst zone)) + (- (ymd->date yy mm dd h m s) (/ offset 24))))) + +(defun date->javascript-time (date) + "Convert a datetime to the corresponding time value in Javascript" + (let ((days (- date 2440587.5d0))) ; days since 1-Jan-1970 + (truncate (* days 86400 1000)))) ; number of milliseconds + +(defun easter-day (yy &optional (want-gregorian-date nil)) + "Returns the date for Easter Sunday in the given year. +Accurate until approx. 4000 CE +Returns a Julian date if want-gregorian-date is NIL. Otherwise, +it returns year, month, day, hour, minute and second as 6 values" + (let* ((century (floor (/ yy 100))) + (remain-19 (mod yy 19)) + (temp (+ (floor (* 1/2 (- century 15))) + 202 + (* -11 remain-19)))) + ;; calculate date of Paschal moon + (cond ((member century '(21 24 25 27 28 29 30 31 32 34 35 38)) + (decf temp)) + ((member century '(33 36 37 39 40)) + (decf temp 2))) + (setf temp (mod temp 30)) + (let ((temp-a (+ temp 21))) + (when (or (= 29 temp) + (and (= 28 temp) (> remain-19 10))) + (decf temp-a)) + ;; find next Sunday + (let* ((temp-b (mod (- temp-a 19) 7)) + (temp-c (mod (- 40 century) 4))) + (when (= temp-c 3) + (incf temp-c)) + (when (> temp-c 1) + (incf temp-c)) + (setf temp (mod yy 100)) + (let* ((temp-d (mod (+ temp (floor (/ temp 4))) 7)) + (temp-e (1+ (mod (- 20 temp-b temp-c temp-d) 7))) + (dd (+ temp-a temp-e)) + (mm 3)) + (when (> dd 31) + (setf dd (- dd 31) + mm 4)) + (if want-gregorian-date + (values yy mm dd) + (ymd->date yy mm dd))))))) + +(defun vernal-equinox (yy &optional (want-gregorian-date nil)) + "Return UTC date-time of the vernal (spring) equinox for the given year. +Returns a Julian date if want-gregorian-date is NIL. Otherwise, +it returns year, month, day, hour, minute and second as 6 values" + (calc-equinox-or-solstice-date 1 yy want-gregorian-date)) +(defun summer-solstice (yy &optional (want-gregorian-date nil)) + "Return UTC date-time of the summer solstice for the given year. +Returns a Julian date if want-gregorian-date is NIL. Otherwise, +it returns year, month, day, hour, minute and second as 6 values" + (calc-equinox-or-solstice-date 2 yy want-gregorian-date)) +(defun autumnal-equinox (yy &optional (want-gregorian-date nil)) + "Return UTC date-time of the autumnal equinox for the given year. +Returns a Julian date if want-gregorian-date is NIL. Otherwise, +it returns year, month, day, hour, minute and second as 6 values" + (calc-equinox-or-solstice-date 3 yy want-gregorian-date)) +(defun winter-solstice (yy &optional (want-gregorian-date nil)) + "Return UTC date-time of the winter solstice for the given year. +Returns a Julian date if want-gregorian-date is NIL. Otherwise, +it returns year, month, day, hour, minute and second as 6 values" + (calc-equinox-or-solstice-date 4 yy want-gregorian-date)) + +;; Formulae are from Chapter 27 of Jan Meeus' Astronomical Algorithms +;; Valid for years between 1000-3000 +;; Accurate to within a minute from 1950-2050 +(defun calc-equinox-or-solstice-date (which yy want-gregorian-date) + (let* ((mf (/ (- yy 2000) 1000)) + (date (cond ((= which 1) (+ 2451623.80984d0 + (* mf 365242.37404d0) + (* mf mf 0.05169d0) + (* mf mf mf -0.00411d0) + (* mf mf mf mf -0.00057d0))) + ((= which 2) (+ 2451716.56767d0 + (* mf 365241.62603d0) + (* mf mf 0.00325d0) + (* mf mf mf 0.00888d0) + (* mf mf mf mf -0.00030d0))) + ((= which 3) (+ 2451810.21715d0 + (* mf 365242.01767d0) + (* mf mf -0.11575d0) + (* mf mf mf -0.00337d0) + (* mf mf mf mf 0.00078d0))) + ((= which 4) (+ 2451900.05952d0 + (* mf 365242.74049d0) + (* mf mf -0.06223d0) + (* mf mf mf -0.00823d0) + (* mf mf mf mf 0.00032d0))) + (t (error "Invalid param which: ~a" which))))) + (setf date (apply-correction-factors yy date)) + (if want-gregorian-date + (date->ymd date) + date))) + +(defun cos-degrees (deg) + (cos (/ (* deg pi) 180d0))) + +;; Jan Meeus' Astronomical Algorithms, Chapter 27 +(defun apply-correction-factors (yy jd) + (let* ((t0 (/ (- jd 2451545d0) 36525d0)) + (w (- (* 35999.373d0 t0) 2.47d0)) + (dl (+ 1.0 (* 0.0334d0 (cos-degrees w)) + (* 0.0007d0 (cos-degrees (* 2.0d0 w))))) + (s (periodic-24 t0))) + ;; get correct time in TDT (terrestrial dynamic time) + (incf jd (/ (* 0.00001d0 s) dl)) + ;; apply correction TDT -> UTC + (tdt-to-utc yy jd))) + +;; Jan Meeus' Astronomical Algorithms, Chapter 27 +(defparameter +periodic-24-a+ #(485d0 203d0 199d0 182d0 156d0 136d0 77d0 74d0 70d0 + 58d0 52d0 50d0 45d0 44d0 29d0 18d0 17d0 16d0 14d0 + 12d0 12d0 12d0 9d0 8d0)) +(defparameter +periodic-24-b+ #(324.96d0 337.23d0 342.08d0 27.85d0 73.14d0 171.52d0 + 222.54d0 296.72d0 243.58d0 119.81d0 297.17d0 21.02d0 + 247.54d0 325.15d0 60.93d0 155.12d0 288.79d0 198.04d0 + 199.76d0 95.39d0 287.11d0 320.81d0 227.73d0 15.45d0)) +(defparameter +periodic-24-c+ #(1934.136d0 32964.467d0 20.186d0 445267.112d0 45036.886d0 + 22518.443d0 65928.934d0 3034.906d0 9037.513d0 33718.147d0 + 150.678d0 2281.226d0 29929.562d0 31555.956d0 4443.417d0 + 67555.328d0 4562.452d0 62894.029d0 31436.921d0 14577.848d0 + 31931.756d0 34777.259d0 1222.114d0 16859.074d0)) +(defun periodic-24 (t0) + (loop for i from 0 below 24 + summing (* (aref +periodic-24-a+ i) + (cos-degrees (+ (aref +periodic-24-b+ i) + (* t0 (aref +periodic-24-c+ i))))))) + +;; TDT -> UTC conversion: from Meeus' Astronomical Algorithms, Chapter 10 +;; Applies affsets in seconds to convert from Terrestrial Dynamic Time to UTC +;; +;; Offsets are directly available for even-numbered years between 1620-2002. +;; Offsets for odd-numbered years are linearly interpolated. +;; Offsets for years before 1620 and after 2002 (upto 2100) are obtained by +;; applying interpolation formulae. +;; 2000 and 2002 year data is from NASA and others from Meeus. +(defparameter +tdt-offsets+ + #(121 112 103 95 88 82 77 72 68 63 60 56 53 51 48 46 44 42 40 38 ; 1620-1658 + 35 33 31 29 26 24 22 20 18 16 14 12 11 10 9 8 7 7 7 7 ; 1660-1698 + 7 7 8 8 9 9 9 9 9 10 10 10 10 10 10 10 10 11 11 11 ; 1700-1738 + 11 11 12 12 12 12 13 13 13 14 14 14 14 15 15 15 15 15 16 16 ; 1740-1778 + 16 16 16 16 16 16 15 15 14 13 13.1 12.5 12.2 12 12 12 12 12 12 11.9 ; 1780-1818 + 11.6 11 10.2 9.2 8.2 7.1 6.2 5.6 5.4 5.3 5.4 5.6 5.9 6.2 6.5 ; 1820-1848 + 6.8 7.1 7.3 7.5 7.6 7.7 7.3 6.2 5.2 2.7 1.4 -1.2 -2.8 -3.8 -4.8 ; 1850-1878 + -5.5 -5.3 -5.6 -5.7 -5.9 -6 -6.3 -6.5 -6.2 -4.7 -2.8 -0.1 2.6 5.3 7.7 ; 1880-1908 + 10.4 13.3 16 18.2 20.2 21.1 22.4 23.5 23.8 24.3 24 23.9 23.9 23.7 24 ; 1910-1938 + 24.3 25.3 26.2 27.3 28.2 29.1 30 30.7 31.4 32.2 33.1 34 35 36.5 38.3 ; 1940-1968 + 40.2 42.2 44.5 46.5 48.5 50.5 52.5 53.8 54.9 55.8 56.9 58.3 60 61.6 63 ; 1970-1998 + 63.8 64.3)) ; 2000-2002 +(defparameter +offsets-first-year+ 1620) +(defparameter +offsets-last-year+ (+ +offsets-first-year+ + (* 2 (1- (length +tdt-offsets+))))) +(defun tdt-to-utc (yy jd) + (let* ((yt (/ (- yy 2000) 100d0)) ; centuries from epoch + (delta-t ; calculated offset in seconds + (cond ((<= +offsets-first-year+ yy +offsets-last-year+) + ;; correction directly available from table + (if (evenp yy) + ;; lookup directly + (aref +tdt-offsets+ (* 1/2 (- yy +offsets-first-year+))) + ;; interpolate + (* 1/2 (+ (aref +tdt-offsets+ (* 1/2 (- (1+ yy) +offsets-first-year+))) + (aref +tdt-offsets+ (* 1/2 (- (1- yy) +offsets-first-year+))))))) + ((< yy 948) + (+ 2177d0 (* 497d0 yt) (* 44.1d0 yt yt))) + ((< yy 947 +offsets-first-year+) + (+ 102d0 (* 102d0 yt) (* 25.3d0 yt yt))) + ((<= 2000 yy 2100) + (+ 102d0 (* 102d0 yt) (* 25.3d0 yt yt) + (* 0.37d0 (- yy 2100)))) + ;; no adjustment available for years later than 2100 + (t 0)))) + ;; decrement date by offset seconds (as a fraction of a day) + (decf jd (hms->day-fraction 0 0 delta-t)))) diff --git a/packages.lisp b/packages.lisp new file mode 100644 index 0000000..a7bca02 --- /dev/null +++ b/packages.lisp @@ -0,0 +1,78 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-user) + +(defpackage :cl-dates + (:nicknames :dt) + (:use :common-lisp) + (:export + ;; Make a new date + :ymd->date ; date from y/m/d/h/m/s components + :string->date ; date from string + :todays-date ; system date + :todays-datetime ; system date-time + ;; Date converters + :date->ymd ; to components + :date->string ; to string + :date->long-string ; to string (verbose) + :date->javascript-time ; to JS datetime + :date->local-time ; to local time zone + :month->string ; full name of month + :dow->string ; day of week as string + ;; Special dates for given year + :easter-day ; easter day + :vernal-equinox ; spring equinox date-time + :summer-solstice ; summer solstice date-time + :autumnal-equinox ; autumn equinox date-time + :winter-solstice ; winter solstice date-time + ;; Miscellaneous functions + :valid-date-p + :valid-time-p + :jday-number ; Julian day number + :day-of-week ; Day of week for date + ;; Comparisons + :date= :date/= + :date< :date<= + :date> :date>= + ;; Calendar date arithmetic + :date+ :date- ; add/subtract days to date + :date-diff ; days between two dates + :nth-day-of-week ; 'n' weeks offset from start of month + :first-of-next-month ; first day of next month + :last-day-of-month ; last calendar day in month + :last-day-of-prev-month ; last calendar day in previous month + ;; Business date arithmetic + ;; :previous-workday ; last working day before date (which can be a holiday) + ;; :next-workday ; next working day after date (which can be a holiday) + ;; :bdate+ :bdate- ; add/subtract working days to date + ;; :add-calendar-months ; add/subtract months obeying day count conventions + ;; :add-months ; add/subtract months obeying day count conventions + ;; :last-workday-of-month ; Last working day in month + ;; :generate-date-series ; Series of coupon dates following day conventions + )) + + diff --git a/parse-date.lisp b/parse-date.lisp new file mode 100644 index 0000000..bbe8801 --- /dev/null +++ b/parse-date.lisp @@ -0,0 +1,592 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +(defparameter +white-space+ '(#\space #\tab #\return #\linefeed #\newline)) +(defun white-space-p (char) + (member char +white-space+ :test #'char=)) + +(defun tokenize (str) + (let ((token-list nil) + (state nil) + (token nil) + (seen-alpha nil)) + (labels ((split-list (test list &aux (start list) (end list)) + (loop while (and end (setq start (member-if-not test end))) + collect (ldiff start (setq end (member-if test start))))) + (emit-token () + (if (and (member state '(:in-alpha-num :in-num-alpha)) + (or seen-alpha + (> (count #\. token) 1) + (char= #\. (car token))) + ;; ugly special casing for a.m./p.m. strings + (not (equal token '(#\. #\m #\. #\a))) + (not (equal token '(#\m #\. #\a))) + (not (equal token '(#\. #\m #\. #\p))) + (not (equal token '(#\m #\. #\p)))) + (progn + ;; separate "23.jun.2017" => "23" "." "jun" "." "2017" + ;; any trailing period will be dropped + (setf token (nreverse token)) + (loop for tk on (split-list #'(lambda(x) (char= x #\.)) token) + do (progn + (push (coerce (car tk) 'string) token-list) + (unless (null (cdr tk)) + (push "." token-list))))) + (push (coerce (nreverse token) 'string) token-list)) + ;; reset state + (setf state nil seen-alpha nil token nil))) + (do ((i 0 (1+ i)) + (len-str (length str))) + ((>= i len-str)) + (let ((ch (schar str i))) + (case state + ((nil) + (push ch token) + (setf state (cond ((alpha-char-p ch) :in-alpha) + ((digit-char-p ch) :in-num) + ((white-space-p ch) :in-space) + (t :in-separators)))) + (:in-separators + (if (or (alphanumericp ch) (white-space-p ch)) + (progn + (decf i) + (emit-token)) + (push ch token))) + (:in-space + (when (not (white-space-p ch)) + ;; throw away white space and reset state + (decf i) + (setf token nil state nil))) + (:in-alpha + (setf seen-alpha t) + (cond ((alpha-char-p ch) (push ch token)) + ((char= ch #\.) + (push ch token) + (setf state :in-alpha-num)) + (t (decf i) + (emit-token)))) + (:in-num + (cond ((digit-char-p ch) (push ch token)) + ((char= ch #\.) + (push ch token) + (setf state :in-num-alpha)) + (t (decf i) + (emit-token)))) + (:in-alpha-num + (setf seen-alpha t) + (cond ((or (alpha-char-p ch) (char= ch #\.)) (push ch token)) + ((and (digit-char-p ch) token (char= #\. (car token))) + (push ch token) + (setf state :in-num-alpha)) + (t (decf i) + (emit-token)))) + (:in-num-alpha + (cond ((or (digit-char-p ch) (char= ch #\.)) (push ch token)) + ((and (alpha-char-p ch) token (char= #\. (car token))) + (push ch token) + (setf state :in-alpha-num)) + (t(decf i) + (emit-token))))))) + (when (and token (not (member state '(:in-space :in-separators)))) + (emit-token))) + (nreverse token-list))) + +(defun advance-date (dt dow dir) + (if (eq dir :closest) + (let ((next (advance-date dt dow :next)) + (prev (advance-date dt dow :prev))) + (if (< (- dt prev) (- next dt)) + prev + next)) + (let ((inc (if (eq dir :next) 1 -1))) + (loop + (if (eq (day-of-week dt) dow) + (return dt) + (incf dt inc)))))) + +(defun make-four-digit-year (year) + (if (> year 99) + year + (multiple-value-bind (s m h dd mm yy dw dst zone) + (decode-universal-time (get-universal-time) 0) + (declare (ignore s m h dd mm dw dst zone)) + (let ((century (* 100 (truncate (/ yy 100))))) + (incf year century) + (if (>= (abs (- year yy)) 50) + (if (< year yy) + (+ year 100) + (- year 100)) + year))))) + +;; decide between yy/mm and mm/yy +(defun assign-yy-mm (list precedence) + (cond ((> (car list) 12) (values (car list) (cadr list))) + ((> (cadr list) 12) (values (cadr list) (car list))) + ((eq precedence :ymd) (values (car list) (cadr list))) + (t (values (cadr list) (car list))))) + +;; decide between yy/dd and dd/yy +(defun assign-yy-dd (list precedence) + (cond ((> (car list) 31) (values (car list) (cadr list))) + ((> (cadr list) 31) (values (cadr list) (car list))) + ((eq precedence :ymd) (values (car list) (cadr list))) + (t (values (cadr list) (car list))))) + +;; decide between dd/mm and mm/dd +(defun assign-mm-dd (list precedence) + (cond ((> (car list) 12) (values (cadr list) (car list))) + ((> (cadr list) 12) (values (car list) (cadr list))) + ((or (eq precedence :ymd) (eq precedence :mdy)) + (values (car list) (cadr list))) + (t (values (cadr list) (car list))))) + +;; decide order in which day, month and year appear given 2 or 3 numbers +(defun assign-yy-mm-dd (list precedence) + (if (= 2 (length list)) + (cond ((and (> (car list) 12) (> (cadr list) 12)) + (multiple-value-bind (yy dd) (assign-yy-dd list precedence) + (values yy nil dd))) + (t (multiple-value-bind (yy mm) (assign-yy-mm list precedence) + (values yy mm nil)))) + (let (tmp) + (cond ((every #'(lambda(x) (<= x 12)) list) + ;; 01/02/03 + (cond ((eq precedence :mdy) (values (third list) (first list) (second list))) + ((eq precedence :dmy) (values (third list) (second list) (first list))) + (t (values (first list) (second list) (third list))))) + ((setf tmp (find-if #'(lambda(x) (> x 31)) list)) + ;; 12/5/55 + (setf list (remove tmp list :count 1)) + (multiple-value-bind (mm dd) (assign-mm-dd list precedence) + (values tmp mm dd))) + ((= 1 (count-if #'(lambda(x) (<= x 12)) list)) + (setf tmp (find-if #'(lambda(x) (<= x 12)) list) + list (remove tmp list :count 1)) + (multiple-value-bind (yy dd) (assign-yy-dd list precedence) + (values yy tmp dd))) + (t ;; 5/6/27 + (cond ((eq precedence :ymd) + (multiple-value-bind (mm dd) (assign-mm-dd (cdr list) precedence) + (values (car list) mm dd))) + ((eq precedence :dmy) + (multiple-value-bind (yy mm) (assign-yy-mm (cdr list) precedence) + (values yy mm (car list)))) + (t (multiple-value-bind (yy dd) (assign-yy-dd (cdr list) precedence) + (values yy (car list) dd))))))))) + +;; struct to hold values during parse +(defstruct (date-components (:conc-name dt-)) + dow yr mth day hr min sec tz) + +(defparameter +date-separators+ '("." "/" "-")) + +(defun string->date (string &key (reference-date (todays-date)) (precedence :ymd)) + (setf string (string-downcase (string-trim +white-space+ string))) + (unless (member precedence '(:ymd :dmy :mdy)) + (error "invalid precedence ~a" precedence)) + (macrolet ((when-null-set (var field value) + `(when (null (,field ,var)) (setf (,field ,var) ,value)))) + (let* ((res (make-date-components)) + (tokens (tokenize string)) + (num-tok (length tokens)) + (skipped-tokens nil) ; tokens that were ignored + (relative-dow nil) ; when day of week is qualified by this/next/prev + (date-comps nil) ; uncertain whether day/month/year + (num-date-comps 0) ; length of date-comps + date components in res + (time-parsed nil)) ; whether all of h/m/s have been parsed + (do ((i 0 (1+ i))) + ((>= i num-tok)) + (let* ((token (elt tokens i)) + (len (length token)) + (next-tok (if (>= (1+ i) num-tok) nil (elt tokens (1+ i)))) + (prev-tok (if (> i 0) (elt tokens (1- i)) nil)) + (num (parse-number token)) + tmp) + (when (numberp num) + (cond + ;; e.g. yyyymmddThhmmss or yyyymmddThh:mm:ss + ((and (= num-date-comps 3) + (or (= len 2) (= len 4) (= len 6)) + prev-tok (string= prev-tok "t") + (null (find #\. token))) + ;; if colon separated time, only hour will be parsed + ;; cannot set time-parsed to T unless len > 2 + (setf (dt-hr res) (parse-integer (subseq token 0 2))) + (when (>= len 4) + (setf (dt-min res) (parse-integer (subseq token 2 4)) + time-parsed t)) + (when (= len 6) + (setf (dt-sec res) (parse-integer (subseq token 4))))) + ;; YYMMDD or HHMMSS + ((or (= len 6) + (and (> len 7) (setf tmp (position #\. token)) (= tmp 6))) + (if (and (= 0 num-date-comps) (= len 6) + (or (null prev-tok) (string/= prev-tok "t"))) + ;; date if not yet parsed and not explicitly marked as time + (let* ((yy (truncate (/ num 10000))) + (mm (truncate (- (/ num 100) (* yy 100)))) + (dd (truncate (- num (+ (* yy 10000) (* mm 100)))))) + (setf date-comps (list dd mm yy)) ; reverse order of specification + (incf num-date-comps 3)) + ;; time + (setf (dt-hr res) (parse-integer (subseq token 0 2)) + (dt-min res) (parse-integer (subseq token 2 4)) + (dt-sec res) (parse-number (subseq token 4)) + time-parsed t))) + ;; YYYYMMDD - only format for 8 digit date (ddmmyyyy must be e.g. dd/mm/yyyy) + ((= len 8) + (when (find #\. token) + ;; nnnn.nnn + (return-from string->date nil)) + (let* ((yy (truncate (/ num 10000))) + (mm (truncate (- (/ num 100) (* yy 100)))) + (dd (truncate (- num (+ (* yy 10000) (* mm 100)))))) + (setf (dt-yr res) yy (dt-mth res) mm (dt-day res) dd) + (incf num-date-comps 3))) + ;; YYYYMMDDHHMM[SS[.ss]] + ((or (= len 12) (= len 14) + (and (> len 15) (setf tmp (position #\. token)) (= tmp 14))) + (setf (dt-yr res) (parse-integer (subseq token 0 4)) + (dt-mth res) (parse-integer (subseq token 4 6)) + (dt-day res) (parse-integer (subseq token 6 8)) + (dt-hr res) (parse-integer (subseq token 8 10)) + (dt-min res) (parse-integer (subseq token 10 12))) + (incf num-date-comps 3) + (when (> len 12) + (setf (dt-sec res) (parse-number (subseq token 12)))) + (setf time-parsed t)) + ;; HH:MM:SS + ((or (and next-tok (string= next-tok ":")) + (and (not (null (dt-hr res))) prev-tok (string= prev-tok ":"))) + ;; distinguish case when coming here from yyyymmddThh:mm:ss + (unless (and prev-tok (not time-parsed) (string= prev-tok ":")) + (setf (dt-hr res) num) + (incf i 2)) ; advance pointer to minute + (setf num (parse-number (elt tokens i))) + (when (null num) + (return-from string->date nil)) + ;; also handle weirdness like hh:mm.ss + (multiple-value-bind (int frac) (truncate num) + (setf (dt-min res) int) + (when (> frac 0) + (setf (dt-sec res) (* 60 frac)) + (when (and (< (1+ i) num-tok) (string= (elt tokens (1+ i)) ":")) + ;; can't handle hh:mm.ss:wtf + (return-from string->date nil)))) + (when (and (< (1+ i) num-tok) (string= (elt tokens (1+ i)) ":")) + (incf i 2) ; position on second + ;; both hh:mm: and hh:mm:ss are acceptable + (when (< i num-tok) + (setf num (parse-number (elt tokens i))) + (when (null num) + (return-from string->date nil)) + (setf (dt-sec res) num)))) + ;; dd/mm/yy and variants + ((and next-tok (member next-tok +date-separators+ :test #'string=)) + (let ((sep next-tok) + (is-ymd nil)) + (if (> num 31) + ;; year - default to yymmdd format + (setf (dt-yr res) num + is-ymd t) + (push num date-comps)) + (incf num-date-comps) + (incf i 2) ; position on second component + (when (>= i num-tok) + ;; reject strings ending in trailing slash + (return-from string->date nil)) + (setf num (parse-number (elt tokens i))) + (if (numberp num) + (cond ((and is-ymd (<= num 12)) (setf (dt-mth res) num)) + (is-ymd (setf (dt-day res) num)) + (t (push num date-comps))) + (progn + (setf tmp (str-to-month (elt tokens i))) + (if tmp + (setf (dt-mth res) tmp) + ;; not number or month name + (return-from string->date nil)))) + (incf num-date-comps) + (when (and (< (1+ i) num-tok) (string= sep (elt tokens (1+ i)))) + (incf i 2) ; position on last component + (when (>= i num-tok) + (return-from string->date nil)) ; trailing slash + (setf num (parse-number (elt tokens i))) + (when (null num) + ;; month name in last position is not accepted + (return-from string->date nil)) + (cond ((and is-ymd (not (null (dt-day res)))) (setf (dt-mth res) num)) + (is-ymd (setf (dt-day res) num)) + (t (push num date-comps))) + (incf num-date-comps)))) + ;; 2nd, 3rd, 21st etc. - parse as day + ((and next-tok (str-is-day-suffix next-tok)) + (incf i) ; skip suffix + (setf (dt-day res) num) + (incf num-date-comps)) + ;; 13h45m, 13h 45, etc - parse as time + ((and next-tok (setf tmp (str-to-hms next-tok))) + (loop + ;; consume following tokens as far as possible + (multiple-value-bind (int frac) (truncate num) + (cond ((eq tmp :hour) + (setf (dt-hr res) int) + (when (> frac 0) + (setf (dt-min res) (* 60 frac)))) + ((eq tmp :minute) + (setf (dt-min res) int) + (when (> frac 0) + (setf (dt-sec res) (* 60 frac)))) + ((eq tmp :second) + (setf (dt-sec res) num + time-parsed t)))) + (incf i) ; pointer is on suffix + (when (or (>= (1+ i) num-tok) (eq tmp :second)) + (return)) ; done + (setf num (parse-number (elt tokens (1+ i)))) + (when (null num) + (return)) + (incf i) ; pointer on number after time suffix + (if (or (>= (1+ i) num-tok) + (null (str-to-hms (elt tokens (1+ i))))) + ;; no time suffix - set to min/sec based on what prev token was + (progn + (if (eq tmp :hour) + (multiple-value-bind (int frac) (truncate num) + (setf (dt-min res) int) + (when (> frac 0) + (setf (dt-sec res) (* 60 frac)))) + (setf (dt-sec res) num)) + (setf time-parsed t) + (return)) + ;; set up var for next loop iteration + (setf tmp (str-to-hms (elt tokens (1+ i))))))) + ;; time with am/pm indicator e.g. 10 am + ((and next-tok (setf tmp (str-to-ampm next-tok))) + (when (> num 12) + (return-from string->date nil)) + (multiple-value-bind (int frac) (truncate num) + (if (and (< int 12) (eq tmp :pm)) + (incf int 12) + (when (and (= int 12) (eq tmp :am)) + (setf int 0))) + (setf (dt-hr res) int) + (when (> frac 0) + ;; 10.37 pm - frac is minutes not fraction of hour + (setf (dt-min res) frac + time-parsed t)))) + ;; all other numbers - assume it is a date component + (t (if (and prev-tok (string= prev-tok "of") (not (null (dt-mth res)))) + ;; june of 1976 + (setf (dt-yr res) num) + (push num date-comps)) + (incf num-date-comps))) + ;; avoid giant if-then-else + (go end-of-do-loop)) + ;; token is not a number + (cond + ;; weekday + ((setf tmp (str-to-weekday token)) (setf (dt-dow res) tmp)) + ;; relative day of week + ((setf tmp (str-to-relative-dow token)) (setf relative-dow tmp)) + ;; today / tomorrow etc + ((setf tmp (str-to-relative-date token)) + (when (/= 0 num-date-comps) + (return-from string->date nil)) + (multiple-value-bind (yy mm dd h m s) (date->ymd (+ reference-date tmp)) + (setf (dt-yr res) yy + (dt-mth res) mm + (dt-day res) dd + num-date-comps 3) + (when-null-set res dt-hr h) + (when-null-set res dt-min m) + (when-null-set res dt-sec s))) + ;; am/pm + ((setf tmp (str-to-ampm token)) + (let ((hr (dt-hr res))) + (when (null hr) + ;; am/pm not accepted before time string + (return-from string->date nil)) + (if (and (= hr 12) (eq tmp :am)) + (setf hr 0) + (when (and (< hr 12) (eq tmp :pm)) + (incf hr 12))) + (setf (dt-hr res) hr))) + ;; time zone name + ((or (and (> (length token) 2) (setf tmp (str-to-tz-offset token))) + ;; military time zone spec valid only at end of string + (and (<= (length token) 2) (null next-tok) (setf tmp (str-to-tz-offset token)))) + (when (null (dt-hr res)) + (return-from string->date nil)) + ;; if a time zone is specified as e.g. GMT+9 or JST-1, + ;; the next cond-clause for numeric offset will process it. + ;; offset is stored as fractions of a day since we add it to the julian date + (setf (dt-tz res) (/ tmp 24))) + ;; Numeric time zone offset: e.g., +0530 or +05:30 or -5 + ;; also handles GMT+9 etc in conjunction with previous clause + ((and (or (string= token "+") (string= token "-")) + next-tok (not (null (parse-number next-tok)))) + (when (null (dt-hr res)) + (return-from string->date nil)) + (let ((sign (if (string= token "+") 1 -1)) + offset-hrs) + (incf i) ; position on number + (cond + ;; e.g. 5:30 + ((and (< (1+ i) num-tok) (string= ":" (elt tokens (1+ i)))) + (setf offset-hrs (/ (parse-integer next-tok) 24)) + (incf i 2) ; position on minutes + (if (or (>= i num-tok) (null (setf tmp (parse-number (elt tokens i))))) + (return-from string->date nil) + (incf offset-hrs (/ tmp 1440)))) + ;; e.g., 3.5 or 11 + ((or (not (null (position #\. next-tok))) (< (length next-tok) 3)) + (setf offset-hrs (/ (parse-number next-tok) 24))) + ;; e.g., 0530 + ((= 4 (length next-tok)) + (setf offset-hrs (+ (/ (parse-integer (subseq next-tok 0 2)) 24) + (/ (parse-integer (subseq next-tok 2 4)) 1440)))) + ;; e.g., 530 + ((= 3 (length next-tok)) + (setf offset-hrs (+ (/ (parse-integer (subseq next-tok 0 1)) 24) + (/ (parse-integer (subseq next-tok 1 3)) 1440)))) + ;; all others are invalid + (t (return-from string->date nil))) + (setf offset-hrs (* sign offset-hrs)) + (if (null (dt-tz res)) + (setf (dt-tz res) offset-hrs) + (incf (dt-tz res) offset-hrs)))) + ;; Alphabetic month name + ((setf tmp (str-to-month token)) + (when (dt-mth res) + (return-from string->date nil)) ; duplicate spec + (setf (dt-mth res) tmp) + (incf num-date-comps) + (when (and next-tok (member next-tok +date-separators+ :test #'string=)) + ;; Jul/23/1956 + (let ((sep next-tok)) + (incf i 2) ; point to second component + (when (< i num-tok) + (setf num (parse-number (elt tokens i))) + (when (null num) + (return-from string->date nil)) + (incf num-date-comps) + (if (> num 31) + (setf (dt-yr res) num) ; clearly a year + (push num date-comps)) + (when (and (< (1+ i) num-tok) (string= sep (elt tokens (1+ i)))) + (incf i 2) ; point to third component + (setf num (parse-number (elt tokens i))) + (when (null num) + (return-from string->date nil)) + (incf num-date-comps) + (if (> num 31) + (if (dt-yr res) + ;; Jul/99/1985 + (return-from string->date nil) + ;; unambiguous date + (setf (dt-yr res) num + (dt-day res) (pop date-comps))) + (if (dt-yr res) + (setf (dt-day res) num) + (push num date-comps)))))))) + ;; default - skip token + (t (push token skipped-tokens)))) + end-of-do-loop) + (when (> num-date-comps 3) + (return-from string->date nil)) + (when (and (= num-date-comps 0) (dt-dow res) + (null relative-dow)) + ;; day of week without date is relative to reference date + (setf relative-dow :closest)) + (when date-comps + (setf date-comps (nreverse date-comps)) ; order of appearance + (let ((len (length date-comps))) + (cond ((or (= 3 num-date-comps len) + (= 2 num-date-comps len)) ; all uncertain + (multiple-value-bind (yy mm dd) (assign-yy-mm-dd date-comps precedence) + (setf (dt-yr res) yy (dt-mth res) mm (dt-day res) dd))) + ((and (= 3 num-date-comps) (= 2 len)) ; 2 of 3 are not certain + (cond ((dt-day res) (multiple-value-bind (yy mm) (assign-yy-mm date-comps precedence) + (setf (dt-yr res) yy (dt-mth res) mm))) + ((dt-mth res) (multiple-value-bind (yy dd) (assign-yy-dd date-comps precedence) + (setf (dt-yr res) yy (dt-day res) dd))) + ((dt-yr res) (multiple-value-bind (mm dd) (assign-mm-dd date-comps precedence) + (setf (dt-mth res) mm (dt-day res) dd))))) + ((and (= 3 num-date-comps) (= 1 len)) ; assign to null memeber in res + (when-null-set res dt-yr (car date-comps)) + (when-null-set res dt-mth (car date-comps)) + (when-null-set res dt-day (car date-comps))) + ((and (= 2 num-date-comps) (= 1 len)) + (cond ((dt-yr res) ;; dd or mm + (cond ((> (car date-comps) 12) (setf (dt-day res) (car date-comps))) + ((eq precedence :dmy) (setf (dt-day res) (car date-comps))) + (t (setf (dt-mth res) (car date-comps))))) + ((dt-mth res) ;; dd or yy + (if (> (car date-comps) 31) + (setf (dt-yr res) (car date-comps)) + (setf (dt-day res) (car date-comps)))) + ((dt-day res) ;; mm or yy + (if (> (car date-comps) 12) + (setf (dt-yr res) (car date-comps)) + (setf (dt-mth res) (car date-comps)))))) + (t ;; i.e., num-date-comps = 1 so one of dd or mm or yy + (cond ((> (car date-comps) 31) (setf (dt-yr res) (car date-comps))) + ((<= (car date-comps) 12) (setf (dt-mth res) (car date-comps))) + (t ;; between 12 and 31 + (setf (dt-day res) (car date-comps)))))))) + ;; check that we could assign all date components + (when (/= num-date-comps (+ (if (dt-yr res) 1 0) (if (dt-mth res) 1 0) (if (dt-day res) 1 0))) + ;; duplicate day / mth / year + (return-from string->date nil)) + ;; handle two-digit years + (when (dt-yr res) + (setf (dt-yr res) (make-four-digit-year (dt-yr res)))) + ;; copy all missing values from the reference date + (multiple-value-bind (yy mm dd h m s) (date->ymd reference-date) + (when-null-set res dt-yr yy) + (when-null-set res dt-mth mm) + (when-null-set res dt-day dd) + (when-null-set res dt-hr h) + (when-null-set res dt-min m) + (when-null-set res dt-sec s)) + (unless (and (valid-date-p (dt-yr res) (dt-mth res) (dt-day res)) + (valid-time-p (dt-hr res) (dt-min res) (dt-sec res))) + (return-from string->date nil)) + (let ((dt (ymd->date (dt-yr res) (dt-mth res) (dt-day res) + (dt-hr res) (dt-min res) (dt-sec res)))) + (if (and relative-dow (dt-dow res)) + (setf dt (advance-date dt (dt-dow res) relative-dow)) + (when (dt-dow res) + (when (and (= 3 num-date-comps) (not (eq (dt-dow res) (day-of-week dt)))) + ;; date was misparsed or day of week is not consistent with date + (return-from string->date nil)))) + (when (dt-tz res) + ;; +ve offset = subtract, -ve offset = add + (decf dt (dt-tz res))) + (values dt skipped-tokens))))) diff --git a/print-date.lisp b/print-date.lisp new file mode 100644 index 0000000..e688d79 --- /dev/null +++ b/print-date.lisp @@ -0,0 +1,108 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +(defun date->string (date &key (format :human) zone) + "Return a string representation of a datetime as per the desired format. +Format must be specified as one of (:human :iso-8601 :asctime :rfc-822 :rfc-850). + +The time is omitted if it is exactly midnight or noon + +If timezone is not null, the datetime is first translated to the given timezone +from UTC and then converted to a string. Timezone can be specified as an alphabetic +code or a numeric offset in fractions of hours. + +The :human format is the same as :iso-8601 except that the separators between date, +time and timezone are spaces to make it more readable to the human eye." + (let ((offset (zone-to-offset zone))) + (incf date (/ offset 24)) + (multiple-value-bind (yy mm dd h m s) (date->ymd date) + (let ((year (format nil "~d" yy)) + (month (case format + ((:asctime :rfc-822 :rfc-850) (three-letter-month mm)) + (otherwise (format nil "~2,'0d" mm)))) + (day (format nil "~2,'0d" dd)) + (dow (case format + ((:asctime :rfc-822 :rfc-850) (three-letter-dow (day-of-week date))) + (otherwise "")))) + (if (or (integerp date) + (= 1/2 (mod date 1))) + (date-only-string year month day dow format) + (date-time-string year month day dow h m s offset format)))))) + +(defun date->long-string (date &key zone (date-only nil)) + "Converts a date to a long string with day of week and month fully spelled out +e.g., \"Thursday, 6 July 2017, 09:38:43.567 +0900\". + +If :date-only is true, the time and timezone are omitted." + (let ((offset (zone-to-offset zone))) + (incf date (/ offset 24)) + (multiple-value-bind (yy mm dd h m s) (date->ymd date) + (if date-only + (format nil "~a, ~d ~a ~d" (dow->string (day-of-week date)) + dd (month->string mm) yy) + (let* ((tz-hh (truncate (abs offset))) + (tz-mm (truncate (* 60 (- (abs offset) tz-hh)))) + (tz-str (cond ((= 0 offset) "UTC") + ((< offset 0) (format nil "-~2,'0d~2,'0d" tz-hh tz-mm)) + (t (format nil "+~2,'0d~2,'0d" tz-hh tz-mm)))) + (time-str (if (< (- s (truncate s)) 0.001) + (format nil "~2,'0d:~2,'0d:~2,'0d" h m (truncate s)) + (format nil "~2,'0d:~2,'0d:~6,3,,,'0f" h m s)))) + (format nil "~a, ~d ~a ~d, ~a ~a" (dow->string (day-of-week date)) + dd (month->string mm) yy time-str tz-str)))))) + +(defun date-only-string (yy mm dd dow fmt) + (case fmt + (:rfc-822 (format nil "~a, ~a ~a ~a" dow dd mm yy)) + (:asctime (format nil "~a ~a ~a ~a" dow mm dd yy)) + (:rfc-850 (format nil "~a, ~a-~a-~a" dow dd mm yy)) + (t (format nil "~a-~a-~a" yy mm dd)))) + +(defun date-time-string (yy mm dd dow h m s offset fmt) + (let* ((tz-hh (truncate (abs offset))) + (tz-mm (truncate (* 60 (- (abs offset) tz-hh)))) + (tz-str (case fmt + ((:rfc-822 :rfc-850 :asctime) + (cond ((= 0 offset) "GMT") + ((< offset 0) (format nil "-~2,'0d~2,'0d" tz-hh tz-mm)) + (t (format nil "+~2,'0d~2,'0d" tz-hh tz-mm)))) + (t (cond ((= 0 offset) (if (eq fmt :iso-8601) "Z" "UTC")) + ((< offset 0) (format nil "-~2,'0d:~2,'0d" tz-hh tz-mm)) + (t (format nil "+~2,'0d:~2,'0d" tz-hh tz-mm)))))) + (time-str (if (< (- s (truncate s)) 0.001) + (format nil "~2,'0d:~2,'0d:~2,'0d" h m (truncate s)) + (format nil "~2,'0d:~2,'0d:~6,3,,,'0f" h m s)))) + (case fmt + (:rfc-822 (format nil "~a, ~a ~a ~a ~a ~a" dow dd mm yy time-str tz-str)) + (:asctime (format nil "~a ~a ~a ~a ~a ~a" dow mm dd time-str tz-str yy)) + (:rfc-850 (format nil "~a, ~a-~a-~a ~a ~a" dow dd mm yy time-str tz-str)) + (:iso-8601 (format nil "~a-~a-~aT~a~a" yy mm dd time-str tz-str)) + (t (format nil "~a-~a-~a ~a ~a" yy mm dd time-str tz-str))))) + + + diff --git a/test-dates.lisp b/test-dates.lisp new file mode 100644 index 0000000..f7f5090 --- /dev/null +++ b/test-dates.lisp @@ -0,0 +1,128 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates-test) + +(deftest julian () + (format t "Julian date conversions ...~%") + (check + (= (ymd->date 2017 2 5) 2457789.5) + (= (ymd->date 1959 12 29) 2436931.5) + (= (ymd->date 1959 12 29 18 12 26.3) 2436932.2586376667d0) + (equal '(2017 2 5 0 0 0.0d0) + (multiple-value-call #'list (date->ymd 2457789.5))) + (= (ymd->date 1600 2 29) 2305506.5) + (= (ymd->date 1600 2 29 9 31 23.5) 2305506.896799773d0) + (equal '(1600 2 29 9 31 23.500385284423828d0) + (multiple-value-call #'list (date->ymd 2305506.896799773d0))) + (= (date->javascript-time (ymd->date 2017 6 18)) 1497744000000) + (= (jday-number (ymd->date 2017 6 18)) 2457923))) + +(deftest misc-fns () + (format t "Miscellaneous date functions ...~%") + (check + (valid-date-p 2000 2 29) + (null (valid-date-p 1900 2 29)) + (null (valid-date-p 1234 65 789)) + + (eq :monday (day-of-week (ymd->date 2017 2 6))) + (eq :wednesday (day-of-week (ymd->date 1959 9 23))) + + (= (nth-day-of-week (ymd->date 2017 2 5) :tuesday 3) (ymd->date 2017 2 21)) + (= (nth-day-of-week (ymd->date 2017 2 5) :thursday 3) (ymd->date 2017 2 16)) + ;; overflow month - returns last tuesday + (= (nth-day-of-week (ymd->date 2017 2 5) :tuesday 7) (ymd->date 2017 2 28)) + ;; any number <= 1 should return 1st tuesday + (= (nth-day-of-week (ymd->date 2017 2 5) :tuesday -7) (ymd->date 2017 2 7)))) + +(deftest print-fns () + (format t "Conversions to strings ...~%") + (let ((dt1 (ymd->date 2017 2 16)) + (dt2 (ymd->date 2017 2 16 17 30 25 +9))) + (check + (string= (dow->string :monday) "Monday") + (string= (dow->string :tuesday) "Tuesday") + (string= (dow->string :wednesday) "Wednesday") + (string= (dow->string :thursday) "Thursday") + (string= (dow->string :friday) "Friday") + (string= (dow->string :saturday) "Saturday") + (string= (dow->string :sunday) "Sunday") + + (string= (month->string 1) "January") + (string= (month->string 2) "February") + (string= (month->string 3) "March") + (string= (month->string 4) "April") + (string= (month->string 5) "May") + (string= (month->string 6) "June") + (string= (month->string 7) "July") + (string= (month->string 8) "August") + (string= (month->string 9) "September") + (string= (month->string 10) "October") + (string= (month->string 11) "November") + (string= (month->string 12) "December") + + (string= (date->string dt1) "2017-02-16") + (string= (date->string dt1 :format :iso-8601) "2017-02-16") + (string= (date->string dt1 :format :asctime) "Thu Feb 16 2017") + (string= (date->string dt1 :format :rfc-822) "Thu, 16 Feb 2017") + (string= (date->string dt1 :format :rfc-850) "Thu, 16-Feb-2017") + + (string= (date->string dt2) "2017-02-16 08:30:25 UTC") + (string= (date->string dt2 :format :iso-8601) "2017-02-16T08:30:25Z") + (string= (date->string dt2 :format :asctime) "Thu Feb 16 08:30:25 GMT 2017") + (string= (date->string dt2 :format :rfc-822) "Thu, 16 Feb 2017 08:30:25 GMT") + (string= (date->string dt2 :format :rfc-850) "Thu, 16-Feb-2017 08:30:25 GMT") + + (string= (date->string dt2 :zone "JST") "2017-02-16 17:30:25 +09:00") + (string= (date->string dt2 :zone "JST" :format :iso-8601) "2017-02-16T17:30:25+09:00") + (string= (date->string dt2 :zone "JST" :format :asctime) "Thu Feb 16 17:30:25 +0900 2017") + (string= (date->string dt2 :zone "JST" :format :rfc-822) "Thu, 16 Feb 2017 17:30:25 +0900") + (string= (date->string dt2 :zone "JST" :format :rfc-850) "Thu, 16-Feb-2017 17:30:25 +0900")))) + +(deftest special-dates () + (format t "Computation of special dates ...~%") + (check + (= (easter-day 2001) (ymd->date 2001 4 15)) + (= (easter-day 2002) (ymd->date 2002 3 31)) + (= (easter-day 2005) (ymd->date 2005 3 27)) + (= (easter-day 2011) (ymd->date 2011 4 24)) + + (= (vernal-equinox 2017) + (ymd->date 2017 3 20 10 28 32.05221712589264d0)) + (= (summer-solstice 2017) + (ymd->date 2017 6 21 4 23 43.49940687417984d0)) + (= (autumnal-equinox 2017) + (ymd->date 2017 9 22 20 1 8.430179357528687D0)) + (= (winter-solstice 2017) + (ymd->date 2017 12 21 16 27 51.39586955308914d0)) + (= (vernal-equinox 1959) + (ymd->date 1959 3 21 8 55 7.991203665733337d0)) + (= (summer-solstice 1959) + (ymd->date 1959 6 22 3 49 50.55352360010147d0)) + (= (autumnal-equinox 1959) + (ymd->date 1959 9 23 19 8 29.363870322704315D0)) + (= (winter-solstice 1959) + (ymd->date 1959 12 22 14 34 33.68946969509125d0)))) diff --git a/test-main.lisp b/test-main.lisp new file mode 100644 index 0000000..bca835a --- /dev/null +++ b/test-main.lisp @@ -0,0 +1,89 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-user) + +(defpackage :cl-dates-test + (:use :common-lisp :cl-dates) + (:export :run-all-tests)) + +(in-package :cl-dates-test) + +;;; +;;; Peter Siebel's test framework from Practical Common Lisp with +;;; slight modifications +;;; +(defvar *test-name* nil) +(defvar *verbose-results* nil) +(defvar *total-tests* 0) +(defvar *failed-tests* 0) + +(defmacro deftest (name parameters &body body) + "Define a test function. Within a test function we can call + other test functions or use 'check' to run individual test + cases." + `(defun ,name ,parameters + (let ((*test-name* (append *test-name* (list ',name)))) + ,@body))) + +(defmacro check (&body forms) + "Run each expression in 'forms' as a test case." + `(combine-results + ,@(loop for f in forms collect `(report-result ,f ',f)))) + +(defmacro combine-results (&body forms) + "Combine the results (as booleans) of evaluating 'forms' in order." + (let ((result (gensym))) + `(let ((,result t)) + ,@(loop for f in forms collect `(unless ,f (setf ,result nil))) + ,result))) + +(defun report-result (result form) + "Report the results of a single test case. Called by 'check'." + (incf *total-tests*) + (when (not result) + (incf *failed-tests*)) + (if *verbose-results* + (format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form) + (when (not result) + (format t "FAIL ... ~a: ~a~%" *test-name* form))) + result) + +(defun run-all-tests (&key (verbose nil)) + (let ((*total-tests* 0) + (*failed-tests* 0) + (*verbose-results* verbose) + (status (combine-results + (julian) + (misc-fns) + (special-dates) + (print-fns) + (parse-dates)))) + (unless (zerop *total-tests*) + (format t "~d tests executed - ~d passed (~,2f%)~%" + *total-tests* (- *total-tests* *failed-tests*) + (/ (* 100 (- *total-tests* *failed-tests*)) *total-tests*))) + status)) diff --git a/test-parse-date.lisp b/test-parse-date.lisp new file mode 100644 index 0000000..b869d58 --- /dev/null +++ b/test-parse-date.lisp @@ -0,0 +1,203 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates-test) + +;; Rounding errors in fractional seconds when changing timezones require +;; testing for approximate equality in some test cases +(defparameter +max-error+ 1/1000000) +(defun a= (a b) + (< (abs (- a b)) +max-error+)) + +(deftest parse-dates () + (format t "Parse dates from strings ...~%") + (let ((dt (ymd->date 2003 9 25 1 36 28))) + (check + ;; Fully specified date with time and timezone + (= (string->date "Thu Sep 25 10:36:28 JST 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (null (string->date "Wed Sep 25 10:36:28 JST 2003" :reference-date dt)) ; day of week doesn't match date + (= (string->date "2003 10:36:28 JST 25 Sep Thu" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Thu, 25 Sep 2003 10:36:28 JST" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Thu, 25 Sep 2003 10:36:28 +0900" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003-09-25T10:49:41-03:00" :reference-date dt) (ymd->date 2003 9 25 13 49 41)) + (a= (string->date "2003-09-25T10:49:41.5-03:00" :reference-date dt) (ymd->date 2003 9 25 13 49 41.5)) + (= (string->date "20030925T10:49:41-03:00" :reference-date dt) (ymd->date 2003 9 25 13 49 41)) + (a= (string->date "20030925T10:49:41.5-03:00" :reference-date dt) (ymd->date 2003 9 25 13 49 41.5)) + + ;; Partially specified dates (alphanumeric) + (= (string->date "Thu Sep 25 10:36:28 2003" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "Thu Sep 25 10:36:28" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "Thu Sep 10:36:28" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "Thu 10:36:28" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "Sep 10:36:28" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "10:36:28" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "10:36" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "Thu Sep 25 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Sep 25 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Sep 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Sep" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003-09-25T10:49:41" :reference-date dt) (ymd->date 2003 9 25 10 49 41)) + (= (string->date "2003-09-25T10:49" :reference-date dt) (ymd->date 2003 9 25 10 49 28)) + (= (string->date "2003-09-25T10" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "2003-09-25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "20030925T104941" :reference-date dt) (ymd->date 2003 9 25 10 49 41)) + (= (string->date "20030925T1049" :reference-date dt) (ymd->date 2003 9 25 10 49 28)) + (= (string->date "20030925T10" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "20030925T10:49:41" :reference-date dt) (ymd->date 2003 9 25 10 49 41)) + (= (string->date "20030925T10:49" :reference-date dt) (ymd->date 2003 9 25 10 49 28)) + (= (string->date "20030925T10" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "20030925" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + + ;; Partially specified (numeric with possible alphabetic month name + (= (string->date "19970902090807" :reference-date dt) (ymd->date 1997 9 2 9 8 7)) + (= (string->date "199709020908" :reference-date dt) (ymd->date 1997 9 2 9 8 28)) + (= (string->date "2003-Sep-25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25-Sep-2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Sep-25-2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "09-25-2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) ; no ambiguity because 25 is a day + (= (string->date "25-09-2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) ; no ambiguity because 25 is a day + (= (string->date "10-09-2003" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10-09-2003" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10-09-03" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10-09-03" :precedence :ymd :reference-date dt) + (ymd->date 2010 9 3 1 36 28)) ; explicitly yy/mm/dd + (= (string->date "2003.09.25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003.Sep.25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25.Sep.2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "09.25.2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25.09.2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003/09/25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003/Sep/25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25/Sep/2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "09/25/2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25/09/2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "10.09.2003" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10.09.2003" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10.09.03" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10.09.03" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10/09/2003" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10/09/2003" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10/09/03" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10/09/03" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (null (string->date "2003/25/Sep" :reference-date dt)) ; invalid - yy/dd/MMM + (= (string->date "2003 09 25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003 Sep 25" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "2003 25 Sep" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25 Sep 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Sep 25 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "09 25 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "25 09 2003" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "10 09 2003" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10 09 2003" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "10 09 03" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 10 1 36 28)) ; explicitly dd/mm/yy + (= (string->date "10 09 03" :precedence :mdy :reference-date dt) + (ymd->date 2003 10 9 1 36 28)) ; explicitly mm/dd/yy + (= (string->date "03 25 Sep" :reference-date dt) + (ymd->date 2003 9 25 1 36 28)) ; default is yy/mm/dd + (= (string->date "25 03 Sep" :precedence :dmy :reference-date dt) + (ymd->date 2003 9 25 1 36 28)) ; explicitly day before year + + ;; Assorted time formats + (a= (string->date "10h16m38.5s" :reference-date dt) (ymd->date 2003 9 25 10 16 38.5)) + (= (string->date "10h16m38s" :reference-date dt) (ymd->date 2003 9 25 10 16 38)) + (a= (string->date "10h16m" :reference-date dt) (ymd->date 2003 9 25 10 16 28)) + (= (string->date "10h" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (a= (string->date "10 h 16" :reference-date dt) (ymd->date 2003 9 25 10 16 28)) + (= (string->date "10h am" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "10h pm" :reference-date dt) (ymd->date 2003 9 25 22 36 28)) + (= (string->date "10 am" :reference-date dt) (ymd->date 2003 9 25 10 36 28)) + (= (string->date "10 pm" :reference-date dt) (ymd->date 2003 9 25 22 36 28)) + (= (string->date "10:00 am" :reference-date dt) (ymd->date 2003 9 25 10 0 28)) + (= (string->date "10:00 pm" :reference-date dt) (ymd->date 2003 9 25 22 0 28)) + (= (string->date "10:00am" :reference-date dt) (ymd->date 2003 9 25 10 0 28)) + (= (string->date "10:00pm" :reference-date dt) (ymd->date 2003 9 25 22 0 28)) + (= (string->date "10:00a.m." :reference-date dt) (ymd->date 2003 9 25 10 0 28)) + (= (string->date "10:00p.m." :reference-date dt) (ymd->date 2003 9 25 22 0 28)) + (= (string->date "10:00a.m" :reference-date dt) (ymd->date 2003 9 25 10 0 28)) + (= (string->date "10:00p.m" :reference-date dt) (ymd->date 2003 9 25 22 0 28)) + + ;; relative dates etc + (= (string->date "Sep 03" :reference-date dt) (ymd->date 2003 9 3 1 36 28)) + (= (string->date "Sep of 03" :reference-date dt) (ymd->date 2003 9 25 1 36 28)) + (= (string->date "Wed" :reference-date dt) (ymd->date 2003 9 24 1 36 28)) + (= (string->date "Wednesday" :reference-date dt) (ymd->date 2003 9 24 1 36 28)) + (= (string->date "last Wednesday" :reference-date dt) (ymd->date 2003 9 24 1 36 28)) + (= (string->date "next Wednesday" :reference-date dt) (ymd->date 2003 10 1 1 36 28)) + (= (string->date "October" :reference-date dt) (ymd->date 2003 10 25 1 36 28)) + (= (string->date "31-Dec-00" :precedence :dmy :reference-date dt) + (ymd->date 2000 12 31 1 36 28)) + + ;; Random verbiage / unusual formats + (= (string->date "The date is the 25th of September of 2003, exactly at 10:49:41 with time zone -03:00" + :reference-date dt) (ymd->date 2003 9 25 13 49 41)) + (null (string->date "Today is the 25th of September of 2003, exactly at 10:49:41 with time zone -03:00" + :reference-date dt)) ; 'today' is assumed to be a date spec so a duplicate date is detected + (= (string->date " July 4 , 1976 12:01:02 am " :reference-date dt) + (ymd->date 1976 7 4 0 1 2)) + (= (string->date "today 12:35" :reference-date dt) (ymd->date 2003 9 25 12 35 28)) + (= (string->date "today 12:35 JST" :reference-date dt) (ymd->date 2003 9 25 3 35 28)) + (= (string->date "tomorrow 12:35" :reference-date dt) (ymd->date 2003 9 26 12 35 28)) + (= (string->date "yesterday 12:35 am JST" :reference-date dt) (ymd->date 2003 9 23 15 35 28)) ; date is 2 days prior in UTC + (= (string->date "Wed Jul 10, '96" :reference-date dt) (ymd->date 1996 7 10 1 36 28)) + (a= (string->date "1996.07.10 AD at 15:08:56 PDT" :reference-date dt) (ymd->date 1996 7 10 22 8 56)) + (= (string->date "1996.07.10 AD at 12:08 PM" :reference-date dt) (ymd->date 1996 7 10 12 8 28)) + (= (string->date "Saturday, April 12, 1952 AD 3:30:42pm PST" :reference-date dt) (ymd->date 1952 4 12 23 30 42)) + (= (string->date "November 5, 1994, 8:15:30 am EST" :reference-date dt) (ymd->date 1994 11 5 13 15 30)) + (= (string->date "1994-11-05T08:15:30-05:00" :reference-date dt) (ymd->date 1994 11 5 13 15 30)) + (= (string->date "1994-11-05T08:15:30Z" :reference-date dt) (ymd->date 1994 11 5 8 15 30)) + (= (string->date "0:01:02" :reference-date dt) (ymd->date 2003 9 25 0 1 2)) + (= (string->date "12h 01m02s am" :reference-date dt) (ymd->date 2003 9 25 0 1 2)) + (= (string->date "0:01:02 on July 4, 1976" :reference-date dt) (ymd->date 1976 7 4 0 1 2)) + (null (string->date "July 4, 1976 pm 12:01:02" :reference-date dt)) ; am/pm must come after time spec + (= (string->date "July 4, 4pm 4:01:02" :reference-date dt) + (ymd->date 2003 7 4 4 1 2)) ; 4pm overridden by 4:01:02 + (= (string->date "July 4, 4pm" :reference-date dt) (ymd->date 2003 7 4 16 36 28)) ; this is ok + (a= (string->date "04.04.95 00:22" :reference-date dt) (ymd->date 1995 4 4 0 22 28)) + (= (string->date "950404 122212" :reference-date dt) (ymd->date 1995 4 4 12 22 12)) + (= (string->date "0:00 PM, PST" :reference-date dt) (ymd->date 2003 9 25 20 0 28)) + (= (string->date "12:08 PM" :reference-date dt) (ymd->date 2003 9 25 12 8 28)) + (= (string->date "5:50 A.M on June 13, 1990" :reference-date dt) (ymd->date 1990 6 13 5 50 28)) + (= (string->date "01h02m03" :reference-date dt) (ymd->date 2003 9 25 1 2 3)) + (= (string->date "01h02" :reference-date dt) (ymd->date 2003 9 25 1 2 28)) + (= (string->date "01h02s" :reference-date dt) (ymd->date 2003 9 25 1 36 2)) + (= (string->date "01m02" :reference-date dt) (ymd->date 2003 9 25 1 1 2)) + (a= (string->date "01m02h" :reference-date dt) (ymd->date 2003 9 25 2 1 28)) + (= (string->date "2004 10 April 11h30m" :reference-date dt) (ymd->date 2004 4 10 11 30 28))))) diff --git a/timezones.lisp b/timezones.lisp new file mode 100644 index 0000000..0f3ffc7 --- /dev/null +++ b/timezones.lisp @@ -0,0 +1,260 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +;; hash table containing mappings from time zone abbreviation to GMT offset +(defparameter +tz-info+ (make-hash-table :test #'equalp)) + +(defun str-to-tz-offset (str) + (gethash str +tz-info+)) + +(defun zone-to-offset (zone) + (typecase zone + (number zone) + (string (or (str-to-tz-offset zone) 0)) + (t 0))) + +(progn + ;; Time zone abbreviation to offset mapping table (source: Wikipedia) + ;; Wherever there are duplicate abbreviations, the more economically significant + ;; (in terms of trading centres) zone has been retained + (let ((info '(("ACDT" 10.5 "Australian Central Daylight Savings Time") + ("ACST" 9.5 "Australian Central Standard Time") + ("ACT" -5 "Acre Time") + ("ADT" -3 "Atlantic Daylight Time") + ("AEDT" 11 "Australian Eastern Daylight Savings Time") + ("AEST" 10 "Australian Eastern Standard Time") + ("AFT" 4.5 "Afghanistan Time") + ("AKDT" -8 "Alaska Daylight Time") + ("AKST" -9 "Alaska Standard Time") + ("AMST" -3 "Amazon Summer Time (Brazil)") + ("AMT" -4 "Amazon Time (Brazil)") + ("AMT" 4 "Armenia Time") + ("ART" -3 "Argentina Time") + ("AST" 3 "Arabia Standard Time") + ;; ("AST" -4 "Atlantic Standard Time") + ("AWST" 8 "Australian Western Standard Time") + ("AZOST" 0 "Azores Summer Time") + ("AZOT" -1 "Azores Standard Time") + ("AZT" 4 "Azerbaijan Time") + ("BDT" 8 "Brunei Time") + ("BIOT" 6 "British Indian Ocean Time") + ("BIT" -12 "Baker Island Time") + ("BOT" -4 "Bolivia Time") + ("BRST" -2 "Brasilia Summer Time") + ("BRT" -3 "Brasilia Time") + ;; ("BST" 6 "Bangladesh Standard Time") + ;; ("BST" 11 "Bougainville Standard Time") + ("BST" 1 "British Summer Time") + ("BTT" 6 "Bhutan Time") + ("CAT" 2 "Central Africa Time") + ("CCT" 6.5 "Cocos Islands Time") + ("CDT" -5 "Central Daylight Time (North America)") + ;; ("CDT" -4 "Cuba Daylight Time") + ("CEST" 2 "Central European Summer Time (Cf. HAEC)") + ("CET" 1 "Central European Time") + ("CHADT" 13.75 "Chatham Daylight Time") + ("CHAST" 12.75 "Chatham Standard Time") + ("CHOT" 8 "Choibalsan Standard Time") + ("CHOST" 9 "Choibalsan Summer Time") + ("CHST" 10 "Chamorro Standard Time") + ("CHUT" 10 "Chuuk Time") + ("CIST" -8 "Clipperton Island Standard Time") + ("CIT" 8 "Central Indonesia Time") + ("CKT" -10 "Cook Island Time") + ("CLST" -3 "Chile Summer Time") + ("CLT" -4 "Chile Standard Time") + ("COST" -4 "Colombia Summer Time") + ("COT" -5 "Colombia Time") + ("CST" -6 "Central Standard Time (North America)") + ;; ("CST" 8 "China Standard Time") + ;; ("CST" -5 "Cuba Standard Time") + ("ACST" 9.5 "Central Standard Time (Australia)") + ("ACDT" 10.5 "Central Summer Time (Australia)") + ("CT" 8 "China time") + ("CVT" -1 "Cape Verde Time") + ("CXT" 7 "Christmas Island Time") + ("DAVT" 7 "Davis Time") + ("DDUT" 10 "Dumont d'Urville Time") + ("DFT" 1 "AIX specific equivalent of Central European Time") + ("EASST" -5 "Easter Island Summer Time") + ("EAST" -6 "Easter Island Standard Time") + ("EAT" 3 "East Africa Time") + ("ECT" -4 "Eastern Caribbean Time (does not recognise DST)") + ;; ("ECT" -5 "Ecuador Time") + ("EDT" -4 "Eastern Daylight Time (North America)") + ("AEDT" 11 "Eastern Summer Time (Australia)") + ("EEST" 3 "Eastern European Summer Time") + ("EET" 2 "Eastern European Time") + ("EGST" 0 "Eastern Greenland Summer Time") + ("EGT" -1 "Eastern Greenland Time") + ("EIT" 9 "Eastern Indonesian Time") + ("EST" -5 "Eastern Standard Time (North America)") + ("AEST" 10 "Eastern Standard Time (Australia)") + ("FET" 3 "Further-eastern European Time") + ("FJT" 12 "Fiji Time") + ("FKST" -3 "Falkland Islands Summer Time") + ("FKT" -4 "Falkland Islands Time") + ("FNT" -2 "Fernando de Noronha Time") + ("GALT" -6 "Galapagos Time") + ("GAMT" -9 "Gambier Islands") + ("GET" 4 "Georgia Standard Time") + ("GFT" -3 "French Guiana Time") + ("GILT" 12 "Gilbert Island Time") + ("GIT" -9 "Gambier Island Time") + ("GMT" 0 "Greenwich Mean Time") + ;; ("GST" -2 "South Georgia and the South Sandwich Islands") + ("GST" 4 "Gulf Standard Time") + ("GYT" -4 "Guyana Time") + ("HADT" -9 "Hawaii-Aleutian Daylight Time") + ("HAEC" 2 "Heure Avancée d'Europe Centrale francised name for CEST") + ("HAST" -10 "Hawaii-Aleutian Standard Time") + ("HKT" 8 "Hong Kong Time") + ("HMT" 5 "Heard and McDonald Islands Time") + ("HOVST" 8 "Khovd Summer Time") + ("HOVT" 7 "Khovd Standard Time") + ("ICT" 7 "Indochina Time") + ("IDT" 3 "Israel Daylight Time") + ("IOT" 3 "Indian Ocean Time") + ("IRDT" 4.5 "Iran Daylight Time") + ("IRKT" 8 "Irkutsk Time") + ("IRST" 3.5 "Iran Standard Time") + ("IST" 5.5 "Indian Standard Time") + ;; ("IST" 1 "Irish Standard Time[6]") + ;; ("IST" 2 "Israel Standard Time") + ("JST" 9 "Japan Standard Time") + ("KGT" 6 "Kyrgyzstan time") + ("KOST" 11 "Kosrae Time") + ("KRAT" 7 "Krasnoyarsk Time") + ("KST" 9 "Korea Standard Time") + ("LHST" 10.5 "Lord Howe Standard Time") + ;; ("LHST" 11 "Lord Howe Summer Time") + ("LINT" 14 "Line Islands Time") + ("MAGT" 12 "Magadan Time") + ("MART" -9.5 "Marquesas Islands Time") + ("MAWT" 5 "Mawson Station Time") + ("MDT" -6 "Mountain Daylight Time (North America)") + ("MET" 1 "Middle European Time Same zone as CET") + ("MEST" 2 "Middle European Summer Time Same zone as CEST") + ("MHT" 12 "Marshall Islands") + ("MIST" 11 "Macquarie Island Station Time") + ("MIT" -9.5 "Marquesas Islands Time") + ("MMT" 6.5 "Myanmar Standard Time") + ("MSK" 3 "Moscow Time") + ;; ("MST" 8 "Malaysia Standard Time") + ("MST" -7 "Mountain Standard Time (North America)") + ("MUT" 4 "Mauritius Time") + ("MVT" 5 "Maldives Time") + ("MYT" 8 "Malaysia Time") + ("NCT" 11 "New Caledonia Time") + ("NDT" -2.5 "Newfoundland Daylight Time") + ("NFT" 11 "Norfolk Time") + ("NPT" 5.75 "Nepal Time") + ("NST" -3.5 "Newfoundland Standard Time") + ("NT" -3.5 "Newfoundland Time") + ("NUT" -11 "Niue Time") + ("NZDT" 13 "New Zealand Daylight Time") + ("NZST" 12 "New Zealand Standard Time") + ("OMST" 6 "Omsk Time") + ("ORAT" 5 "Oral Time") + ("PDT" -7 "Pacific Daylight Time (North America)") + ("PET" -5 "Peru Time") + ("PETT" 12 "Kamchatka Time") + ("PGT" 10 "Papua New Guinea Time") + ("PHOT" 13 "Phoenix Island Time") + ("PHT" 8 "Philippine Time") + ("PKT" 5 "Pakistan Standard Time") + ("PMDT" -2 "Saint Pierre and Miquelon Daylight time") + ("PMST" -3 "Saint Pierre and Miquelon Standard Time") + ("PONT" 11 "Pohnpei Standard Time") + ("PST" -8 "Pacific Standard Time (North America)") + ;; ("PST" 8 "Philippine Standard Time") + ("PYST" -3 "Paraguay Summer Time (South America)[7]") + ("PYT" -4 "Paraguay Time (South America)[8]") + ("RET" 4 "Réunion Time") + ("ROTT" -3 "Rothera Research Station Time") + ("SAKT" 11 "Sakhalin Island time") + ("SAMT" 4 "Samara Time") + ("SAST" 2 "South African Standard Time") + ("SBT" 11 "Solomon Islands Time") + ("SCT" 4 "Seychelles Time") + ("SGT" 8 "Singapore Time") + ("SLST" 5.5 "Sri Lanka Standard Time") + ("SRET" 11 "Srednekolymsk Time") + ("SRT" -3 "Suriname Time") + ;; ("SST" -11 "Samoa Standard Time") + ("SST" 8 "Singapore Standard Time") + ("SYOT" 3 "Showa Station Time") + ("TAHT" -10 "Tahiti Time") + ("THA" 7 "Thailand Standard Time") + ("TFT" 5 "Indian/Kerguelen") + ("TJT" 5 "Tajikistan Time") + ("TKT" 13 "Tokelau Time") + ("TLT" 9 "Timor Leste Time") + ("TMT" 5 "Turkmenistan Time") + ("TRT" 3 "Turkey Time") + ("TOT" 13 "Tonga Time") + ("TVT" 12 "Tuvalu Time") + ("ULAST" 9 "Ulaanbaatar Summer Time") + ("ULAT" 8 "Ulaanbaatar Standard Time") + ("USZ1" 2 "Kaliningrad Time") + ("UTC" 0 "Coordinated Universal Time") + ("UYST" -2 "Uruguay Summer Time") + ("UYT" -3 "Uruguay Standard Time") + ("UZT" 5 "Uzbekistan Time") + ("VET" -4 "Venezuelan Standard Time") + ("VLAT" 10 "Vladivostok Time") + ("VOLT" 4 "Volgograd Time") + ("VOST" 6 "Vostok Station Time") + ("VUT" 11 "Vanuatu Time") + ("WAKT" 12 "Wake Island Time") + ("WAST" 2 "West Africa Summer Time") + ("WAT" 1 "West Africa Time") + ("WEST" 1 "Western European Summer Time") + ("WET" 0 "Western European Time") + ("WIT" 7 "Western Indonesian Time") + ("WST" 8 "Western Standard Time") + ("YAKT" 9 "Yakutsk Time") + ("YEKT" 5 "Yekaterinburg Time")))) + (dolist (tz info) + (setf (gethash (car tz) +tz-info+) (cadr tz)))) + + ;; add Military time zones + (loop for ch across "ABCDEFGHI" + do (let ((offset (1+ (- (char-code ch) (char-code #\A))))) + (setf (gethash (string ch) +tz-info+) offset))) + (setf (gethash "K" +tz-info+) 10 + (gethash "L" +tz-info+) 11 + (gethash "M" +tz-info+) 12 + (gethash "C*" +tz-info+) 3.5 + (gethash "D*" +tz-info+) 4.5 + (gethash "E*" +tz-info+) 5.5 + (gethash "F*" +tz-info+) 6.5) + (loop for ch across "NOPQRSTUVWXY" + do (let ((offset (- (1+ (- (char-code ch) (char-code #\N)))))) + (setf (gethash (string ch) +tz-info+) offset))) + (setf (gethash "Z" +tz-info+) 0)) diff --git a/util.lisp b/util.lisp new file mode 100644 index 0000000..0dbf109 --- /dev/null +++ b/util.lisp @@ -0,0 +1,117 @@ +;;; Copyright (c) 2017, Sudhir Shenoy. All rights reserved. + +;;; Redistribution and use in source and binary forms, with or without +;;; modification, are permitted provided that the following conditions +;;; are met: + +;;; * Redistributions of source code must retain the above copyright +;;; notice, this list of conditions and the following disclaimer. + +;;; * Redistributions in binary form must reproduce the above +;;; copyright notice, this list of conditions and the following +;;; disclaimer in the documentation and/or other materials +;;; provided with the distribution. + +;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED +;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +(in-package :cl-dates) + +(defun parse-number (string) + "Returns a number if string is numeric (ignoring surrounding white-space) else NIL" + (handler-case (values (parse-integer string)) + (parse-error () + (multiple-value-bind (integer num-chars) (parse-integer string :junk-allowed t) + (let ((len (length string))) + (cond ((or (>= num-chars len) + (and (= num-chars (1- len)) + (char= (char string num-chars) #\.))) + integer) + ((char/= (char string num-chars) #\.) nil) + (t (handler-case + (multiple-value-bind (fraction frac-chars) + (parse-integer string :start (1+ num-chars)) + (if (zerop fraction) + integer + (coerce (+ integer + (/ fraction (expt 10 (- frac-chars num-chars 1)))) + 'double-float))) + (parse-error () nil))))))))) + +(defun hms->day-fraction (hh mm ss) + (/ (+ (* hh 3600) (* mm 60) ss) 86400)) + +(defun day-fraction->hms (day-frac) + (let* ((secs (* day-frac 86400)) + (hh (floor (/ secs 3600)))) + (setf secs (- secs (* hh 3600))) + (let* ((mm (floor (/ secs 60))) + (ss (- secs (* mm 60)))) + (values hh mm ss)))) + +(defparameter +weekdays+ '((:monday . ("Mon" "Monday")) (:tuesday . ("Tue" "Tuesday" "Tues")) + (:wednesday . ("Wed" "Wednesday")) (:thursday . ("Thu" "Thursday" "Thurs")) + (:friday . ("Fri" "Friday")) (:saturday . ("Sat" "Saturday")) + (:sunday . ("Sun" "Sunday")))) +(defun dow->string (dow) + (third (assoc dow +weekdays+))) + +(defun three-letter-dow (dow) + (second (assoc dow +weekdays+))) + +(defun str-to-weekday (str) + (loop for i from 0 below 7 + do (let ((list (elt +weekdays+ i))) + (when (member str (cdr list) :test #'string-equal) + (return-from str-to-weekday (car list)))))) + +(defparameter +months+ '((nil) ("Jan" "January") ("Feb" "February") ("Mar" "March") ("Apr" "April") + ("May" "May") ("Jun" "June") ("Jul" "July") ("Aug" "August") + ("Sep" "September" "Sept") ("Oct" "October") ("Nov" "November") + ("Dec" "December"))) +(defun month->string (mm) + (second (elt +months+ mm))) + +(defun three-letter-month (mm) + (car (elt +months+ mm))) + +(defun str-to-month (str) + (loop for i from 1 to 12 + do (when (member str (elt +months+ i) :test #'string-equal) + (return-from str-to-month i)))) + +(defparameter +time-suffixes+ '((:hour . ("h" "hr" "hrs" "hour" "hours")) + (:minute . ("m" "min" "mins" "minute" "minutes")) + (:second . ("s" "sec" "secs" "second" "seconds")))) +(defun str-to-hms (str) + (loop for i from 0 below 3 + do (let ((list (elt +time-suffixes+ i))) + (when (member str (cdr list) :test #'string-equal) + (return-from str-to-hms (car list)))))) + +(defun str-to-ampm (str) + (cond ((member str '("a.m." "a.m" "am" "morning") :test #'string-equal) :am) + ((member str '("p.m." "p.m" "pm" "evening" "afternoon") :test #'string-equal) :pm) + (t nil))) + +(defun str-is-day-suffix (str) + (not (null (member str '("st" "nd" "rd" "th") :test #'string-equal)))) + +(defun str-to-relative-dow (str) + (cond ((member str '("last" "prev" "previous") :test #'string-equal) :prev) + ((member str '("next" "coming") :test #'string-equal) :next) + ((string-equal str "this") :closest) + (t nil))) + +(defparameter +date-words+ '(("today" . 0) ("tomorrow" . 1) ("yesterday" . -1))) +(defun str-to-relative-date (str) + (cdr (find str +date-words+ :test #'string-equal :key #'car)))