Last active
April 4, 2021 13:24
-
-
Save spacegangster/7f4554e20c0e0b3bc2d913fa653aa8cc to your computer and use it in GitHub Desktop.
Parsing and spec-ing recurrence rule rules in Clojure
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns rrule.rrule | |
"Parse RRULE as in | |
https://icalendar.org/iCalendar-RFC-5545/3-3-10-recurrence-rule.html | |
https://tools.ietf.org/html/rfc5545#section-3.3.10 | |
;; Links ;; | |
https://github.com/dmfs/lib-recur | |
https://github.com/jcvanderwal/google-rfc-2445 | |
https://github.com/mangstadt/biweekly | |
https://github.com/ical4j/ical4j" | |
(:require [clojure.string :as str] | |
[clojure.spec.alpha :as s]) | |
(:import (java.text SimpleDateFormat))) | |
(defn update-if-present [m k f & args] | |
(if (contains? m k) | |
(assoc m k (apply f (cons (get m k) args))) | |
m)) | |
(comment | |
:rr/freq #"^FREQ=" | |
:rr/until #"^UNTIL=" ; enddate | |
:rr/count #"^COUNT=" ; digit | |
:rr/interval #"^INTERVAL=" ; digit | |
:rr/by-second #"^BYSECOND=" ; byseclist | |
:rr/by-minute #"^BYMINUTE=" | |
:rr/by-hour #"^BYHOUR=" | |
:rr/by-day #"^BYDAY=" | |
:rr/by-month-day #"^BYMONTHDAY=" | |
:rr/by-year-day #"^BYYEARDAY=" | |
:rr/by-week-no #"^BYWEEKNO=" | |
:rr/by-month #"^BYMONTH=" | |
:rr/by-set-pos #"^BYSETPOS=" | |
:rr/wkst #"^WKST=") | |
(defn raw-rrule-part [^String rrule-part] | |
(let [[key val] (str/split rrule-part #"=")] | |
[(keyword "rr" (.toLowerCase key)) val])) | |
(defn rrule-components-raw [^String rrule-str] | |
(let [parts (str/split rrule-str #";")] | |
(into {} (mapv raw-rrule-part parts)))) | |
(assert (= #:rr{:interval "1", :freq "DAILY"} | |
(rrule-components-raw "INTERVAL=1;FREQ=DAILY"))) | |
(assert (= #:rr{:interval "1", :freq "DAILY"} | |
(rrule-components-raw "INTERVAL=1;FREQ=DAILY"))) | |
(def str->weekday | |
{"MO" :weekday/monday | |
"TU" :weekday/tuesday | |
"WE" :weekday/wednesday | |
"TH" :weekday/thursday | |
"FR" :weekday/friday | |
"SA" :weekday/saturday | |
"SU" :weekday/sunday}) | |
(def weekdays-set | |
(set (vals str->weekday))) | |
(defn read-num-enum [^String nums-str] | |
(let [nums (str/split nums-str #",")] | |
(mapv read-string nums))) | |
(assert (= [1 2 -3] (read-num-enum "1,2,-3"))) | |
(defn read-weekday-enum [^String weekdays] | |
(let [weekdays (str/split weekdays #",")] | |
(mapv str->weekday weekdays))) | |
(assert (= [:weekday/wednesday :weekday/monday :weekday/friday :weekday/sunday :weekday/saturday :weekday/tuesday :weekday/thursday] | |
(read-weekday-enum "WE,MO,FR,SU,SA,TU,TH"))) | |
(def parse-date | |
#?(:cljs | |
(fn [date-str] | |
(let [fixed-date-str | |
(-> date-str | |
(str/replace #"([0-9]{2})Z$" ":$1-00:00") | |
(str/replace #"([0-9]{2}):([0-9]{2})-" ":$1:$2-") | |
(str/replace #"([0-9]{2})T([0-9]{2})" "-$1T$2") | |
(str/replace #"^([0-9]{4})([0-9]{2})" "$1-$2"))] | |
(js/Date. fixed-date-str))) | |
:clj | |
(fn [date-str] | |
(let [date-str (str/replace date-str #"Z$" "+0000") | |
sdf (SimpleDateFormat. "yyyyMMdd'T'HHmmssZ")] | |
(.parse sdf date-str))))) | |
(assert (= (parse-date "20201027T000000Z") | |
#inst"2020-10-27T00:00:00.000-00:00")) | |
(def by-key-renames | |
{:rr/bysecond :rr/by-second | |
:rr/byminute :rr/by-minute | |
:rr/byhour :rr/by-hour | |
:rr/byday :rr/by-day | |
:rr/bymonthday :rr/by-month-day | |
:rr/byweekno :rr/by-week-no | |
:rr/bymonth :rr/by-month | |
:rr/byyear :rr/by-year}) | |
(defn rrule-components [rrule-str] | |
(let [rrule-raw (rrule-components-raw rrule-str)] | |
(-> rrule-raw | |
(update :rr/freq #(keyword "rr.freq" (.toLowerCase %))) | |
(clojure.set/rename-keys by-key-renames) | |
(update-if-present :rr/count read-string) | |
(assoc :rr/interval (or (some-> rrule-raw :rr/interval read-string) 1)) | |
; | |
(update-if-present :rr/wkst str->weekday) | |
(update-if-present :rr/until parse-date) | |
; | |
(update-if-present :rr/by-second read-num-enum) | |
(update-if-present :rr/by-minute read-num-enum) | |
(update-if-present :rr/by-hour read-num-enum) | |
(update-if-present :rr/by-day read-weekday-enum) | |
(update-if-present :rr/by-month-day read-num-enum) | |
(update-if-present :rr/by-week-no read-num-enum) | |
(update-if-present :rr/by-month read-num-enum) | |
(update-if-present :rr/by-year read-num-enum)))) | |
(s/def :rr/freq | |
#{:rr.freq/secondly :rr.freq/minutely :rr.freq/hourly | |
:rr.freq/daily :rr.freq/weekly :rr.freq/monthly :rr.freq/yearly}) | |
(s/def :rr/interval pos-int?) | |
(s/def :rr/count pos-int?) | |
(s/def ::weekday weekdays-set) | |
(s/def :rr/by-minute (s/coll-of int?)) | |
(s/def :rr/by-second (s/coll-of int?)) | |
(s/def :rr/by-day (s/coll-of ::weekday)) | |
(s/def :rr/by-month-day (s/coll-of int?)) | |
(s/def ::week-no | |
(s/or ::pos-week (s/int-in 1 54) | |
::neg-week (s/int-in -53 0))) | |
(s/conform ::week-no -1) | |
(s/def :rr/by-week-no | |
; weeks of year 1..53 -1..-53 | |
(s/coll-of ::week-no)) | |
(s/def :rr/by-month (s/coll-of int?)) | |
(s/def :rr/by-set-pos (s/coll-of int?)) | |
(s/def :rr/wkst ::weekday) | |
(s/def :rr/rule-components | |
(s/keys :req [:rr/freq (or :rr/count :rr/until)])) | |
(def rc1 (rrule-components "INTERVAL=1;FREQ=DAILY;COUNT=10;WKST=MO;BYDAY=MO,TU")) | |
(assert (= #:rr{:interval 1, :freq :rr.freq/daily, | |
:count 10 :wkst :weekday/monday | |
:by-day [:weekday/monday :weekday/tuesday]} rc1)) | |
(s/assert :rr/rule-components rc1) | |
(comment | |
(reset! @(var s/registry-ref) {})) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey, I've been playing with recurrence rule parsing, in the end I've realised that I don't have enough time to build the engine to process that. I hope the links and spec could save you some time.