emacs/org/elpa/ts-20220822.2313/ts.el

637 lines
26 KiB
EmacsLisp

;;; ts.el --- Timestamp and date/time library -*- lexical-binding: t; -*-
;; Copyright (C) 2018-2019 Adam Porter
;; Author: Adam Porter <adam@alphapapa.net>
;; URL: http://github.com/alphapapa/ts.el
;; Package-Version: 20220822.2313
;; Package-Commit: 552936017cfdec89f7fc20c254ae6b37c3f22c5b
;; Version: 0.3
;; Package-Requires: ((emacs "26.1") (dash "2.14.1") (s "1.12.0"))
;; Keywords: calendar, lisp
;; This file is not part of GNU Emacs.
;;; Commentary:
;; This package is designed to ease manipulation of dates, times, and
;; timestamps in Emacs.
;; A struct `ts' is defined, which represents a timestamp. All
;; manipulation is done internally using Unix timestamps. Accessors
;; are used to retrieve calendar values such as month, day, year from
;; a timestamp, and these values are cached in the struct once
;; accessed, to avoid repeatedly calling `format-time-string', which
;; is expensive. Function arguments are designed to work well with
;; the `thread-last' macro, to make sequential operations easy to
;; follow.
;; The current timestamp is retrieved with `ts-now'.
;; Timestamps are easily modified using `ts-adjust', `ts-apply',
;; `ts-incf', `ts-dec', etc.
;; Timestamps are parsed and formatted using `ts-parse',
;; `ts-parse-org', and `ts-format'.
;; Differences and durations are calculated with `ts-diff',
;; `ts-human-duration', and `ts-human-format-duration'. Comparisons
;; are done with `ts<', `ts<=', `ts=', `ts>', and `ts>='.
;;; License:
;; This program 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.
;; This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'dash)
(require 's)
;;;; Variables
(defvar ts-default-format "%Y-%m-%d %H:%M:%S %z"
"Default format for `ts-format'.")
;;;; Structs
(cl-defmacro ts-defstruct (&rest args)
"Like `cl-defstruct', but with additional slot options from ARGS.
Additional slot options and values:
`:accessor-init': a sexp that initializes the slot in the
accessor if the slot is nil. The symbol `struct' will be bound
to the current struct. The accessor is defined after the struct
is fully defined, so it may refer to the struct
definition (e.g. by using the `cl-struct' `pcase' macro).
`:aliases': A list of symbols which will be aliased to the slot
accessor, prepended with the struct name (e.g. a struct `ts' with
slot `year' and alias `y' would create an alias `ts-y')."
(declare (indent defun))
;; FIXME: Compiler warnings about accessors defined multiple times. Not sure if we can fix this
;; except by ignoring warnings.
(let* ((struct-name (car args))
(struct-slots (cdr args))
(cl-defstruct-expansion (macroexpand `(cl-defstruct ,struct-name ,@struct-slots)))
accessor-forms alias-forms)
(cl-loop for slot in struct-slots
for pos from 1
when (listp slot)
do (-let* (((slot-name _slot-default . slot-options) slot)
((&keys :accessor-init :aliases) slot-options)
(accessor-name (intern (concat (symbol-name struct-name) "-" (symbol-name slot-name))))
(accessor-docstring (format "Access slot \"%s\" of `%s' struct STRUCT."
slot-name struct-name))
(struct-pred (intern (concat (symbol-name struct-name) "-p")))
;; Accessor form copied from macro expansion of `cl-defstruct'.
(accessor-form `(cl-defsubst ,accessor-name (struct)
,accessor-docstring
;; FIXME: side-effect-free is probably not true here, but what about error-free?
;; (declare (side-effect-free error-free))
(or (,struct-pred struct)
(signal 'wrong-type-argument
(list ',struct-name struct)))
,(when accessor-init
`(unless (aref struct ,pos)
(aset struct ,pos ,accessor-init)))
;; NOTE: It's essential that this `aref' form be last
;; so the gv-setter works in the compiler macro.
(aref struct ,pos))))
(push accessor-form accessor-forms)
;; Remove accessor forms from `cl-defstruct' expansion. This may be distasteful,
;; but it would seem more distasteful to copy all of `cl-defstruct' and potentially
;; have the implementations diverge in the future when Emacs changes (e.g. the new
;; record type).
(cl-loop for form in-ref cl-defstruct-expansion
do (pcase form
(`(cl-defsubst ,(and accessor (guard (eq accessor accessor-name)))
. ,_)
accessor ; Silence "unused lexical variable" warning.
(setf form nil))))
;; Alias definitions.
(cl-loop for alias in aliases
for alias-name = (intern (concat (symbol-name struct-name) "-" (symbol-name alias)))
do (push `(defalias ',alias-name ',accessor-name) alias-forms))
;; TODO: Setter
;; ,(when (plist-get slot-options :reset)
;; `(gv-define-setter ,accessor-name (ts value)
;; `(progn
;; (aset ,ts ,,pos ,value)
;; (setf (ts-unix ts) ni))))
))
`(progn
,cl-defstruct-expansion
,@accessor-forms
,@alias-forms)))
;; TODO: When a field is changed, the unix/internal slot needs to be updated. On the other hand,
;; maybe not. Maybe `ts-adjust' should be the only way to adjust them. Otherwise, updating the
;; unix/internal time value when a slot is changed gets really complicated, and it might not be
;; worth it. Or, at least, not in the initial version.
(ts-defstruct ts
(hour
nil :accessor-init (string-to-number (format-time-string "%H" (ts-unix struct)))
:aliases (H)
:constructor "%H"
:type integer)
(minute
nil :accessor-init (string-to-number (format-time-string "%M" (ts-unix struct)))
:aliases (min M)
:constructor "%M"
:type integer)
(second
nil :accessor-init (string-to-number (format-time-string "%S" (ts-unix struct)))
:aliases (sec S)
:constructor "%S"
:type integer)
(day
nil :accessor-init (string-to-number (format-time-string "%d" (ts-unix struct)))
:aliases (day-of-month-num dom d)
:constructor "%d"
:type integer)
(month
nil :accessor-init (string-to-number (format-time-string "%m" (ts-unix struct)))
:aliases (month-num moy m)
:constructor "%m"
:type integer)
(year
nil :accessor-init (string-to-number (format-time-string "%Y" (ts-unix struct)))
:aliases (Y)
:constructor "%Y"
:type integer)
(dow
nil :accessor-init (string-to-number (format-time-string "%w" (ts-unix struct)))
:aliases (day-of-week-num)
:constructor "%w"
:type integer)
(day-abbr
nil :accessor-init (format-time-string "%a" (ts-unix struct))
:aliases (day-of-week-abbr)
:constructor "%a")
(day-name
nil :accessor-init (format-time-string "%A" (ts-unix struct))
:aliases (day-of-week-name)
:constructor "%A")
;; (doe nil
;; :accessor-init (days-between (format-time-string "%Y-%m-%d 00:00:00" (ts-unix struct))
;; "1970-01-01 00:00:00")
;; :aliases (day-of-epoch))
(doy
nil :accessor-init (string-to-number (format-time-string "%j" (ts-unix struct)))
:aliases (day-of-year)
:constructor "%j"
:type integer)
(woy
nil :accessor-init (string-to-number (format-time-string "%V" (ts-unix struct)))
:aliases (week week-of-year)
:constructor "%V"
:type integer)
(month-abbr
nil :accessor-init (format-time-string "%b" (ts-unix struct))
:aliases (b)
:constructor "%b")
(month-name
nil :accessor-init (format-time-string "%B" (ts-unix struct))
:aliases (B)
:constructor "%B")
(tz-abbr
nil :accessor-init (format-time-string "%Z" (ts-unix struct))
:constructor "%Z")
(tz-offset
nil :accessor-init (format-time-string "%z" (ts-unix struct))
:constructor "%z")
;; MAYBE: Add tz-offset-minutes
(internal
nil :accessor-init (apply #'encode-time (decode-time (ts-unix struct))))
(unix
nil :accessor-init (pcase-let* (((cl-struct ts second minute hour day month year) struct))
(if (and second minute hour day month year)
(float-time (encode-time second minute hour day month year))
(float-time)))))
;;;; Substs
(defun ts-now ()
"Return `ts' struct set to now.
This is a non-inlined function, so it may be rebound, e.g. with
`cl-letf' for testing."
(make-ts :unix (float-time)))
(defsubst ts-format (&optional ts-or-format-string ts)
"Format timestamp with `format-time-string'.
If TS-OR-FORMAT-STRING is a timestamp or nil, use the value of
`ts-default-format'. If both TS-OR-FORMAT-STRING and TS are nil,
use the current time."
(cl-etypecase ts-or-format-string
(ts (format-time-string ts-default-format (ts-unix ts-or-format-string)))
(string (cl-etypecase ts
(ts (format-time-string ts-or-format-string (ts-unix ts)))
(null (format-time-string ts-or-format-string))))
(null (cl-etypecase ts
(ts (format-time-string ts-default-format (ts-unix ts)))
(null (format-time-string ts-default-format))))))
(defsubst ts-parse (string)
"Return new `ts' struct, parsing STRING with `parse-time-string'."
(let ((parsed (parse-time-string string)))
;; Fill nil values
(cl-loop for i from 0 to 5
when (null (nth i parsed))
do (setf (nth i parsed) 0))
(->> parsed
(apply #'encode-time)
float-time
(make-ts :unix))))
(defsubst ts-parse-fill (fill string)
"Return new `ts' struct, parsing STRING with `parse-time-string'.
Empty hour/minute/second values are filled according to FILL: if
`begin', with 0; if `end', hour is filled with 23 and
minute/second with 59; if nil, an error may be signaled when time
values are empty.
Note that when FILL is `end', a time value like \"12:12\" is
filled to \"12:12:00\", not \"12:12:59\"."
(let ((parsed (parse-time-string string)))
;; Fill nil values
(pcase-exhaustive fill
('begin (unless (nth 0 parsed)
(setf (nth 0 parsed) 0))
(unless (nth 1 parsed)
(setf (nth 1 parsed) 0))
(unless (nth 2 parsed)
(setf (nth 2 parsed) 0)))
;; NOTE: When the second value is not present in the string, it's
;; set to 0, even when FILL is `end'. In a way this seems wrong,
;; but on the other hand, "12:12" as a plain time value is assumed
;; to refer to the moment it becomes 12:12, which means 0 seconds.
('end (unless (nth 0 parsed)
(setf (nth 0 parsed) 59))
(unless (nth 1 parsed)
(setf (nth 1 parsed) 59))
(unless (nth 2 parsed)
(setf (nth 2 parsed) 23)))
(`nil nil))
(->> parsed
(apply #'encode-time)
float-time
(make-ts :unix))))
(defsubst ts-reset (ts)
"Return TS with all slots cleared except `unix'.
Non-destructive. The same as:
(make-ts :unix (ts-unix ts))"
(make-ts :unix (ts-unix ts)))
(defsubst ts-update (ts)
"Return timestamp TS after updating its Unix timestamp from its other slots.
Non-destructive. To be used after setting slots with,
e.g. `ts-fill'."
(pcase-let* (((cl-struct ts second minute hour day month year) ts))
(make-ts :unix (float-time (encode-time second minute hour day month year)))))
(defsubst ts-parse-org-element (element)
"Return timestamp object for Org timestamp element ELEMENT.
Element should be like one parsed by `org-element', the first
element of which is `timestamp'. Assumes timestamp is not a
range."
(-let (((_ (&keys :year-start :month-start :day-start :hour-start :minute-start)) element))
(make-ts :year year-start :month month-start :day day-start
:hour (or hour-start 0) :minute (or minute-start 0) :second 0)))
;; We don't want to force `org' to load when this library does, so we declare
;; the function. Users should load `org' before calling `ts-parse-org'.
(declare-function org-parse-time-string "org-macs.el")
(defsubst ts-parse-org (org-ts-string)
"Return timestamp object for Org timestamp string ORG-TS-STRING.
Note that function `org-parse-time-string' is called, which
should be loaded before calling this function."
(pcase-let* ((`(,second ,minute ,hour ,day ,month ,year)
(save-match-data
(org-parse-time-string org-ts-string))))
(make-ts :second second :minute minute :hour hour :day day :month month :year year)))
(defsubst ts-parse-org-fill (fill org-ts-string)
"Return timestamp object for Org timestamp string ORG-TS-STRING.
Note that function `org-parse-time-string' is called, which
should be loaded before calling this function.
Hour/minute/second values are filled according to FILL: if
`begin', with 0; if `end', hour is filled with 23 and
minute/second with 59. Note that `org-parse-time-string' does
not support timestamps that contain seconds."
(pcase-let* ((`(,second ,minute ,hour ,day ,month ,year)
(org-parse-time-string org-ts-string 'nodefault)))
(pcase-exhaustive fill
('begin (unless second
(setf second 0))
(unless minute
(setf minute 0))
(unless hour
(setf hour 0)))
('end (if (not (or hour minute))
(progn
;; `hour' and `minute' are nil, so set them and `second'.
;; `org-parse-time-string' sets the second to 0 even if
;; NODEFAULT is non-nil.
(setf second 59
minute 59
hour 23))
;; FIXME: Some of these could be omitted.
(unless second
(setf second 59))
(unless minute
(setf minute 59))
(unless hour
(setf hour 23))))
(_ (error "FILL must be `begin' or `end'")))
(make-ts :second second :minute minute :hour hour :day day :month month :year year)))
;;;; Functions
(cl-defun ts-apply (&rest args)
"Return new timestamp based on TS with new slot values from ARGS.
Fill timestamp slots, overwrite given slot values, and return new
timestamp with Unix timestamp value derived from new slot values.
SLOTS is a list of alternating key-value pairs like that passed
to `make-ts'."
(declare (advertised-calling-convention (&rest slots ts) nil))
(-let* (((&keys :second :minute :hour :day :month :year) args)
(ts (-last-item args)))
;; MAYBE: Add timezone offset?
(setf ts (ts-fill ts))
(when second
(setf (ts-second ts) second))
(when minute
(setf (ts-minute ts) minute))
(when hour
(setf (ts-hour ts) hour))
(when day
(setf (ts-day ts) day))
(when month
(setf (ts-month ts) month))
(when year
(setf (ts-year ts) year))
(ts-update ts)))
(defmacro ts-define-fill ()
"Define function that fills all applicable slots of a `ts' from its `unix' slot."
(let* ((slots (->> (cl-struct-slot-info 'ts)
(--select (and (not (member (car it) '(unix internal cl-tag-slot)))
(plist-get (cddr it) :constructor)))
(--map (list (intern (concat ":" (symbol-name (car it))))
(cddr it)))))
(keywords (-map #'car slots))
(constructors (->> slots
(--map (plist-get (cadr it) :constructor))
-non-nil))
(types (--map (plist-get (cadr it) :type) slots))
(format-string (s-join "\f" constructors))
(value-conversions (cl-loop for type in types
for keyword in keywords
for i from 0
for val = `(nth ,i time-values)
append (list keyword (pcase type
('integer `(string-to-number ,val))
(_ val))))))
;; MAYBE: Construct the record manually? Probably not worth it, but might eke out a bit more speed.
`(defun ts-fill (ts &optional zone)
"Return TS having filled all slots from its Unix timestamp.
This is non-destructive. ZONE is passed to `format-time-string',
which see."
;; MAYBE: Use `decode-time' instead of `format-time-string'? It provides most of the values we need. Should benchmark.
(let ((time-values (save-match-data
(split-string (format-time-string ,format-string (ts-unix ts) zone) "\f"))))
(make-ts :unix (ts-unix ts) ,@value-conversions)))))
(ts-define-fill)
;; FIXME: This fails, and I'm not sure if it's a limitation of gvs or if I did something wrong:
;; (ts-incf (ts-moy (ts-now)))
(defun ts-difference (a b)
"Return difference in seconds between timestamps A and B."
;; MAYBE: Use absolute values so arg order doesn't matter.
(- (ts-unix a) (ts-unix b)))
(defalias 'ts-diff 'ts-difference)
(defun ts-human-duration (seconds)
"Return plist describing duration SECONDS.
List includes years, days, hours, minutes, and seconds. This is
a simple calculation that does not account for leap years, leap
seconds, etc."
;; TODO: Add weeks.
(cl-macrolet ((dividef (place divisor)
;; Divide PLACE by DIVISOR, set PLACE to the remainder, and return the quotient.
`(prog1 (/ ,place ,divisor)
(setf ,place (% ,place ,divisor)))))
(let* ((seconds (floor seconds))
(years (dividef seconds 31536000))
(days (dividef seconds 86400))
(hours (dividef seconds 3600))
(minutes (dividef seconds 60)))
(list :years years :days days :hours hours :minutes minutes :seconds seconds))))
;; See also the built-in function `format-seconds', which I seem to have
;; overlooked before writing this. However, a quick benchmark, run
;; 100,000 times, shows that, when controllable formatting is not needed,
;; `ts-human-format-duration' is much faster and generates less garbage:
;; | Form | x faster than next | Total runtime | # of GCs | Total GC runtime |
;; |--------------------------+--------------------+---------------+----------+------------------|
;; | ts-human-format-duration | 5.82 | 0.832945 | 3 | 0.574929 |
;; | format-seconds | slowest | 4.848253 | 17 | 3.288799 |
(cl-defun ts-human-format-duration (seconds &optional abbreviate)
"Return human-formatted string describing duration SECONDS.
If SECONDS is less than 1, returns \"0 seconds\". If ABBREVIATE
is non-nil, return a shorter version, without spaces. This is a
simple calculation that does not account for leap years, leap
seconds, etc."
;; FIXME: Doesn't work with negative values, even though `ts-human-duration' does.
(if (< seconds 1)
(if abbreviate "0s" "0 seconds")
(cl-macrolet ((format> (place)
;; When PLACE is greater than 0, return formatted string using its symbol name.
`(when (> ,place 0)
(format "%d%s%s" ,place
(if abbreviate "" " ")
(if abbreviate
,(substring (symbol-name place) 0 1)
,(symbol-name place)))))
(join-places (&rest places)
;; Return string joining the names and values of PLACES.
`(->> (list ,@(cl-loop for place in places
collect `(format> ,place)))
-non-nil
(s-join (if abbreviate "" ", ")))))
(-let* (((&plist :years :days :hours :minutes :seconds) (ts-human-duration seconds)))
(join-places years days hours minutes seconds)))))
;;;;; Adjustors
;; These functions are very cool, and they may make the adjust function unnecessary, because you can
;; do something like (ts-adjust 'moy 120 (ts-now)) and get a timestamp 10 years in the future.
;; FIXME: Note that not all slots can be used to adjust the timestamp.
;; For example, the day-of-week-num slot doesn't have any effect.
;;;;;; Non-destructive
;; These non-destructive versions take the slot symbol as an argument and the object last, and they
;; return the timestamp object rather than the new slot value, making them suitable for use in
;; threading macros when the initial form is a sexp rather than a value or variable.
(defun ts-adjust (&rest adjustments)
"Return new timestamp having applied ADJUSTMENTS to TS.
ADJUSTMENTS should be a series of alternating SLOTS and VALUES by
which to adjust them. For example, this form returns a new
timestamp that is 47 hours into the future:
(ts-adjust 'hour -1 'day +2 (ts-now))
Since the timestamp argument is last, it's suitable for use in a
threading macro."
(declare (advertised-calling-convention (&rest adjustments ts) nil))
(let* ((ts (-last-item adjustments))
(adjustments (nbutlast adjustments))
(ts (ts-fill ts)))
(cl-loop for (slot change) on adjustments by #'cddr
do (cl-incf (cl-struct-slot-value 'ts slot ts) change))
(ts-update ts)))
(defsubst ts-inc (slot value ts)
"Return a new timestamp based on TS with its SLOT incremented by VALUE.
SLOT should be specified as a plain symbol, not a keyword."
(setq ts (ts-fill ts))
(cl-incf (cl-struct-slot-value 'ts slot ts) value)
(ts-update ts))
(defsubst ts-dec (slot value ts)
"Return a new timestamp based on TS with its SLOT decremented by VALUE.
SLOT should be specified as a plain symbol, not a keyword."
(setq ts (ts-fill ts))
(cl-decf (cl-struct-slot-value 'ts slot ts) value)
(ts-update ts))
;;;;;; Generalized variables
;; These destructive versions act like `cl-incf'. They are slightly less suitable for use in
;; threading macros, because it's not possible to do, e.g. this:
;; (-> (ts-now)
;; (ts-adjustf 'day 1))
;; ...because `ts-now' doesn't return a generalized variable. But this still works:
;; (let ((ts (ts-now)))
;; (-> ts (ts-adjustf 'dom 1)))
;; TODO: Look at `cl-incf' implementation, consider whether we should imitate it.
(defmacro ts-adjustf (ts &rest adjustments)
"Return timestamp TS having applied ADJUSTMENTS.
This function is destructive, as it calls `setf' on TS.
ADJUSTMENTS should be a series of alternating SLOTS and VALUES by
which to adjust them. For example, this form adjusts a timestamp
to 47 hours into the future:
(let ((ts (ts-now)))
(ts-adjustf ts 'hour -1 'day +2))"
;; MAYBE: Is it possible to make this kind of macro work in a threading macro by taking its TS
;; argument last? It only seems to work if the TS is a symbol rather than a form, because of how
;; generalized variables work, but that makes it less useful and more error-prone.
`(progn
;; We use the accessor functions rather than `cl-struct-slot-value', because it's slightly
;; faster to use the accessors, even though `cl-struct-slot-value' is supposed to be
;; byte-compiled to essentially the same thing (although it's possible I'm doing something
;; wrong).
(setf ,ts (ts-fill ,ts))
,@(cl-loop for (slot change) on adjustments by #'cddr
for accessor = (intern (concat "ts-" (symbol-name (cadr slot))))
collect `(cl-incf (,accessor ,ts) ,change))
(setf ,ts (ts-update ,ts))))
(cl-defmacro ts-incf (place &optional (value 1))
"Increment `ts' PLACE by VALUE (default 1) and return the new value of PLACE.
Updates its `unix' slot accordingly."
`(progn
(setf ,(cadr place) (ts-fill ,(cadr place)))
(prog1
(cl-incf ,place ,value)
(setf ,(cadr place)
(ts-update ,(cadr place))))))
(cl-defmacro ts-decf (place &optional (value 1))
"Decrement `ts' PLACE by VALUE (default 1) and return the new value of PLACE.
Updates its `unix' slot accordingly."
`(progn
(setf ,(cadr place) (ts-fill ,(cadr place)))
(prog1
(cl-decf ,place ,value)
(setf ,(cadr place)
(ts-update ,(cadr place))))))
;;;;; Comparators
(defsubst ts-in (beg end ts)
"Return non-nil if TS is within range BEG to END, inclusive.
All arguments should be `ts' structs."
(and (ts<= beg ts)
(ts<= ts end)))
(defun ts= (a b)
"Return non-nil if timestamp A is the same as timestamp B.
Compares only the timestamps' `unix' slots. Note that a
timestamp's Unix slot is a float and may differ by less than one
second, causing them to be unequal even if all of the formatted
parts of the timestamp are the same."
(= (ts-unix a) (ts-unix b)))
(defun ts< (a b)
"Return non-nil if timestamp A is less than timestamp B."
(< (ts-unix a) (ts-unix b)))
(defun ts<= (a b)
"Return non-nil if timestamp A is <= timestamp B."
(<= (ts-unix a) (ts-unix b)))
(defun ts> (a b)
"Return non-nil if timestamp A is greater than timestamp B."
(> (ts-unix a) (ts-unix b)))
(defun ts>= (a b)
"Return non-nil if timestamp A is >= timestamp B."
(>= (ts-unix a) (ts-unix b)))
;;;; Footer
(provide 'ts)
;;; ts.el ends here