summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 14:40:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 14:40:12 +0200
commit405910d2fd245e7a11e09eecb8c6fffb68d2169c (patch)
tree7cb7e0011fee6c1c07ae81762640f0ab867e88b6
parent1aeca948c2a2a82bffaaec65077140aa05f7b3f4 (diff)
downloadcolitur-405910d2fd245e7a11e09eecb8c6fffb68d2169c.tar.gz
colitur-405910d2fd245e7a11e09eecb8c6fffb68d2169c.zip
feat(citation): canonical book ids and tradition mapping
Seven books arrive in two spellings, inherited from lectio's ini and ultimately from Divinum Officium. Collapse them onto one id here rather than editing generated data. Naming and renumbering are kept apart: a tradition decides which book an id denotes, a language file decides what it is called.
-rw-r--r--lib/citation/book.ml74
-rw-r--r--lib/citation/book.mli49
-rw-r--r--lib/citation/dune2
-rw-r--r--test/dune2
-rw-r--r--test/test_citation.ml43
-rw-r--r--test/test_colitur.ml1
6 files changed, 170 insertions, 1 deletions
diff --git a/lib/citation/book.ml b/lib/citation/book.ml
new file mode 100644
index 0000000..a2f9678
--- /dev/null
+++ b/lib/citation/book.ml
@@ -0,0 +1,74 @@
+(* SPDX-License-Identifier: AGPL-3.0-or-later *)
+
+type id = string
+
+let to_string t = t
+
+(* Every book the shipped EF lectionary cites, with every spelling it uses.
+ The dotted/undotted pairs are inherited from lectio -- see book.mli. *)
+let table =
+ [ ("genesis", [ "Gen" ]);
+ ("exodus", [ "Ex" ]);
+ ("leviticus", [ "Lev" ]);
+ ("numbers", [ "Num" ]);
+ ("kings_3", [ "3 Kings"; "3 Kgs." ]);
+ ("kings_4", [ "4 Kings" ]);
+ ("esdras_2", [ "2 Esd." ]);
+ ("esther", [ "Esther" ]);
+ ("ecclesiasticus", [ "Ecclus" ]);
+ ("isaiah", [ "Isa"; "Isa." ]);
+ ("jeremiah", [ "Jer" ]);
+ ("ezekiel", [ "Ezech" ]);
+ ("daniel", [ "Dan" ]);
+ ("osee", [ "Osee" ]);
+ ("joel", [ "Joel" ]);
+ ("jonas", [ "Jonas" ]);
+ ("matthew", [ "Matt"; "Matt." ]);
+ ("mark", [ "Mark" ]);
+ ("luke", [ "Luke" ]);
+ ("john", [ "John" ]);
+ ("acts", [ "Acts" ]);
+ ("romans", [ "Rom" ]);
+ ("corinthians_1", [ "1 Cor"; "1 Cor." ]);
+ ("corinthians_2", [ "2 Cor." ]);
+ ("galatians", [ "Gal" ]);
+ ("ephesians", [ "Eph"; "Eph." ]);
+ ("philippians", [ "Phil" ]);
+ ("colossians", [ "Col" ]);
+ ("thessalonians_1", [ "1 Thess"; "1 Thess." ]);
+ ("thessalonians_2", [ "2 Thess" ]);
+ ("titus", [ "Titus" ]);
+ ("hebrews", [ "Heb" ]);
+ ("james", [ "Jas" ]);
+ ("peter_1", [ "1 Pet"; "1 Pet." ]);
+ ("john_1", [ "1 John" ]) ]
+
+(* Targets a tradition can map ONTO that the Vulgate data never cites
+ directly. Present so [tradition_of_fields] can validate both sides. *)
+let tradition_targets =
+ [ "kings_1"; "kings_2"; "nehemiah"; "sirach"; "hosea"; "jonah" ]
+
+let all = List.map fst table @ tradition_targets
+
+let tokens =
+ List.concat_map (fun (id, sp) -> List.map (fun s -> (s, id)) sp) table
+
+let of_token s =
+ let s = String.trim s in
+ List.assoc_opt s tokens
+
+type tradition = (string * string) list
+
+let vulgate = []
+
+let known id = List.mem id all
+
+let tradition_of_fields fields =
+ List.filter (fun (a, b) -> known a && known b) fields
+
+let unknown_fields fields =
+ List.filter_map
+ (fun (a, b) -> if known a && known b then None else Some a)
+ fields
+
+let map tr id = match List.assoc_opt id tr with Some x -> x | None -> id
diff --git a/lib/citation/book.mli b/lib/citation/book.mli
new file mode 100644
index 0000000..dd04e0d
--- /dev/null
+++ b/lib/citation/book.mli
@@ -0,0 +1,49 @@
+(* SPDX-License-Identifier: AGPL-3.0-or-later *)
+
+(** Bible books: one canonical id per book, and the tradition that decides
+ which book an id denotes.
+
+ Ids follow the VULGATE structure ([kings_3], [esdras_2],
+ [ecclesiasticus]), because the Vulgate is the default tradition and the
+ shipped 1962 data is Vulgate throughout. An id is internal -- it is never
+ shown to a reader, exactly as a slug is never shown. *)
+
+type id
+
+val to_string : id -> string
+
+(** Resolve one spelling as it appears in the data. Returns [None] for
+ anything not in {!tokens}.
+
+ SEVEN books arrive in two spellings ([Isa]/[Isa.], [3 Kgs.]/[3 Kings],
+ and five more). That inconsistency is INHERITED from lectio's own ini,
+ which is itself generated from missalemeum/Divinum Officium -- it is not
+ a colitur transcription error, and the data is deliberately left
+ untouched. Both spellings resolve here instead. *)
+val of_token : string -> id option
+
+(** Every id this build knows, for coverage checks. *)
+val all : id list
+
+(** Every accepted spelling paired with its id. *)
+val tokens : (string * id) list
+
+(** A numbering tradition: which book an id denotes. Separate from NAMING
+ (what a book is called), which lives in a language file's [\[bible\]]
+ section, because naming varies by language and this does not -- "modern
+ numbering" is the same decision in Latin, Polish and English. *)
+type tradition
+
+(** The identity tradition: the Vulgate, as the 1962 Missal prints it. The
+ default; colitur never silently renumbers. *)
+val vulgate : tradition
+
+(** Build a tradition from an ini section's fields. An entry naming an
+ unknown id on either side is IGNORED, not fatal: a traditions file
+ written for a newer colitur must still work on an older one. Use
+ {!unknown_fields} to report them. *)
+val tradition_of_fields : (string * string) list -> tradition
+
+val unknown_fields : (string * string) list -> string list
+
+val map : tradition -> id -> id
diff --git a/lib/citation/dune b/lib/citation/dune
new file mode 100644
index 0000000..e6cec3c
--- /dev/null
+++ b/lib/citation/dune
@@ -0,0 +1,2 @@
+(library
+ (name colitur_citation))
diff --git a/test/dune b/test/dune
index 84c5ec7..a90b715 100644
--- a/test/dune
+++ b/test/dune
@@ -1,6 +1,6 @@
(test
(name test_colitur)
- (libraries colitur_kernel colitur_naming colitur_render rite_ef alcotest qcheck qcheck-alcotest sexplib)
+ (libraries colitur_kernel colitur_naming colitur_render colitur_citation rite_ef alcotest qcheck qcheck-alcotest sexplib)
(deps
../data/ef/sanctoral.sexp
../data/ef/adjustments.sexp
diff --git a/test/test_citation.ml b/test/test_citation.ml
new file mode 100644
index 0000000..0694d8b
--- /dev/null
+++ b/test/test_citation.ml
@@ -0,0 +1,43 @@
+module B = Colitur_citation.Book
+
+let s = Alcotest.string
+
+let test_both_spellings_are_one_book () =
+ (* The seven inherited duplicate spellings must collapse. This is the
+ whole reason the parser exists rather than a regex. *)
+ let same a b =
+ match B.of_token a, B.of_token b with
+ | Some x, Some y ->
+ Alcotest.(check s) (a ^ " = " ^ b) (B.to_string x) (B.to_string y)
+ | _ -> Alcotest.failf "%s or %s did not resolve" a b
+ in
+ same "Isa" "Isa.";
+ same "Matt" "Matt.";
+ same "1 Cor" "1 Cor.";
+ same "1 Pet" "1 Pet.";
+ same "1 Thess" "1 Thess.";
+ same "Eph" "Eph.";
+ same "3 Kgs." "3 Kings"
+
+let test_unknown_token_is_none () =
+ Alcotest.(check bool) "not a book" true (B.of_token "Nonesuch" = None)
+
+let test_vulgate_is_identity () =
+ match B.of_token "3 Kings" with
+ | None -> Alcotest.fail "3 Kings did not resolve"
+ | Some k ->
+ Alcotest.(check s) "unmapped" "kings_3" (B.to_string (B.map B.vulgate k))
+
+let test_modern_renumbers () =
+ let modern = B.tradition_of_fields [ ("kings_3", "kings_1") ] in
+ match B.of_token "3 Kings" with
+ | None -> Alcotest.fail "3 Kings did not resolve"
+ | Some k ->
+ Alcotest.(check s) "renumbered" "kings_1" (B.to_string (B.map modern k))
+
+let suite =
+ ( "book",
+ [ Alcotest.test_case "both spellings one book" `Quick test_both_spellings_are_one_book;
+ Alcotest.test_case "unknown token" `Quick test_unknown_token_is_none;
+ Alcotest.test_case "vulgate identity" `Quick test_vulgate_is_identity;
+ Alcotest.test_case "modern renumbers" `Quick test_modern_renumbers ] )
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 1bdd220..46a31aa 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -4,6 +4,7 @@ let () =
[ Test_date.suite; Test_computus.suite; Test_colour.suite; Test_slug.suite; Test_names.suite;
Test_lang.suite;
Test_lang_coverage.suite;
+ Test_citation.suite;
Test_config.suite;
Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite;