///|
/// Frequency ordered from the smallest to the largest recurrence period.
pub enum Frequency {
Secondly
Minutely
Hourly
Daily
Weekly
Monthly
Yearly
} derive(Eq, Compare, Debug)
///|
pub fn Frequency::code(self : Frequency) -> String {
match self {
Secondly => "SECONDLY"
Minutely => "MINUTELY"
Hourly => "HOURLY"
Daily => "DAILY"
Weekly => "WEEKLY"
Monthly => "MONTHLY"
Yearly => "YEARLY"
}
}
///|
pub fn frequency_from_code(code : String) -> Frequency? {
match code.to_upper() {
"SECONDLY" => Some(Secondly)
"MINUTELY" => Some(Minutely)
"HOURLY" => Some(Hourly)
"DAILY" => Some(Daily)
"WEEKLY" => Some(Weekly)
"MONTHLY" => Some(Monthly)
"YEARLY" => Some(Yearly)
_ => None
}
}
///|
/// A weekday optionally qualified with an ordinal such as 1MO or -1FR.
pub struct ByDay {
ordinal : Int?
weekday : Weekday
} derive(Eq, Compare, Debug)
///|
pub fn ByDay::new(weekday : Weekday, ordinal? : Int) -> ByDay {
{ ordinal, weekday }
}
///|
pub fn ByDay::to_string(self : ByDay) -> String {
match self.ordinal {
Some(value) => value.to_string() + self.weekday.code()
None => self.weekday.code()
}
}
///|
/// A fully typed subset of RFC 5545 RECUR. Empty arrays mean that the
/// corresponding BY rule is absent and DTSTART supplies the default.
pub struct RRule {
frequency : Frequency
interval : Int
count : Int?
until : DateTime?
week_start : Weekday
by_second : Array[Int]
by_minute : Array[Int]
by_hour : Array[Int]
by_day : Array[ByDay]
by_month_day : Array[Int]
by_year_day : Array[Int]
by_week_number : Array[Int]
by_month : Array[Int]
by_set_position : Array[Int]
} derive(Eq, Debug)
///|
pub fn RRule::new(frequency : Frequency) -> RRule {
{
frequency,
interval: 1,
count: None,
until: None,
week_start: Monday,
by_second: [],
by_minute: [],
by_hour: [],
by_day: [],
by_month_day: [],
by_year_day: [],
by_week_number: [],
by_month: [],
by_set_position: [],
}
}
///|
pub(all) enum Severity {
Error
Warning
Information
} derive(Eq, Compare, Debug)
///|
/// Machine-readable and human-friendly diagnostic returned by validation.
pub struct Diagnostic {
severity : Severity
code : String
field : String
message : String
suggestion : String?
} derive(Eq, Debug)
///|
fn diagnostic(
severity : Severity,
code : String,
field : String,
message : String,
suggestion? : String,
) -> Diagnostic {
{ severity, code, field, message, suggestion }
}
///|
pub suberror RRuleParseError {
EmptyRule
InvalidPart(String)
DuplicateField(String)
MissingFrequency
UnknownFrequency(String)
InvalidInteger(String, String)
InvalidWeekday(String)
InvalidUntil(String)
InvalidValue(String, String)
} derive(Eq, Debug)
///|
fn array_contains(values : Array[Int], target : Int) -> Bool {
for value in values {
if value == target {
return true
}
}
false
}
///|
fn byday_contains(values : Array[ByDay], target : ByDay) -> Bool {
for value in values {
if value == target {
return true
}
}
false
}
///|
fn canonical_ints(values : Array[Int]) -> Array[Int] {
let result : Array[Int] = []
for value in values {
if !array_contains(result, value) {
result.push(value)
}
}
result.sort()
result
}
///|
fn canonical_days(values : Array[ByDay]) -> Array[ByDay] {
let result : Array[ByDay] = []
for value in values {
if !byday_contains(result, value) {
result.push(value)
}
}
result.sort()
result
}
///|
fn join_ints(values : Array[Int]) -> String {
values.map(value => value.to_string()).join(",")
}
///|
fn join_days(values : Array[ByDay]) -> String {
values.map(value => value.to_string()).join(",")
}
///|
pub fn RRule::normalize(self : RRule) -> RRule {
{
frequency: self.frequency,
interval: self.interval,
count: self.count,
until: self.until,
week_start: self.week_start,
by_second: canonical_ints(self.by_second),
by_minute: canonical_ints(self.by_minute),
by_hour: canonical_ints(self.by_hour),
by_day: canonical_days(self.by_day),
by_month_day: canonical_ints(self.by_month_day),
by_year_day: canonical_ints(self.by_year_day),
by_week_number: canonical_ints(self.by_week_number),
by_month: canonical_ints(self.by_month),
by_set_position: canonical_ints(self.by_set_position),
}
}
///|
pub fn RRule::to_string(self : RRule) -> String {
let rule = self.normalize()
let parts : Array[String] = ["FREQ=" + rule.frequency.code()]
if rule.interval != 1 {
parts.push("INTERVAL=" + rule.interval.to_string())
}
match rule.count {
Some(value) => parts.push("COUNT=" + value.to_string())
None => ()
}
match rule.until {
Some(value) => parts.push("UNTIL=" + value.to_iso_string())
None => ()
}
if rule.week_start != Monday {
parts.push("WKST=" + rule.week_start.code())
}
if rule.by_second.length() > 0 {
parts.push("BYSECOND=" + join_ints(rule.by_second))
}
if rule.by_minute.length() > 0 {
parts.push("BYMINUTE=" + join_ints(rule.by_minute))
}
if rule.by_hour.length() > 0 {
parts.push("BYHOUR=" + join_ints(rule.by_hour))
}
if rule.by_day.length() > 0 {
parts.push("BYDAY=" + join_days(rule.by_day))
}
if rule.by_month_day.length() > 0 {
parts.push("BYMONTHDAY=" + join_ints(rule.by_month_day))
}
if rule.by_year_day.length() > 0 {
parts.push("BYYEARDAY=" + join_ints(rule.by_year_day))
}
if rule.by_week_number.length() > 0 {
parts.push("BYWEEKNO=" + join_ints(rule.by_week_number))
}
if rule.by_month.length() > 0 {
parts.push("BYMONTH=" + join_ints(rule.by_month))
}
if rule.by_set_position.length() > 0 {
parts.push("BYSETPOS=" + join_ints(rule.by_set_position))
}
parts.join(";")
}
///|
fn validate_range(
diagnostics : Array[Diagnostic],
field : String,
values : Array[Int],
minimum : Int,
maximum : Int,
zero_allowed? : Bool = true,
) -> Unit {
for value in values {
if value < minimum || value > maximum || (!zero_allowed && value == 0) {
diagnostics.push(
diagnostic(
Error,
"RRULE_RANGE",
field,
"value " + value.to_string() + " is outside the allowed range",
suggestion="use values from " +
minimum.to_string() +
" through " +
maximum.to_string(),
),
)
}
}
}
///|
pub fn RRule::validate(self : RRule) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if self.interval < 1 {
diagnostics.push(
diagnostic(
Error,
"RRULE_INTERVAL",
"INTERVAL",
"INTERVAL must be positive",
suggestion="use INTERVAL=1 or greater",
),
)
}
match self.count {
Some(value) if value < 1 =>
diagnostics.push(
diagnostic(
Error,
"RRULE_COUNT",
"COUNT",
"COUNT must be positive",
suggestion="remove COUNT or use COUNT=1 or greater",
),
)
_ => ()
}
if self.count is Some(_) && self.until is Some(_) {
diagnostics.push(
diagnostic(
Error,
"RRULE_BOUND_CONFLICT",
"COUNT,UNTIL",
"COUNT and UNTIL are mutually exclusive",
suggestion="keep only one recurrence bound",
),
)
}
validate_range(diagnostics, "BYSECOND", self.by_second, 0, 60)
validate_range(diagnostics, "BYMINUTE", self.by_minute, 0, 59)
validate_range(diagnostics, "BYHOUR", self.by_hour, 0, 23)
validate_range(
diagnostics,
"BYMONTHDAY",
self.by_month_day,
-31,
31,
zero_allowed=false,
)
validate_range(
diagnostics,
"BYYEARDAY",
self.by_year_day,
-366,
366,
zero_allowed=false,
)
validate_range(
diagnostics,
"BYWEEKNO",
self.by_week_number,
-53,
53,
zero_allowed=false,
)
validate_range(diagnostics, "BYMONTH", self.by_month, 1, 12)
validate_range(
diagnostics,
"BYSETPOS",
self.by_set_position,
-366,
366,
zero_allowed=false,
)
if self.by_set_position.length() > 0 &&
self.by_second.length() == 0 &&
self.by_minute.length() == 0 &&
self.by_hour.length() == 0 &&
self.by_day.length() == 0 &&
self.by_month_day.length() == 0 &&
self.by_year_day.length() == 0 &&
self.by_week_number.length() == 0 &&
self.by_month.length() == 0 {
diagnostics.push(
diagnostic(
Error,
"RRULE_SETPOS_CONTEXT",
"BYSETPOS",
"BYSETPOS requires another BY rule",
suggestion="add a BYDAY, BYMONTHDAY, or another BY rule",
),
)
}
for day in self.by_day {
match day.ordinal {
Some(0) =>
diagnostics.push(
diagnostic(
Error,
"RRULE_BYDAY_ZERO",
"BYDAY",
"weekday ordinal cannot be zero",
),
)
Some(value) if value < -53 || value > 53 =>
diagnostics.push(
diagnostic(
Error,
"RRULE_BYDAY_RANGE",
"BYDAY",
"weekday ordinal is outside -53 through 53",
),
)
Some(_) if self.frequency != Monthly && self.frequency != Yearly =>
diagnostics.push(
diagnostic(
Error,
"RRULE_BYDAY_CONTEXT",
"BYDAY",
"weekday ordinals are only valid with MONTHLY or YEARLY frequency",
),
)
_ => ()
}
}
if self.by_week_number.length() > 0 && self.frequency != Yearly {
diagnostics.push(
diagnostic(
Error,
"RRULE_WEEKNO_CONTEXT",
"BYWEEKNO",
"BYWEEKNO is only valid with YEARLY frequency",
),
)
}
if self.by_year_day.length() > 0 &&
(
self.frequency == Daily ||
self.frequency == Weekly ||
self.frequency == Monthly
) {
diagnostics.push(
diagnostic(
Error,
"RRULE_YEARDAY_CONTEXT",
"BYYEARDAY",
"BYYEARDAY is not valid with DAILY, WEEKLY, or MONTHLY frequency",
),
)
}
diagnostics
}
///|
pub fn RRule::is_valid(self : RRule) -> Bool {
for item in self.validate() {
if item.severity == Error {
return false
}
}
true
}