1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
(* emit_csv.ml *)
module T = Template
let escape_field s =
let needs =
String.exists (fun c -> c = ',' || c = '"' || c = '\n' || c = '\r') s
in
if not needs then s
else begin
let b = Buffer.create (String.length s + 8) in
Buffer.add_char b '"';
String.iter (fun c -> if c = '"' then Buffer.add_string b "\"\"" else Buffer.add_char b c) s;
Buffer.add_char b '"';
Buffer.contents b
end
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 | _ -> ""
(* [name] is now the single RESOLVED display string (view.ml), not a
lang-keyed object -- the [name_la]/[name_en] pair this replaces carried
the kernel's own [Celebration.names], a different, unlocalised source.
Machine formats still carry [slug] alongside it, so a script keeps the
stable key and a human reads the name.
W5 left this deliberately NOT widened with a "second" column, unlike
[Emit_xml]/[Emit_ics] (each a repeated-element/free-text format that
grows for free): a CSV's header row is a CONTRACT with every row under
it (RFC 4180 sec. 2: "each field in the header... should contain the
same number of fields as the records"), so there is no way to add a
column only on the days that carry a [Second] reading -- a real
three-citation day needs either an ALWAYS-present, mostly-empty
"second" column or genuinely variable-width rows (not CSV). At the
time, [bin/main.ml]'s [reject_rite_for] refused [--rite] on `emit`
entirely for any rite but EF, so no [Second] reading could reach this
module, and this was recorded as the deliberate, disclosed breaking
change the day OF was admitted here would need -- "a new column, a
version bump, or a documented trade-off, not a silent one".
Fix 1 (cli-flags-report, 2026-08-27): that day is this one. The
"always-present, mostly-empty column" option is taken, gated on
[rite] (read from [v] itself, exactly as [row]'s own [~rite] already
is) rather than threaded as a new parameter: EF's [rite = "ef"] keeps
the ORIGINAL 16-column header and rows, byte for byte -- no [Second]
reading has ever reached EF's own [citations]
({!Rite_ef}'s own [citation_shapes] is still exactly
[[First; Gospel]]), so [rite = "ef"]'s branch below is provably
unreachable-different, not merely untested-different. Any OTHER rite
(OF today) gets a 17th column, "second", spliced between "first" and
"gospel" -- the same position [Emit_xml]/[Emit_ics] already use for
it -- reading [s d "second"], which [get]'s own [None -> ""] fallback
already resolves to the empty string on every day that has no [Second]
part (a feria, feast or memorial; OLM 1981 Praenotanda n. 66.1/n. 69.1),
so nothing here needs to know in advance which OF days do carry one. *)
let columns ~rite =
let base =
[ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday"; "rank"; "rank_name";
"colour"; "colour_name"; "subject"; "first" ]
in
let tail = [ "gospel"; "comms" ] in
if rite = "ef" then base @ tail else base @ [ "second" ] @ tail
let row ~rite d =
let comms =
match get d "comms" with
| Some (T.List l) -> String.concat " " (List.map (fun c -> s c "slug") l)
| _ -> ""
in
let base =
[ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug"; s d "name";
s d "weekday"; s d "rank"; s d "rank_name"; s d "colour"; s d "colour_name"; s d "subject";
s d "first" ]
in
let tail = [ s d "gospel"; comms ] in
let fields = if rite = "ef" then base @ tail else base @ [ s d "second" ] @ tail in
String.concat "," (List.map escape_field fields)
let year v =
let rite = s v "rite" in
let days = match get v "days" with Some (T.List l) -> l | _ -> [] in
let b = Buffer.create (64 * 400) in
Buffer.add_string b (String.concat "," (columns ~rite));
Buffer.add_char b '\n';
List.iter (fun d -> Buffer.add_string b (row ~rite d); Buffer.add_char b '\n') days;
Buffer.contents b
|