aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bin/dune2
-rw-r--r--bin/main.ml43
-rw-r--r--lib/render/dune2
-rw-r--r--lib/render/emit_csv.ml16
-rw-r--r--lib/render/emit_ics.ml12
-rw-r--r--lib/render/emit_xml.ml17
-rw-r--r--lib/render/view.ml121
-rw-r--r--lib/render/view.mli18
-rw-r--r--schema/colitur-v1.xsd17
-rw-r--r--schema/day-v1.json58
-rw-r--r--test/cli.t23
-rw-r--r--test/test_emit.ml6
-rw-r--r--test/test_view.ml92
13 files changed, 320 insertions, 107 deletions
diff --git a/bin/dune b/bin/dune
index 856dafe..28c04d7 100644
--- a/bin/dune
+++ b/bin/dune
@@ -6,4 +6,4 @@
; colitur.opam's frozen depends, only a new library this executable links
; against. Used by Task 12's [mkdir_p] (colitur publish, recursive
; directory creation) and nowhere else.
- (libraries colitur_kernel rite_ef colitur_render unix))
+ (libraries colitur_kernel rite_ef colitur_render colitur_naming unix))
diff --git a/bin/main.ml b/bin/main.ml
index 6f64582..b2eef28 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -131,6 +131,41 @@ let data_dir () =
end
| _ -> if has_data installed then installed else build_tree
+(* TEMPORARY BRIDGE (naming-and-config Task 5): [View.of_days] now takes
+ [~lang], but real CLI flag/config resolution (--lang, --raw,
+ Colitur_naming.Config) is Task 6's own scope, not this one's. Rather than
+ leave `emit`/`table`/`publish` showing bare slugs as their "resolved
+ name" -- which is precisely the defect this whole branch exists to fix --
+ this loads the shipped Latin table by default, using EXACTLY the probe
+ order [data_dir] above already uses (installed prefix, then the build
+ tree), so an installed binary finds its language file the same way it
+ finds its calendar data. Task 6 replaces this wholesale with
+ `--lang`/`--raw`/config resolution and per-command defaults; nothing
+ here is meant to survive that task unchanged. A missing or malformed
+ language file degrades to [Lang.raw] (name = slug) rather than crashing
+ the CLI -- Task 6 is what makes that case a proper, reported error. *)
+let lang_dir () =
+ let prefix = Filename.dirname (Filename.dirname Sys.executable_name) in
+ let installed = List.fold_left Filename.concat prefix [ "share"; "colitur"; "lang" ] in
+ if Sys.file_exists (Filename.concat installed "la.ini") then installed
+ else Filename.concat prefix "lang"
+
+let default_lang =
+ lazy
+ (let path = Filename.concat (lang_dir ()) "la.ini" in
+ match open_in_bin path with
+ | exception Sys_error _ -> Colitur_naming.Lang.raw
+ | ic -> (
+ match
+ Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () ->
+ really_input_string ic (in_channel_length ic))
+ with
+ | exception Sys_error _ -> Colitur_naming.Lang.raw
+ | text -> (
+ match Colitur_naming.Lang.of_string text with
+ | Ok t -> t
+ | Error _ -> Colitur_naming.Lang.raw)))
+
(* Loads the universal sanctoral layer and applies the one hand-authored
overlay over it (data/ef/adjustments.sexp -- see that file's own header):
[Overlay.apply]'s diagnostics are never silently dropped (Overlay.mli),
@@ -428,7 +463,7 @@ let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y =
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
+ Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
in
match format with
| "csv" ->
@@ -538,7 +573,7 @@ let table_report ~template ~flavour_opt ~overlays y =
exit 2
| Ok src -> (
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
+ let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
match Colitur_render.Template.render_string ~flavour src v with
| Error e ->
(* The template is user input; a parse failure is reported with the
@@ -757,7 +792,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
in
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
+ let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
let ys = string_of_int y in
emit ("ef/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v);
emit ("ef/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v);
@@ -770,7 +805,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
(fun d ->
let iso = D.to_iso8601 d.Colitur_kernel.Liturgical_day.date in
let mm = String.sub iso 5 2 and dd = String.sub iso 8 2 in
- let one = Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in
+ let one = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in
emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one))
days
done;
diff --git a/lib/render/dune b/lib/render/dune
index 7880188..548cf43 100644
--- a/lib/render/dune
+++ b/lib/render/dune
@@ -1,5 +1,5 @@
(library
(name colitur_render)
- (libraries colitur_kernel sexplib)
+ (libraries colitur_kernel colitur_naming sexplib)
(preprocess
(pps ppx_sexp_conv)))
diff --git a/lib/render/emit_csv.ml b/lib/render/emit_csv.ml
index b478265..415a684 100644
--- a/lib/render/emit_csv.ml
+++ b/lib/render/emit_csv.ml
@@ -16,11 +16,15 @@ let escape_field s =
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 nested v a b = match get v a with Some inner -> s inner b | None -> ""
+(* [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. *)
let columns =
- [ "date"; "rite"; "season"; "week"; "slug"; "rank"; "colour"; "subject";
- "name_la"; "name_en"; "first"; "gospel"; "comms" ]
+ [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday";
+ "rank"; "rank_name"; "colour"; "colour_name"; "subject"; "first"; "gospel"; "comms" ]
let row ~rite d =
let comms =
@@ -30,9 +34,9 @@ let row ~rite d =
in
String.concat ","
(List.map escape_field
- [ s d "iso"; rite; s d "season"; s d "week"; s d "slug"; s d "rank";
- s d "colour"; s d "subject"; nested d "name" "la"; nested d "name" "en";
- s d "first"; s d "gospel"; comms ])
+ [ 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"; s d "gospel"; comms ])
let year v =
let rite = s v "rite" in
diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml
index 41efb95..4e06eae 100644
--- a/lib/render/emit_ics.ml
+++ b/lib/render/emit_ics.ml
@@ -55,14 +55,10 @@ 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
+ (* [name] is the view's own resolved display string (view.ml) -- no
+ further fallback needed here: under [Lang.raw] it already equals
+ [slug], which is what a miss used to require picking by hand. *)
+ let name = s d "name" in
let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in
let desc =
String.concat "\n"
diff --git a/lib/render/emit_xml.ml b/lib/render/emit_xml.ml
index 4e4b51a..3f1ca32 100644
--- a/lib/render/emit_xml.ml
+++ b/lib/render/emit_xml.ml
@@ -11,20 +11,19 @@ let el b name value =
let day b d =
Buffer.add_string b (" <day date=\"" ^ escape (s d "iso") ^ "\">\n");
el b "season" (s d "season");
+ el b "season_name" (s d "season_name");
el b "week" (s d "week");
el b "slug" (s d "slug");
+ (* The resolved display name. A single element, no [lang] attribute: the
+ view's own [name] is now one resolved string, not a lang-keyed object
+ (view.ml), so there is exactly one to emit rather than one per language. *)
+ el b "name" (s d "name");
+ el b "weekday" (s d "weekday");
el b "rank" (s d "rank");
+ el b "rank_name" (s d "rank_name");
el b "colour" (s d "colour");
+ el b "colour_name" (s d "colour_name");
el b "subject" (s d "subject");
- (match get d "name" with
- | Some (T.Obj kvs) ->
- List.iter
- (fun (lang, v) ->
- match v with
- | T.Str x -> Buffer.add_string b (" <name lang=\"" ^ escape lang ^ "\">" ^ escape x ^ "</name>\n")
- | _ -> ())
- kvs
- | _ -> ());
(match get d "comms" with
| Some (T.List l) ->
List.iter (fun c -> Buffer.add_string b (" <commemoration>" ^ escape (s c "slug") ^ "</commemoration>\n")) l
diff --git a/lib/render/view.ml b/lib/render/view.ml
index 733aa45..bbb8c7d 100644
--- a/lib/render/view.ml
+++ b/lib/render/view.ml
@@ -1,18 +1,10 @@
module K = Colitur_kernel
module T = Template
+module Lang = Colitur_naming.Lang
let str s = T.Str s
let bool b = T.Bool b
-let month_names =
- [| ("Ianuarius", "January"); ("Februarius", "February"); ("Martius", "March");
- ("Aprilis", "April"); ("Maius", "May"); ("Iunius", "June");
- ("Iulius", "July"); ("Augustus", "August"); ("September", "September");
- ("October", "October"); ("November", "November"); ("December", "December") |]
-
-let names_value (n : K.Names.t) =
- T.Obj (List.map (fun (l, s) -> (K.Lang.to_string l, str s)) (K.Names.to_list n))
-
let dow_int = function
| K.Date.Sun -> 0 | K.Date.Mon -> 1 | K.Date.Tue -> 2 | K.Date.Wed -> 3
| K.Date.Thu -> 4 | K.Date.Fri -> 5 | K.Date.Sat -> 6
@@ -24,55 +16,78 @@ let citation_ref cits part =
| Some c -> c.K.Citation.reference
| None -> ""
-let comm_value (c, priv) =
+(* A commemoration's own name resolves through the SAME [lang.celebration]
+ table as the observed day's -- a commemoration's slug is drawn from the
+ identical sanctoral/temporal pool, not a second vocabulary. Templates
+ (the ordo booklet, Task 8) interpolate this as a plain [{{name}}] inside
+ [{{#comms}}], so it must be a string here too, not the kernel's own
+ lang-keyed [Celebration.names] object -- the same reasoning [day_value]'s
+ own [name] follows below, applied consistently rather than left as a
+ second, differently-shaped name field a template author would have to
+ remember. *)
+let comm_value ~lang (c, priv) =
+ let slug_s = K.Slug.to_string c.K.Celebration.slug in
T.Obj
- [ ("slug", str (K.Slug.to_string c.K.Celebration.slug));
- ("name", names_value c.K.Celebration.names);
+ [ ("slug", str slug_s);
+ ("name", str (Lang.celebration lang slug_s));
("privileged", bool (priv = K.Precedence.Privileged)) ]
(* A padding cell: present so a grid row always has seven entries, and flagged
so a template can render it blank. Every field a real day has is present and
- empty, so a template never hits a missing key on a padding cell. *)
+ empty, so a template never hits a missing key on a padding cell -- the same
+ key SET as [day_value], not merely the same shape by coincidence. *)
let padding_cell dow =
T.Obj
[ ("iso", str ""); ("dom", str ""); ("dow", str (string_of_int dow));
("in_month", bool false);
- ("season", str ""); ("week", str ""); ("slug", str "");
- ("name", T.Obj []); ("rank", str "");
- ("colour", str "");
+ ("season", str ""); ("season_name", str "");
+ ("week", str ""); ("slug", str "");
+ ("name", str ""); ("weekday", str "");
+ ("rank", str ""); ("rank_name", str "");
+ ("colour", str ""); ("colour_name", str "");
("is_white", bool false); ("is_red", bool false); ("is_green", bool false);
("is_violet", bool false); ("is_rose", bool false); ("is_black", bool false);
("subject", str ""); ("comms", T.List []);
("transferred_in", T.List []); ("transferred_out", T.List []);
("first", str ""); ("gospel", str ""); ("last", bool false) ]
-let day_value ~vocab (d : ('s, 'r) K.Liturgical_day.t) =
+let day_value ~lang ~vocab (d : ('s, 'r) K.Liturgical_day.t) =
let date = d.K.Liturgical_day.date in
let tmp = d.K.Liturgical_day.temporal in
let cel = d.K.Liturgical_day.observed in
let colour = cel.K.Celebration.colour in
let is c = bool (colour = c) in
+ let slug_s = K.Slug.to_string cel.K.Celebration.slug in
+ let rank_s = vocab.K.Vocab.rank_to_string cel.K.Celebration.rank in
+ let colour_s = K.Colour.to_string colour in
+ let season_s = vocab.K.Vocab.season_to_string tmp.K.Temporal.season in
T.Obj
[ ("iso", str (K.Date.to_iso8601 date));
("dom", str (string_of_int (K.Date.day date)));
("dow", str (string_of_int (dow_int (K.Date.weekday date))));
("in_month", bool true);
- ("season", str (vocab.K.Vocab.season_to_string tmp.K.Temporal.season));
+ ("season", str season_s);
+ ("season_name", str (Lang.season lang season_s));
("week", str (match tmp.K.Temporal.week with Some w -> string_of_int w | None -> ""));
- ("slug", str (K.Slug.to_string cel.K.Celebration.slug));
- ("name", names_value cel.K.Celebration.names);
- (* [rank] is the kernel's own class string ("class-1"); there is
- deliberately no separate localized rank label here -- the kernel
- has no per-language rank names to draw one from, and a field
- whose contents cannot honestly differ from [name] should not
- exist just to exist. Do not re-add one until the kernel can. *)
- ("rank", str (vocab.K.Vocab.rank_to_string cel.K.Celebration.rank));
- ("colour", str (K.Colour.to_string colour));
+ ("slug", str slug_s);
+ (* The resolved display name. A plain string, not a lang-keyed object:
+ a dotted {{name.la}} used to fall back WHOLESALE to the enclosing
+ month's own name.la and print "Ianuarius" on unnamed days. One
+ string removes that hazard entirely -- there is no dotted path left
+ for a partial match to climb out of. Under [Lang.raw] this equals
+ [slug] exactly (every lookup in the identity table echoes its key),
+ which is what makes [--raw] output byte-stable. *)
+ ("name", str (Lang.celebration lang slug_s));
+ ("weekday", str (Lang.weekday lang (dow_int (K.Date.weekday date))));
+ ("rank", str rank_s);
+ ("rank_name", str (Lang.rank lang rank_s));
+ ("colour", str colour_s);
+ ("colour_name", str (Lang.colour lang colour_s));
("is_white", is K.Colour.White); ("is_red", is K.Colour.Red);
("is_green", is K.Colour.Green); ("is_violet", is K.Colour.Violet);
("is_rose", is K.Colour.Rose); ("is_black", is K.Colour.Black);
("subject", str (K.Subject.to_string cel.K.Celebration.subject));
- ("comms", T.List (List.map comm_value d.K.Liturgical_day.commemorations));
+ ("comms", T.List (List.map (comm_value ~lang) d.K.Liturgical_day.commemorations));
( "transferred_in",
T.List
(match d.K.Liturgical_day.transferred_in with
@@ -102,7 +117,14 @@ let set_last cells =
(fun i c -> match c with T.Obj kvs -> T.Obj (("last", bool (i = 6)) :: List.remove_assoc "last" kvs) | v -> v)
cells
-let weeks_of_month ~first_dow day_values =
+(* [month_num]/[month_name] are carried onto every WEEK object because the
+ engine has no {{../}} parent-path syntax: a nested {{num}} inside a week
+ silently finds the WEEK's own number, never the month's, so a template
+ that needs the month (the ordo booklet, Task 8, whose weeks span a
+ {{#months}}{{#weeks}} nesting) has no other way to reach it. Shaping the
+ data here, rather than inventing template syntax, is the same call the
+ [last] flag above already made. *)
+let weeks_of_month ~first_dow ~month_num ~month_name day_values =
let lead = List.init first_dow (fun i -> padding_cell i) in
let cells = lead @ day_values in
let rec chunk acc = function
@@ -118,11 +140,35 @@ let weeks_of_month ~first_dow day_values =
chunk (week :: acc) tl
in
List.mapi
- (fun i w -> T.Obj [ ("num", str (string_of_int (i + 1))); ("days", T.List (set_last w)) ])
+ (fun i w ->
+ T.Obj
+ [ ("num", str (string_of_int (i + 1)));
+ ("month_num", str month_num);
+ ("month_name", str month_name);
+ ("days", T.List (set_last w)) ])
(chunk [] cells)
-let of_days ~vocab ~rite ~year days =
- let dvs = List.map (fun d -> (d, day_value ~vocab d)) days in
+(* The [term] vocabulary a template routes every fixed string through
+ ({{term.epistle}}, {{term.week}}, ...) so a translated booklet needs no
+ template edit. The key list is the vocabulary's own fixed, closed set
+ (lang/*.ini's own [term] section, Task 1/3) -- not open like [celebration],
+ so it is named here rather than invented a second time from a wildcard
+ enumeration. *)
+let term_keys = [ "ordo"; "contents"; "epistle"; "lesson"; "gospel"; "commemoration"; "week" ]
+
+let term_value lang = T.Obj (List.map (fun k -> (k, str (Lang.term lang k))) term_keys)
+
+(* A localised grid header row: {name; last} objects, not bare strings --
+ the engine rejects an empty tag path ({{.}}) as a parse error, so a
+ template walking this list needs a named field to interpolate. Always
+ Sunday-first (index 0), matching [padding_cell]'s own [dow] numbering and
+ every week this view builds. *)
+let weekday_headings lang =
+ T.List
+ (List.init 7 (fun i -> T.Obj [ ("name", str (Lang.weekday lang i)); ("last", bool (i = 6)) ]))
+
+let of_days ~lang ~vocab ~rite ~year days =
+ let dvs = List.map (fun d -> (d, day_value ~lang ~vocab d)) days in
let months =
List.init 12 (fun i ->
let m = i + 1 in
@@ -135,15 +181,18 @@ let of_days ~vocab ~rite ~year days =
| (d, _) :: _ -> dow_int (K.Date.weekday d.K.Liturgical_day.date)
| [] -> 0
in
- let la, en = month_names.(i) in
+ let month_num = string_of_int m in
+ let month_name = Lang.month lang m in
T.Obj
- [ ("num", str (string_of_int m));
- ("name", T.Obj [ ("la", str la); ("en", str en) ]);
+ [ ("num", str month_num);
+ ("name", str month_name);
("days", T.List day_values);
- ("weeks", T.List (weeks_of_month ~first_dow day_values)) ])
+ ("weeks", T.List (weeks_of_month ~first_dow ~month_num ~month_name day_values)) ])
in
T.Obj
[ ("rite", str rite);
("year", str (string_of_int year));
+ ("term", term_value lang);
+ ("weekday_headings", weekday_headings lang);
("months", T.List months);
("days", T.List (List.map snd dvs)) ]
diff --git a/lib/render/view.mli b/lib/render/view.mli
index fb48590..81bfc26 100644
--- a/lib/render/view.mli
+++ b/lib/render/view.mli
@@ -15,10 +15,24 @@
different colour expression. Exactly one of the six is true on every day. *)
val of_days :
+ lang:Colitur_naming.Lang.t ->
vocab:('s, 'r) Colitur_kernel.Vocab.t ->
rite:string ->
year:int ->
('s, 'r) Colitur_kernel.Liturgical_day.t list ->
Template.value
-(** [of_days ~vocab ~rite ~year days] where [days] is one civil year, 1 January
- to 31 December, in order. Pure and total. *)
+(** [of_days ~lang ~vocab ~rite ~year days] where [days] is one civil year,
+ 1 January to 31 December, in order. Pure and total.
+
+ [lang] resolves every display string -- a day's [name], its localised
+ [weekday]/[rank_name]/[colour_name]/[season_name], each month's [name],
+ and the top-level [term] vocabulary and [weekday_headings]. [name] is a
+ PLAIN STRING, not the old lang-keyed object: a dotted [{{name.la}}]
+ reference that misses falls back WHOLESALE to the enclosing scope (a
+ month's own [name.la]), which is how a printed booklet came to show
+ "Ianuarius" in place of a feast with no Latin name. A plain string has no
+ dotted path to fall back through, so that hazard is unrepresentable.
+ [slug] is untouched by [lang] -- it stays the stable machine key,
+ identical between any two calls that differ only in [lang]. Pass
+ {!Colitur_naming.Lang.raw} for the pre-naming behaviour, under which
+ [name] equals [slug] exactly (this is what CLI [--raw] uses). *)
diff --git a/schema/colitur-v1.xsd b/schema/colitur-v1.xsd
index 46af9a9..df11c3d 100644
--- a/schema/colitur-v1.xsd
+++ b/schema/colitur-v1.xsd
@@ -10,20 +10,19 @@
<xs:complexType>
<xs:sequence>
<xs:element name="season" type="xs:string"/>
+ <xs:element name="season_name" type="xs:string"/>
<xs:element name="week" type="xs:string"/>
<xs:element name="slug" type="xs:string"/>
+ <!-- The resolved display name: a single element, no
+ lang attribute. The view's own name is now one
+ resolved string, not a value per language. -->
+ <xs:element name="name" type="xs:string"/>
+ <xs:element name="weekday" type="xs:string"/>
<xs:element name="rank" type="xs:string"/>
+ <xs:element name="rank_name" type="xs:string"/>
<xs:element name="colour" type="xs:string"/>
+ <xs:element name="colour_name" type="xs:string"/>
<xs:element name="subject" type="xs:string"/>
- <xs:element name="name" minOccurs="0" maxOccurs="unbounded">
- <xs:complexType>
- <xs:simpleContent>
- <xs:extension base="xs:string">
- <xs:attribute name="lang" type="xs:string" use="required"/>
- </xs:extension>
- </xs:simpleContent>
- </xs:complexType>
- </xs:element>
<xs:element name="commemoration" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="citation" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
diff --git a/schema/day-v1.json b/schema/day-v1.json
index b48133a..5a7537c 100644
--- a/schema/day-v1.json
+++ b/schema/day-v1.json
@@ -3,10 +3,21 @@
"$id": "https://colitur/schema/day-v1.json",
"title": "colitur liturgical year, v1",
"type": "object",
- "required": ["rite", "year", "months", "days"],
+ "required": ["rite", "year", "term", "weekday_headings", "months", "days"],
"properties": {
"rite": { "type": "string" },
"year": { "type": "string", "pattern": "^[0-9]{4}$" },
+ "term": { "type": "object", "additionalProperties": { "type": "string" }, "description": "fixed vocabulary (ordo, contents, epistle, lesson, gospel, commemoration, week) in the active language" },
+ "weekday_headings": {
+ "type": "array",
+ "minItems": 7,
+ "maxItems": 7,
+ "items": {
+ "type": "object",
+ "required": ["name", "last"],
+ "properties": { "name": { "type": "string" }, "last": { "type": "boolean" } }
+ }
+ },
"months": { "type": "array", "items": { "$ref": "#/$defs/month" } },
"days": { "type": "array", "items": { "$ref": "#/$defs/day" } }
},
@@ -16,36 +27,43 @@
"required": ["num", "name", "days", "weeks"],
"properties": {
"num": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
+ "name": { "type": "string", "description": "the resolved display name, e.g. \"Ianuarius\"" },
"days": { "type": "array", "items": { "$ref": "#/$defs/day" } },
"weeks": { "type": "array", "items": { "$ref": "#/$defs/week" } }
}
},
"week": {
"type": "object",
- "required": ["num", "days"],
+ "required": ["num", "month_num", "month_name", "days"],
"properties": {
- "num": { "type": "string" },
- "days": { "type": "array", "minItems": 7, "maxItems": 7, "items": { "$ref": "#/$defs/day" } }
+ "num": { "type": "string" },
+ "month_num": { "type": "string", "description": "the enclosing month's num, carried here because the engine has no parent-path syntax" },
+ "month_name": { "type": "string", "description": "the enclosing month's resolved name, same reason" },
+ "days": { "type": "array", "minItems": 7, "maxItems": 7, "items": { "$ref": "#/$defs/day" } }
}
},
"day": {
"type": "object",
- "required": ["iso", "dom", "dow", "in_month", "season", "week", "slug",
- "name", "rank", "colour", "is_white", "is_red", "is_green",
- "is_violet", "is_rose", "is_black", "subject", "comms",
- "transferred_in", "transferred_out", "first", "gospel", "last"],
+ "required": ["iso", "dom", "dow", "in_month", "season", "season_name", "week",
+ "slug", "name", "weekday", "rank", "rank_name", "colour", "colour_name",
+ "is_white", "is_red", "is_green", "is_violet", "is_rose", "is_black",
+ "subject", "comms", "transferred_in", "transferred_out", "first",
+ "gospel", "last"],
"properties": {
- "iso": { "type": "string", "description": "empty on a grid padding cell" },
- "dom": { "type": "string" },
- "dow": { "type": "string", "description": "0 = Sunday" },
- "in_month": { "type": "boolean", "description": "false = grid padding cell" },
- "season": { "type": "string" },
- "week": { "type": "string", "description": "empty when the rite numbers no week here" },
- "slug": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
- "rank": { "type": "string" },
- "colour": { "type": "string", "enum": ["white","red","green","violet","rose","black",""] },
+ "iso": { "type": "string", "description": "empty on a grid padding cell" },
+ "dom": { "type": "string" },
+ "dow": { "type": "string", "description": "0 = Sunday" },
+ "in_month": { "type": "boolean", "description": "false = grid padding cell" },
+ "season": { "type": "string" },
+ "season_name": { "type": "string", "description": "the resolved season name in the active language" },
+ "week": { "type": "string", "description": "empty when the rite numbers no week here" },
+ "slug": { "type": "string", "description": "the stable machine key -- unaffected by the active language" },
+ "name": { "type": "string", "description": "the resolved display name; under --raw this equals slug" },
+ "weekday": { "type": "string", "description": "the resolved weekday name in the active language" },
+ "rank": { "type": "string" },
+ "rank_name": { "type": "string", "description": "the resolved rank name in the active language" },
+ "colour": { "type": "string", "enum": ["white","red","green","violet","rose","black",""] },
+ "colour_name": { "type": "string", "description": "the resolved colour name in the active language" },
"is_white": { "type": "boolean" }, "is_red": { "type": "boolean" },
"is_green": { "type": "boolean" }, "is_violet":{ "type": "boolean" },
"is_rose": { "type": "boolean" }, "is_black": { "type": "boolean" },
@@ -57,7 +75,7 @@
"required": ["slug", "name", "privileged"],
"properties": {
"slug": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
+ "name": { "type": "string", "description": "the resolved display name, same table as the day's own name" },
"privileged": { "type": "boolean" }
}
}
diff --git a/test/cli.t b/test/cli.t
index 80c8078..dfe5caa 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -419,8 +419,8 @@ displaced silently.
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,
+ date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms
+ 2027-01-01,ef,christmastide,Tempus Nativitatis,,ef-circumcision,In Octava Nativitatis Domini,Feria VI,class-1,I classis,white,albus,temporal,Titus 2:11-15,Luke 2:21,
$ colitur emit --format csv --from 2027 --to 2027 | wc -l
366
@@ -522,12 +522,21 @@ render is the same operation under the name the design used:
2027-01-01 ef-circumcision
2027-01-02 ef-christmas-1-saturday
-Flavour is inferred from the extension and escapes data -- Sts. Peter &
-Paul (29 June) and its vigil are the only two 2035 entries whose English
-name needs LaTeX escaping:
+Flavour is inferred from the extension. `name` is the RESOLVED display
+string now (view.ml), not the old lang-keyed object -- there is no more
+`.en`/`.la` to reach, so a plain `{{name}}` is the only correct form; the
+old `{{name.en}}` dotted lookup on today's plain string simply finds
+nothing, on purpose (this is the change that also removes the shadowing
+hazard: a miss on a plain string has no dotted path left to fall back
+through). Names are Latin here (no --lang until the CLI wiring task), and
+the Missal's own Latin spells Peter and Paul's feast with "et", never an
+ampersand, so this no longer doubles as an escaping demonstration --
+that property is proved live on 2035 data in test_emit.ml instead
+(test_xml_escapes_live_data), where the English table both name sources
+still share does carry one:
- $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex
- $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul'
+ $ printf '{{#days}}{{name}}\n{{/days}}' > /tmp/t.tex
+ $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Ss\. Petri et Pauli'
2
An unknown extension with no --flavour is an error, not a silent fallback:
diff --git a/test/test_emit.ml b/test/test_emit.ml
index a22eaaf..0a40054 100644
--- a/test/test_emit.ml
+++ b/test/test_emit.ml
@@ -61,7 +61,7 @@ let test_csv_header_and_rows () =
let lines = String.split_on_char '\n' out |> List.filter (fun l -> l <> "") in
Alcotest.(check int) "366 lines: header + 365 days" 366 (List.length lines);
Alcotest.(check string) "header"
- "date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms"
+ "date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms"
(List.hd lines);
Alcotest.(check bool) "first row is 1 January" true
(String.length (List.nth lines 1) > 10 && String.sub (List.nth lines 1) 0 10 = "2027-01-01")
@@ -86,8 +86,8 @@ let test_csv_quotes_commas () =
that escapes its quoting produces 14 fields, not 13. Also assert the
quoted substring appears literally, byte for byte -- belt and braces,
and closer to what a human reviewing the CSV would actually look for. *)
- Alcotest.(check int) "the row has exactly 13 fields, same as the header"
- 13 (List.length (parse_csv_row joseph));
+ Alcotest.(check int) "the row has exactly 16 fields, same as the header"
+ 16 (List.length (parse_csv_row joseph));
Alcotest.(check bool) "Joseph's comma-bearing name is quoted whole, not split" true
(contains ~needle:"\"St. Joseph, Spouse of the Bl. Virgin Mary\"" joseph)
diff --git a/test/test_view.ml b/test/test_view.ml
index e595357..b49b090 100644
--- a/test/test_view.ml
+++ b/test/test_view.ml
@@ -24,8 +24,29 @@ let days_of_year y =
done;
List.rev !out
+(* Shared across this file and test_emit.ml: the ENGLISH table, chained to
+ Latin (en.ini's own [meta] fallback = la), so a slug en.ini does not name
+ directly still resolves through the chain rather than degrading to its
+ slug. English (not Latin, and not Lang.raw) is deliberate: it keeps the
+ emitter tests' own literal expectations -- "St. Joseph, Spouse of the Bl.
+ Virgin Mary" (a comma, for CSV quoting), "Sts. Fabian & Sebastian" (an
+ ampersand, for XML escaping) -- byte-identical to lang/en.ini's own
+ [celebration] entries, verified by grep against the shipped file rather
+ than assumed. *)
+let read_lang path =
+ let ic = open_in_bin path in
+ let s = really_input_string ic (in_channel_length ic) in
+ close_in ic;
+ match Colitur_naming.Lang.of_string s with
+ | Ok t -> t
+ | Error e -> Alcotest.failf "%s: %s" path e
+
+let default_lang =
+ lazy (Colitur_naming.Lang.with_fallback (read_lang "../lang/en.ini") (read_lang "../lang/la.ini"))
+
let view_of y =
- V.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y (days_of_year y)
+ V.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y
+ (days_of_year y)
let get path v =
let rec go v = function
@@ -110,10 +131,79 @@ let test_exactly_one_colour_flag () =
if n <> 1 then Alcotest.failf "%s has %d colour flags set" (as_str (get [ "iso" ] d)) n)
(as_list (get [ "days" ] v))
+(* Padding cells and real days must carry the SAME key set: a template that
+ walks a grid row must never hit a missing key on a padding cell. Compares
+ the sorted key lists of a real day and a padding cell (the first cell of
+ January 2027's first week -- 1 Jan 2027 is a Friday, so that cell IS a
+ padding cell, see test_padding_cells_are_flagged above). *)
+let keys_of = function
+ | T.Obj kvs -> List.sort compare (List.map fst kvs)
+ | _ -> Alcotest.fail "expected an object"
+
+let test_padding_and_real_share_key_set () =
+ let v = view_of 2027 in
+ let jan = List.hd (as_list (get [ "months" ] v)) in
+ let first_week = List.hd (as_list (get [ "weeks" ] jan)) in
+ let cells = as_list (get [ "days" ] first_week) in
+ let padding = List.find (fun c -> not (as_bool (get [ "in_month" ] c))) cells in
+ let real = List.find (fun c -> as_bool (get [ "in_month" ] c)) cells in
+ Alcotest.(check (list string)) "padding and real days have the same key set" (keys_of real)
+ (keys_of padding)
+
+let latin () =
+ let ic = open_in_bin "../lang/la.ini" in
+ let s = really_input_string ic (in_channel_length ic) in
+ close_in ic;
+ match Colitur_naming.Lang.of_string s with
+ | Ok t -> t | Error e -> Alcotest.failf "la.ini: %s" e
+
+let view_named y = V.of_days ~lang:(latin ()) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y (days_of_year y)
+
+(* The defect this whole branch exists to fix: no rendered day may show a slug
+ where a name exists. Asserted over a whole year, not a sample. *)
+let test_no_day_shows_a_slug () =
+ let v = view_named 2027 in
+ List.iter
+ (fun d ->
+ let name = as_str (get [ "name" ] d) and slug = as_str (get [ "slug" ] d) in
+ if name = slug then Alcotest.failf "%s renders its slug as its name" (as_str (get [ "iso" ] d));
+ if name = "" then Alcotest.failf "%s has an empty name" (as_str (get [ "iso" ] d)))
+ (as_list (get [ "days" ] v))
+
+let test_slug_is_unchanged_by_naming () =
+ let raw = V.of_days ~lang:Colitur_naming.Lang.raw ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:2027 (days_of_year 2027) in
+ let named = view_named 2027 in
+ List.iter2
+ (fun a b -> Alcotest.(check string) "slug identical" (as_str (get [ "slug" ] a)) (as_str (get [ "slug" ] b)))
+ (as_list (get [ "days" ] raw)) (as_list (get [ "days" ] named))
+
+(* Under --raw the name IS the slug: that is what makes raw output byte-stable. *)
+let test_raw_name_equals_slug () =
+ let raw = V.of_days ~lang:Colitur_naming.Lang.raw ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:2027 (days_of_year 2027) in
+ List.iter
+ (fun d -> Alcotest.(check string) "raw" (as_str (get [ "slug" ] d)) (as_str (get [ "name" ] d)))
+ (as_list (get [ "days" ] raw))
+
+(* Weekday, month, season, rank and colour must localise too -- a calendar in a
+ language needs more than feast names. *)
+let test_vocabularies_localise () =
+ let v = view_named 2027 in
+ let jan = List.hd (as_list (get [ "months" ] v)) in
+ Alcotest.(check string) "month name" "Ianuarius" (as_str (get [ "name" ] jan));
+ let d1 = List.hd (as_list (get [ "days" ] v)) in
+ Alcotest.(check string) "weekday" "Feria VI" (as_str (get [ "weekday" ] d1));
+ Alcotest.(check string) "rank" "I classis" (as_str (get [ "rank_name" ] d1));
+ Alcotest.(check string) "colour" "albus" (as_str (get [ "colour_name" ] d1))
+
let suite =
( "View",
[ Alcotest.test_case "year shape" `Quick test_year_shape;
Alcotest.test_case "weeks flatten to days" `Quick test_weeks_flatten_to_days;
Alcotest.test_case "padding cells flagged" `Quick test_padding_cells_are_flagged;
+ Alcotest.test_case "padding and real days share key set" `Quick test_padding_and_real_share_key_set;
+ Alcotest.test_case "no day shows a slug" `Quick test_no_day_shows_a_slug;
+ Alcotest.test_case "slug unchanged by naming" `Quick test_slug_is_unchanged_by_naming;
+ Alcotest.test_case "raw name equals slug" `Quick test_raw_name_equals_slug;
+ Alcotest.test_case "vocabularies localise" `Quick test_vocabularies_localise;
Alcotest.test_case "day fields" `Quick test_day_fields;
Alcotest.test_case "exactly one colour flag" `Quick test_exactly_one_colour_flag ] )