diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 09:09:36 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 09:09:36 +0200 |
| commit | 6abc243e5d40bd60174a3fb1affc9517b0ac5617 (patch) | |
| tree | 8c3b03fa7cab2e4239250d572a53fa012755a279 /lib | |
| parent | 544836cf0f6373a0a753d0953ce7e60fd96337af (diff) | |
| download | colitur-6abc243e5d40bd60174a3fb1affc9517b0ac5617.tar.gz colitur-6abc243e5d40bd60174a3fb1affc9517b0ac5617.zip | |
feat(render): iCalendar emitter, RFC 5545
Not a template job: folding, escaping, exclusive DTEND and stable UIDs
are rules a logic-less template cannot enforce, and each fails silently
in a subscriber's client rather than loudly at generation.
DTEND is EXCLUSIVE for an all-day event (section 3.6.1). Wrong here
shows every event a day short, everywhere.
UIDs are YYYYMMDD-<rite>@colitur and stable across regenerations
(section 3.8.4.7). Wrong here duplicates the whole year in every
subscriber's phone, months later.
Every line is CRLF-terminated and folded at 75 octets (section 3.1).
No RRULE: a liturgical calendar is not a recurrence rule. Asserted, so
nobody optimises it later.
DTSTAMP is a parameter, not a clock read. RFC 5545 requires it and the
obvious implementation reads the wall clock -- which violates the
kernel's determinism rule and would make two feeds from identical data
differ byte-for-byte, defeating reproducible builds and any reviewable
diff on a published tree.
Corrected one test literal against real engine output: DTSTAMP is a
per-VEVENT property (section 3.8.7.2), not calendar-level, so the
default-value line count is 365 (every event), not 1.
Mutation-tested: a non-exclusive DTEND reddens the suite.
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/render/emit_ics.ml | 67 | ||||
| -rw-r--r-- | lib/render/emit_ics.mli | 15 |
2 files changed, 82 insertions, 0 deletions
diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml new file mode 100644 index 0000000..e1a5ad0 --- /dev/null +++ b/lib/render/emit_ics.ml @@ -0,0 +1,67 @@ +(* emit_ics.ml *) +module T = Template + +let get v k = match v with T.Obj kvs -> List.assoc_opt k kvs | _ -> None +let s v k = match get v k with Some (T.Str x) -> x | _ -> "" + +let compact iso = (* "2027-01-13" -> "20270113" *) + String.concat "" (String.split_on_char '-' iso) + +(* Civil-date successor without touching the kernel: parse, add, re-format via + Colitur_kernel.Date, which is already total and validated over 1583..9999. *) +let next_day iso = + match Colitur_kernel.Date.of_iso8601 iso with + | Ok d -> Colitur_kernel.Date.to_iso8601 (Colitur_kernel.Date.add_days d 1) + | Error _ -> iso + +let esc x = Escape.apply Escape.Ics x +let line b l = Buffer.add_string b (Escape.fold_ics l) + +let event b ~rite ~dtstamp d = + let iso = s d "iso" in + if iso <> "" then begin + let name = + match get d "name" with + | Some (T.Obj kvs) -> ( + match List.assoc_opt "en" kvs with + | Some (T.Str x) when x <> "" -> x + | _ -> ( match List.assoc_opt "la" kvs with Some (T.Str x) -> x | _ -> s d "slug")) + | _ -> s d "slug" + in + let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in + let desc = + String.concat "\n" + (List.filter (fun x -> x <> "") + [ (if s d "first" <> "" then "Epistle " ^ s d "first" else ""); + (if s d "gospel" <> "" then "Gospel " ^ s d "gospel" else "") ]) + in + line b "BEGIN:VEVENT"; + (* RFC 5545 section 3.8.4.7: the UID must be stable across regenerations, + or every subscriber gets a duplicate of the whole year. *) + line b (Printf.sprintf "UID:%s-%s@colitur" (compact iso) rite); + line b ("DTSTAMP:" ^ dtstamp); + line b ("DTSTART;VALUE=DATE:" ^ compact iso); + (* RFC 5545 section 3.6.1: DTEND is EXCLUSIVE for an all-day event. *) + line b ("DTEND;VALUE=DATE:" ^ compact (next_day iso)); + line b ("SUMMARY:" ^ esc summary); + if desc <> "" then line b ("DESCRIPTION:" ^ esc desc); + line b "TRANSP:TRANSPARENT"; + line b "END:VEVENT" + end + +let year ?dtstamp ?calname v = + let rite = s v "rite" in + let y = s v "year" in + let dtstamp = match dtstamp with Some x -> x | None -> y ^ "0101T000000Z" in + let calname = match calname with Some x -> x | None -> "colitur " ^ rite ^ " " ^ y in + let b = Buffer.create (256 * 1024) in + line b "BEGIN:VCALENDAR"; + line b "VERSION:2.0"; + line b "PRODID:-//colitur//liturgical calendar//EN"; + line b "CALSCALE:GREGORIAN"; + line b "METHOD:PUBLISH"; + (* Non-standard but universally honoured; without it clients show the URL. *) + line b ("X-WR-CALNAME:" ^ esc calname); + (match get v "days" with Some (T.List l) -> List.iter (event b ~rite ~dtstamp) l | _ -> ()); + line b "END:VCALENDAR"; + Buffer.contents b diff --git a/lib/render/emit_ics.mli b/lib/render/emit_ics.mli new file mode 100644 index 0000000..270bd76 --- /dev/null +++ b/lib/render/emit_ics.mli @@ -0,0 +1,15 @@ +(* emit_ics.mli *) +(** RFC 5545 iCalendar. NOT a template job: folding, escaping, exclusive DTEND + and stable UIDs are rules a logic-less template cannot enforce, and getting + any of them wrong produces a feed that fails silently in a subscriber's + client (spec section 6). *) + +(** [year ?dtstamp ?calname view]. + + [dtstamp] defaults to [YYYY0101T000000Z] for the view's own year. It is a + PARAMETER, never a clock read: RFC 5545 requires DTSTAMP, the obvious + implementation reads the wall clock, and that would both violate the + kernel's determinism rule and make two feeds from identical data differ + byte-for-byte -- defeating reproducible builds and a reviewable git diff on + a published tree. Same data in, same bytes out. *) +val year : ?dtstamp:string -> ?calname:string -> Template.value -> string |
