///|
pub fn validate_calendar(calendar : Calendar) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if calendar.version is None {
diagnostics.push(
warning("missing-version", "VCALENDAR does not declare VERSION"),
)
} else if calendar.version != Some("2.0") {
diagnostics.push(warning("version-not-2", "MoonCal v1 expects VERSION:2.0"))
}
if calendar.prodid is None {
diagnostics.push(
warning("missing-prodid", "VCALENDAR does not declare PRODID"),
)
}
if calendar.events.is_empty() &&
calendar.tasks.is_empty() &&
calendar.freebusy.is_empty() {
diagnostics.push(info("no-events", "VCALENDAR contains no VEVENT records"))
}
collect_duplicate_uid_diagnostics(calendar, diagnostics)
for event in calendar.events {
diagnostics.append(validate_event(event))
}
for task in calendar.tasks {
diagnostics.append(validate_task(task))
}
for item in calendar.freebusy {
diagnostics.append(validate_freebusy(item))
}
diagnostics
}
///|
pub fn validate_event(event : Event) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if event.summary.trim().is_empty() {
diagnostics.push(
info("empty-summary", "VEVENT \{event.uid} has no SUMMARY"),
)
}
if event.rrule is Some(rule) {
if rule.count is None && rule.until is None {
diagnostics.push(
warning(
"unbounded-rrule",
"VEVENT \{event.uid} has RRULE without COUNT or UNTIL",
),
)
}
}
if event.duration is Some(duration) {
if duration.negative {
diagnostics.push(
error("negative-duration", "VEVENT \{event.uid} has negative DURATION"),
)
}
if duration.is_zero() {
diagnostics.push(
info("zero-duration", "VEVENT \{event.uid} has zero DURATION"),
)
}
}
if event.status is Some(status) {
if !is_known_status(status) {
diagnostics.push(
warning("unknown-status", "VEVENT \{event.uid} has non-standard STATUS"),
)
}
}
if event.sequence is Some(sequence) {
if sequence < 0 {
diagnostics.push(
error("negative-sequence", "VEVENT \{event.uid} has negative SEQUENCE"),
)
}
}
diagnostics
}
///|
pub fn validate_task(task : Task) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if task.summary.trim().is_empty() {
diagnostics.push(
info("empty-task-summary", "VTODO \{task.uid} has no SUMMARY"),
)
}
if task.status is Some(status) {
if !is_known_task_status(status) {
diagnostics.push(
warning(
"unknown-task-status",
"VTODO \{task.uid} has non-standard STATUS",
),
)
}
if status == "COMPLETED" && task.completed is None {
diagnostics.push(
info(
"completed-without-timestamp",
"VTODO \{task.uid} is completed but has no COMPLETED timestamp",
),
)
}
if status == "NEEDS-ACTION" && task.completed is Some(_) {
diagnostics.push(
warning(
"open-task-has-completed-time",
"VTODO \{task.uid} has COMPLETED while STATUS is NEEDS-ACTION",
),
)
}
}
if task.priority is Some(priority) {
if priority < 0 || priority > 9 {
diagnostics.push(
error(
"priority-out-of-range",
"VTODO \{task.uid} PRIORITY must be 0..9",
),
)
}
}
if task.percent_complete is Some(percent) {
if percent < 0 || percent > 100 {
diagnostics.push(
error(
"percent-out-of-range",
"VTODO \{task.uid} PERCENT-COMPLETE must be 0..100",
),
)
} else if percent == 100 && !task_is_completed(task) {
diagnostics.push(
warning(
"percent-complete-status-mismatch",
"VTODO \{task.uid} is 100 percent complete but not marked completed",
),
)
}
}
if task.start is Some(start) {
if task.due is Some(due) {
if due.before(start) {
diagnostics.push(
error(
"task-due-before-start",
"VTODO \{task.uid} DUE is before DTSTART",
),
)
}
}
}
if task.completed is Some(completed) {
if task.created is Some(created) {
if completed.before(created) {
diagnostics.push(
warning(
"completed-before-created",
"VTODO \{task.uid} COMPLETED is before CREATED",
),
)
}
}
}
for related in task.related_to {
if related == task.uid {
diagnostics.push(
warning(
"task-related-to-self",
"VTODO \{task.uid} RELATED-TO points to itself",
),
)
}
}
diagnostics
}
///|
pub fn validate_freebusy(item : FreeBusy) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
let label = match item.uid {
Some(uid) => uid
None => "(without UID)"
}
if item.uid is None {
diagnostics.push(warning("freebusy-missing-uid", "VFREEBUSY has no UID"))
}
if item.dtstamp is None {
diagnostics.push(
warning("freebusy-missing-dtstamp", "VFREEBUSY \{label} has no DTSTAMP"),
)
}
if item.periods.is_empty() {
diagnostics.push(
info("freebusy-no-periods", "VFREEBUSY \{label} has no FREEBUSY periods"),
)
}
if item.start is Some(start) {
if item.end is Some(end) {
if end.before(start) {
diagnostics.push(
error(
"freebusy-end-before-start",
"VFREEBUSY \{label} DTEND is before DTSTART",
),
)
}
}
}
for period in item.periods {
diagnostics.append(
validate_freebusy_period(period, label, item.start, item.end),
)
}
collect_overlapping_freebusy_periods(item.periods, label, diagnostics)
diagnostics
}
///|
fn validate_freebusy_period(
period : FreeBusyPeriod,
owner : String,
window_start : DateTime?,
window_end : DateTime?,
) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if !is_known_freebusy_type(period.busy_type) {
diagnostics.push(
warning(
"unknown-freebusy-type",
"VFREEBUSY \{owner} has non-standard FBTYPE \{period.busy_type}",
),
)
}
if period.end.before(period.start) {
diagnostics.push(
error(
"freebusy-period-backwards",
"VFREEBUSY \{owner} has backwards period",
),
)
} else if period.end == period.start {
diagnostics.push(
info("freebusy-period-zero", "VFREEBUSY \{owner} has zero-length period"),
)
}
if window_start is Some(start) {
if period.start.before(start) {
diagnostics.push(
warning(
"freebusy-period-before-window",
"VFREEBUSY \{owner} has a period before DTSTART",
),
)
}
}
if window_end is Some(end) {
if period.end.after(end) {
diagnostics.push(
warning(
"freebusy-period-after-window",
"VFREEBUSY \{owner} has a period after DTEND",
),
)
}
}
diagnostics
}
///|
fn collect_duplicate_uid_diagnostics(
calendar : Calendar,
diagnostics : Array[Diagnostic],
) -> Unit {
let ids : Array[(String, String)] = []
for event in calendar.events {
ids.push((event.uid, "VEVENT"))
}
for task in calendar.tasks {
ids.push((task.uid, "VTODO"))
}
for item in calendar.freebusy {
if item.uid is Some(uid) {
ids.push((uid, "VFREEBUSY"))
}
}
for i in 0.. Unit {
for i in 0.. Bool {
status == "TENTATIVE" ||
status == "CONFIRMED" ||
status == "CANCELLED" ||
status == "NEEDS-ACTION" ||
status == "COMPLETED" ||
status == "IN-PROCESS" ||
status == "DRAFT" ||
status == "FINAL"
}
///|
fn is_known_task_status(status : String) -> Bool {
status == "NEEDS-ACTION" ||
status == "COMPLETED" ||
status == "IN-PROCESS" ||
status == "CANCELLED"
}
///|
fn is_known_freebusy_type(value : String) -> Bool {
value == "FREE" ||
value == "BUSY" ||
value == "BUSY-UNAVAILABLE" ||
value == "BUSY-TENTATIVE"
}
///|
fn info(code : String, message : String) -> Diagnostic {
Diagnostic::{ code, message, severity: "info" }
}
///|
fn warning(code : String, message : String) -> Diagnostic {
Diagnostic::{ code, message, severity: "warning" }
}
///|
fn error(code : String, message : String) -> Diagnostic {
Diagnostic::{ code, message, severity: "error" }
}