summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 09:19:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 09:19:14 +0200
commitdecb13fb17697f6d6d952c407f2173a714164804 (patch)
tree7c77e73aac02372df4e26b549029379f0b2a0756
parent6abc243e5d40bd60174a3fb1affc9517b0ac5617 (diff)
downloadcolitur-decb13fb17697f6d6d952c407f2173a714164804.tar.gz
colitur-decb13fb17697f6d6d952c407f2173a714164804.zip
feat(cli): colitur emit -- csv, json, sexp, xml, ics
Reuses resolved_year_report's existing two-liturgical-year indexing rather than copying it: that walk owns the civil-vs-liturgical span reasoning, and a second copy would drift. It is refactored to return the days, with the printer layered on top, so day and readings behave identically -- which cli.t proves byte-for-byte. CSV emits one header for a whole multi-year run, not one per year. A reversed range is a usage error rather than silently empty output. Asserted in cli.t: two ics runs are byte-identical, because nothing in the path reads a clock.
-rw-r--r--bin/dune2
-rw-r--r--bin/main.ml166
-rw-r--r--man/colitur.1143
-rw-r--r--test/cli.t69
4 files changed, 359 insertions, 21 deletions
diff --git a/bin/dune b/bin/dune
index c79bbf1..376fe70 100644
--- a/bin/dune
+++ b/bin/dune
@@ -2,4 +2,4 @@
(name main)
(public_name colitur)
(package colitur)
- (libraries colitur_kernel rite_ef))
+ (libraries colitur_kernel rite_ef colitur_render))
diff --git a/bin/main.ml b/bin/main.ml
index 231d003..ec7fd71 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -314,12 +314,15 @@ let load_ef_data ?(user_overlays = []) () =
| Error msg -> Error msg
| Ok commons -> Ok (layer, lectionary, commons)))
-(* The resolved-year walk, shared by [day_report] and [readings_report]: they
- differ only in how each day is printed, and the two-liturgical-year
- indexing below (with its own reasoning about civil-vs-liturgical spans) is
- exactly the part that must not be duplicated and drift. [line] is the only
- difference between the two commands. *)
-let resolved_year_report ~line ~overlays y =
+(* The resolved-year walk, shared by [day_report], [readings_report] and
+ [emit_report] (Task 8): they differ only in what happens to each day, and
+ the two-liturgical-year indexing below (with its own reasoning about
+ civil-vs-liturgical spans) is exactly the part that must not be duplicated
+ and drift. [resolved_year_days] owns that walk and returns the resolved
+ days, in date order, for one civil year; every caller layers its own
+ handling (a line-printer, an accumulator for a whole-year [Template.value])
+ on top rather than repeating the indexing. *)
+let resolved_year_days ~overlays y =
match load_ef_data ~user_overlays:overlays () with
| Error msg ->
Printf.eprintf "colitur: %s\n" msg;
@@ -341,9 +344,10 @@ let resolved_year_report ~line ~overlays y =
let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in
let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in
let d = ref jan1 in
+ let acc = ref [] in
while D.compare !d dec31 <= 0 do
(match Hashtbl.find_opt by_rata (D.to_rata !d) with
- | Some day -> line day
+ | Some day -> acc := day :: !acc
| None ->
(* Unreachable for any [y] in 1583..9999: the two indexed
liturgical years jointly cover [year_start (y-1), year_start
@@ -357,11 +361,71 @@ let resolved_year_report ~line ~overlays y =
loudly on stderr rather than as a quietly short year. *)
Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d));
d := D.add_days !d 1
- done
+ done;
+ List.rev !acc
+
+let resolved_year_report ~line ~overlays y =
+ List.iter line (resolved_year_days ~overlays y)
let day_report ~overlays y = resolved_year_report ~line:day_line ~overlays y
let readings_report ~overlays y = resolved_year_report ~line:readings_line ~overlays y
+(* Task 8: `colitur emit` -- the five template-family emitters built in
+ Tasks 5-7, wired to a year RANGE rather than a single year, because a
+ published feed (ics) or a data export (csv/json/xml) is usually wanted
+ for more than one civil year at a time. Reuses [resolved_year_days]
+ rather than re-walking the two-liturgical-year index: see that
+ function's own comment.
+
+ CSV is the one format that spans years in a single stream deliberately
+ printed as ONE header followed by every year's rows: emitting a fresh
+ header per year would make `wc -l` and `awk 'NR>1'` both wrong on a
+ multi-year run, and nothing about RFC 4180 requires a header per file
+ rather than per stream. json/xml/sexp/ics are printed once per year
+ instead -- concatenating whole JSON objects or VCALENDARs into one
+ stream is what each of those formats itself expects a multi-document
+ feed to look like (SEXP: printed one form per line, matching the
+ sexp-per-day shape [Liturgical_day.t] already uses elsewhere in this
+ file; XML: one document per year, the schema's own root is a single
+ year; ICS: one VCALENDAR per year, valid to concatenate for a
+ subscriber that reads multiple files). *)
+let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y =
+ if from_y > to_y then begin
+ Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y;
+ exit 2
+ end;
+ for y = from_y to to_y do
+ let days = resolved_year_days ~overlays y in
+ let v =
+ Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
+ in
+ match format with
+ | "csv" ->
+ (* One header for the whole run, not one per year. *)
+ let body = Colitur_render.Emit_csv.year v in
+ if y = from_y then print_string body
+ else
+ print_string
+ (match String.index_opt body '\n' with
+ | Some i -> String.sub body (i + 1) (String.length body - i - 1)
+ | None -> body)
+ | "json" -> print_string (Colitur_render.Emit_json.year v)
+ | "xml" -> print_string (Colitur_render.Emit_xml.year v)
+ | "ics" -> print_string (Colitur_render.Emit_ics.year ?dtstamp v)
+ | "sexp" ->
+ List.iter
+ (fun d ->
+ print_string
+ (Sexplib.Sexp.to_string_hum
+ (Colitur_kernel.Liturgical_day.sexp_of_t
+ Rite_ef.Vocab_ef.sexp_of_season Rite_ef.Vocab_ef.sexp_of_rank d));
+ print_newline ())
+ days
+ | other ->
+ Printf.eprintf "colitur: unknown format %S (want csv, json, sexp, xml or ics)\n" other;
+ exit 2
+ done
+
(* Help and usage are deliberately DIFFERENT things, and the difference is the
Unix convention rather than a preference: asking for help is a request that
SUCCEEDED, so [--help] prints to stdout and exits 0 (it can be piped into a
@@ -388,6 +452,9 @@ usage:
colitur day <year> the resolved day identity, one line per day
colitur readings <year> the Mass reading citations, one line per day
colitur day|readings <year> --overlay FILE [--overlay FILE ...]
+ colitur emit --format csv|json|sexp|xml|ics --from Y --to Y
+ [--overlay FILE ...] [--dtstamp S]
+ render a resolved year range through one of five emitters
colitur new-overlay print a starter overlay file to stdout
colitur convert FILE.ini flat INI overlay -> S-expression, on stdout
colitur check FILE ... load an overlay, say what it does, exit 2 if not
@@ -407,6 +474,13 @@ output formats:
day stays space-separated; that is why they are separate commands rather
than extra columns.
+ emit one schema (season, week, slug, rank, colour, subject, names,
+ citations, commemorations), rendered five ways: csv (RFC 4180,
+ one header for the whole run), json, sexp, xml (schema/colitur-
+ v1.xsd) and ics (RFC 5545). --from/--to give a civil-year range,
+ inclusive. --dtstamp fixes the ics DTSTAMP so two runs over the
+ same data are byte-identical -- the engine reads no clock.
+
overlays:
--overlay FILE (repeatable, ordered; -o) applies a user calendar ON TOP of
the shipped universal one, never instead of it, so local feasts
@@ -485,12 +559,33 @@ let with_year ys f =
[--overlay] accumulates in the order given, and that order is load-bearing
({!Overlay.merge} is last-writer-wins), so the list is reversed exactly
- once at the end rather than callers guessing. *)
+ once at the end rather than callers guessing.
+
+ [--format]/[--from]/[--to]/[--dtstamp] (Task 8, `emit`) are each single-
+ valued, unlike [--overlay], so they are plain [string option] fields
+ rather than accumulating lists. *)
+type parsed_args = {
+ overlays : string list;
+ format : string option;
+ from_y : string option;
+ to_y : string option;
+ dtstamp : string option;
+ positional : string list;
+}
+
let parse_args argv =
- let rec go overlays positional = function
- | [] -> Ok (List.rev overlays, List.rev positional)
- | ("--overlay" | "-o") :: path :: rest -> go (path :: overlays) positional rest
+ let rec go acc = function
+ | [] -> Ok { acc with overlays = List.rev acc.overlays; positional = List.rev acc.positional }
+ | ("--overlay" | "-o") :: path :: rest -> go { acc with overlays = path :: acc.overlays } rest
| [ ("--overlay" | "-o") ] -> Error "--overlay needs a file path"
+ | "--format" :: v :: rest -> go { acc with format = Some v } rest
+ | [ "--format" ] -> Error "--format needs a value"
+ | "--from" :: v :: rest -> go { acc with from_y = Some v } rest
+ | [ "--from" ] -> Error "--from needs a value"
+ | "--to" :: v :: rest -> go { acc with to_y = Some v } rest
+ | [ "--to" ] -> Error "--to needs a value"
+ | "--dtstamp" :: v :: rest -> go { acc with dtstamp = Some v } rest
+ | [ "--dtstamp" ] -> Error "--dtstamp needs a value"
(* The recognised bare flags pass through as positional words for the
dispatch below to match; anything else beginning with '-' is rejected
rather than silently treated as a command or a year. *)
@@ -499,9 +594,21 @@ let parse_args argv =
&& arg.[0] = '-'
&& not (List.mem arg [ "-h"; "--help"; "-V"; "--version" ]) ->
Error (Printf.sprintf "unknown option %s" arg)
- | arg :: rest -> go overlays (arg :: positional) rest
+ | arg :: rest -> go { acc with positional = arg :: acc.positional } rest
in
- go [] [] argv
+ go { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; positional = [] } argv
+
+(* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning
+ on any other command (they take a single [<year>] positional, not a
+ [--from]/[--to] range), so accepting and silently dropping them would be
+ the same failure mode `--overlay` already refuses on `easter`/`temporal`. *)
+let reject_emit_flags_for cmd ~format ~from_y ~to_y ~dtstamp =
+ if format <> None || from_y <> None || to_y <> None || dtstamp <> None then begin
+ Printf.eprintf
+ "colitur: --format/--from/--to/--dtstamp have no effect on `%s`; refusing rather than ignoring them\n"
+ cmd;
+ exit 2
+ end
(* [easter] reads no calendar data at all, and [temporal] deliberately runs the
temporal cycle BEFORE any sanctoral layer exists, so an overlay could not
@@ -677,31 +784,56 @@ let () =
| Error msg ->
Printf.eprintf "colitur: %s\n" msg;
usage ()
- | Ok (overlays, positional) -> (
+ | Ok { overlays; format; from_y; to_y; dtstamp; positional } -> (
+ let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in
match positional with
| [ ("-h" | "--help" | "help") ] ->
reject_overlays_for "--help" overlays;
+ reject_emit "--help";
print_help ()
| [ ("-V" | "--version" | "version") ] ->
reject_overlays_for "--version" overlays;
+ reject_emit "--version";
print_endline version;
exit 0
| [ "easter"; ys ] ->
reject_overlays_for "easter" overlays;
+ reject_emit "easter";
with_year ys easter_report
| [ "temporal"; ys ] ->
reject_overlays_for "temporal" overlays;
+ reject_emit "temporal";
with_year ys temporal_report
| "check" :: (_ :: _ as files) ->
reject_overlays_for "check" overlays;
+ reject_emit "check";
check_report files
| [ "convert"; path ] ->
reject_overlays_for "convert" overlays;
+ reject_emit "convert";
convert_report path
| [ "new-overlay" ] ->
reject_overlays_for "new-overlay" overlays;
+ reject_emit "new-overlay";
print_string new_overlay_template;
exit 0
- | [ "day"; ys ] -> with_year ys (day_report ~overlays)
- | [ "readings"; ys ] -> with_year ys (readings_report ~overlays)
+ | [ "day"; ys ] ->
+ reject_emit "day";
+ with_year ys (day_report ~overlays)
+ | [ "readings"; ys ] ->
+ reject_emit "readings";
+ with_year ys (readings_report ~overlays)
+ | [ "emit" ] -> (
+ match format with
+ | None ->
+ Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n";
+ exit 2
+ | Some format -> (
+ match (from_y, to_y) with
+ | None, _ | _, None ->
+ Printf.eprintf "colitur: emit requires --from YEAR and --to YEAR\n";
+ exit 2
+ | Some from_ys, Some to_ys ->
+ with_year from_ys (fun from_y ->
+ with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y))))
| _ -> usage ())
diff --git a/man/colitur.1 b/man/colitur.1
index 58e46ff..1db7f9a 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -13,6 +13,14 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.RI [ ... ]
.br
.B colitur
+.B emit
+.BI \-\-format " FMT"
+.BI \-\-from " YEAR"
+.BI \-\-to " YEAR"
+.RB [ \-\-overlay " FILE" " ...]"
+.RB [ \-\-dtstamp " STAMP" ]
+.br
+.B colitur
.BR \-h | \-\-help
.SH DESCRIPTION
.B colitur
@@ -65,6 +73,13 @@ occurrence, commemoration and transfer.
.BI readings " YEAR"
The Mass reading citations, one line per day.
.TP
+.B emit
+Render a civil\-year range through one of five emitters \(em
+.BR csv ", " json ", " sexp ", " xml " or " ics .
+See
+.B EMIT
+below.
+.TP
.BI convert " FILE" .ini
Convert a flat INI overlay to the S\-expression form, on standard output. The
conversion verifies its own output before emitting it: the generated text is
@@ -97,13 +112,39 @@ below.
.TP
.BI \-\-overlay " FILE"
Apply a user calendar on top of the shipped one. Repeatable and ordered;
-.B day
-and
-.B readings
+.BR day ", " readings " and " emit
only. See
.B OVERLAYS
below.
.TP
+.BI \-\-format " FMT"
+.RB ( "colitur emit" " only)"
+One of
+.BR csv ", " json ", " sexp ", " xml " or " ics .
+Required. See
+.B EMIT
+below.
+.TP
+.BI \-\-from " YEAR" ", " \-\-to " YEAR"
+.RB ( "colitur emit" " only)"
+The inclusive civil\-year range to render, each
+.B 1583..9999
+as elsewhere.
+.I FROM
+must not be after
+.IR TO .
+Both required.
+.TP
+.BI \-\-dtstamp " STAMP"
+.RB ( "colitur emit \-\-format ics" " only)"
+Fix the feed's own DTSTAMP instead of the default
+.IR YYYY0101T000000Z ,
+where
+.I YYYY
+is the emitted year. Never a clock read either way \(em see
+.B EMIT
+below.
+.TP
.BR \-h ", " \-\-help
Print a usage summary to standard output and exit 0.
.TP
@@ -162,6 +203,102 @@ Both reports are one line per day and ordered by date, so they compose with
and
.BR join (1)
in the ordinary way.
+.SH EMIT
+.BI "colitur emit " \-\-format " FMT " \-\-from " YEAR " \-\-to " YEAR"
+renders the same resolved day \(em season, week, slug, rank, colour,
+subject, Latin and English names, citations, commemorations \(em through one
+of five emitters, for every day in the inclusive civil\-year range
+.IR FROM .. TO .
+Every emitter consumes one shared view of the data, so all five describe
+exactly the same fields.
+.TP
+.B csv
+RFC 4180. One header row for the whole run, not one per year, so a
+multi\-year range still has exactly one header and
+.BR wc (1)
+or
+.B "awk 'NR>1'"
+behave as expected.
+.RS
+.nf
+
+.B colitur emit \-\-format csv \-\-from 2026 \-\-to 2026 | head \-2
+date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
+2026\-01\-01,ef,christmastide,,ef\-circumcision,class\-1,white,temporal,,,Titus 2:11\-15,Luke 2:21,
+.fi
+.RE
+.TP
+.B json
+One JSON object per requested year, concatenated. Shape pinned by
+.IR schema/day\-v1.json .
+.RS
+.nf
+
+.B colitur emit \-\-format json \-\-from 2026 \-\-to 2026 | head \-c 40
+{"rite":"ef","year":"2026","months":[{...
+.fi
+.RE
+.TP
+.B sexp
+One S\-expression per day, one per line \(em the same
+.I Liturgical_day.t
+shape used internally, printed with
+.IR sexplib "'s " to_string_hum .
+.TP
+.B xml
+Element\-per\-field, one
+.I <calendar>
+document per requested year, concatenated. Attributes carry identity only
+(rite, year, date); everything else is an element. Shape pinned by
+.IR schema/colitur\-v1.xsd ,
+checked by
+.B make check\-schema
+when
+.BR xmllint (1)
+is installed.
+.TP
+.B ics
+RFC 5545. One
+.I VCALENDAR
+per requested year, concatenated, one all\-day
+.I VEVENT
+per day. Lines are folded at 75 octets and end
+.RI ( CRLF ),
+matching the protocol exactly \(em
+.RB \(lq " cat \-A " \(rq
+on the output shows
+.B ^M$
+at each line end.
+.RS
+.nf
+
+.B colitur emit \-\-format ics \-\-from 2026 \-\-to 2026 | head \-1
+BEGIN:VCALENDAR
+.fi
+.RE
+.PP
+.B \-\-dtstamp
+fixes the feed's own
+.I DTSTAMP
+field, which RFC 5545 requires on every event. Without it the value defaults
+to
+.I YYYY0101T000000Z
+for the emitted year \(em a fixed value, not a clock read \(em so two
+.B emit \-\-format ics
+runs over identical data are byte\-identical, which matters for a
+reproducible build or a diffable published calendar file. Nothing in the
+.B emit
+path reads the wall clock, for any format.
+.PP
+.BR \-\-overlay
+is accepted exactly as on
+.B day
+and
+.BR readings :
+applied on top of the shipped calendar, in order, before the range is
+rendered. See
+.B OVERLAYS
+below.
.SH OVERLAYS
.TP
.BI \-\-overlay " FILE"
diff --git a/test/cli.t b/test/cli.t
index 1d4df3a..9b3373a 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -415,3 +415,72 @@ displaced silently.
$ colitur day 2026 --overlay ben.sexp | grep '^2026-03-21'
2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday
+
+CSV emits a header and one row per day:
+
+ $ colitur emit --format csv --from 2027 --to 2027 | head -2
+ date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
+ 2027-01-01,ef,christmastide,,ef-circumcision,class-1,white,temporal,,,Titus 2:11-15,Luke 2:21,
+
+ $ colitur emit --format csv --from 2027 --to 2027 | wc -l
+ 366
+
+JSON is one object, ICS one VCALENDAR:
+
+ $ colitur emit --format json --from 2027 --to 2027 | cut -c1-20
+ {"rite":"ef","year":
+
+ $ colitur emit --format ics --from 2027 --to 2027 | head -1 | cat -A | head -1
+ BEGIN:VCALENDAR^M$
+
+Two runs are byte-identical (no clock read anywhere):
+
+ $ colitur emit --format ics --from 2027 --to 2027 > /tmp/a.ics
+ $ colitur emit --format ics --from 2027 --to 2027 > /tmp/b.ics
+ $ cmp /tmp/a.ics /tmp/b.ics && echo identical
+ identical
+
+A multi-year range concatenates years in order, one header for the whole
+CSV run rather than one per year:
+
+ $ colitur emit --format csv --from 2027 --to 2028 | grep -c '^2028-'
+ 366
+
+ $ colitur emit --format csv --from 2027 --to 2028 | wc -l
+ 732
+
+sexp and xml are also available:
+
+ $ colitur emit --format sexp --from 2027 --to 2027 | wc -l
+ 8472
+
+ $ colitur emit --format xml --from 2027 --to 2027 | head -2
+ <?xml version="1.0" encoding="UTF-8"?>
+ <calendar rite="ef" year="2027">
+
+An unknown format is a usage error on stderr, exit 2:
+
+ $ colitur emit --format yaml --from 2027 --to 2027
+ colitur: unknown format "yaml" (want csv, json, sexp, xml or ics)
+ [2]
+
+emit refuses a reversed range rather than emitting nothing:
+
+ $ colitur emit --format csv --from 2028 --to 2027
+ colitur: --from 2028 is after --to 2027
+ [2]
+
+emit's own flags have no effect on the other commands, refused rather than
+silently ignored, the same discipline --overlay already gets:
+
+ $ colitur day 2027 --format csv
+ colitur: --format/--from/--to/--dtstamp have no effect on `day`; refusing rather than ignoring them
+ [2]
+
+day and readings are untouched:
+
+ $ colitur day 2027 | head -1
+ 2027-01-01 friday christmastide - ef-circumcision class-1 white
+
+ $ colitur readings 2027 | head -1
+ 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21