Skip to content

Instantly share code, notes, and snippets.

@spacegangster
Last active April 4, 2021 13:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save spacegangster/7f4554e20c0e0b3bc2d913fa653aa8cc to your computer and use it in GitHub Desktop.
Save spacegangster/7f4554e20c0e0b3bc2d913fa653aa8cc to your computer and use it in GitHub Desktop.
Parsing and spec-ing recurrence rule rules in Clojure
(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) {}))
@spacegangster
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment