diff options
| -rw-r--r-- | bin/dune | 2 | ||||
| -rw-r--r-- | bin/main.ml | 43 | ||||
| -rw-r--r-- | lib/render/dune | 2 | ||||
| -rw-r--r-- | lib/render/emit_csv.ml | 16 | ||||
| -rw-r--r-- | lib/render/emit_ics.ml | 12 | ||||
| -rw-r--r-- | lib/render/emit_xml.ml | 17 | ||||
| -rw-r--r-- | lib/render/view.ml | 121 | ||||
| -rw-r--r-- | lib/render/view.mli | 18 | ||||
| -rw-r--r-- | schema/colitur-v1.xsd | 17 | ||||
| -rw-r--r-- | schema/day-v1.json | 58 | ||||
| -rw-r--r-- | test/cli.t | 23 | ||||
| -rw-r--r-- | test/test_emit.ml | 6 | ||||
| -rw-r--r-- | test/test_view.ml | 92 |
13 files changed, 320 insertions, 107 deletions
@@ -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" } } } @@ -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 ] ) |
