diff --git a/crates/bindings/README.md b/crates/bindings/README.md index 23aa19f..d75de60 100644 --- a/crates/bindings/README.md +++ b/crates/bindings/README.md @@ -84,6 +84,12 @@ except ValueError as why: | `canonical_subject` | what a closed subject scheme calls a heading | | `compose_subjects` | the subjects a book should carry under one | | `needs_subject` | whether it carries a heading the scheme knows | +| `clean_record` | a whole catalogue record audited and repaired | +| `record_problems`, `record_severity` | what is wrong with one, and how much | +| `title_in_author`, `looks_like_person` | the two commonest catalogue faults | +| `is_shouting`, `title_case`, `clean_underscores` | the repairs that need no lookup | +| `valid_isbn`, `isbn_from_text`, `isbn_from_filename` | ISBNs, checked and found | +| `same_work` | whether two titles name the same book | | `citation_prompt` | the question `citefind --prompt` writes | | `merge_citation_reply` | what `citefind --merge` does with the answer | diff --git a/crates/bindings/python/catena/__init__.py b/crates/bindings/python/catena/__init__.py index 6feead9..c1cb82e 100644 --- a/crates/bindings/python/catena/__init__.py +++ b/crates/bindings/python/catena/__init__.py @@ -12,48 +12,72 @@ lines of JSON. from ._catena import ( __version__, - cue_windows, canonical_subject, chunk_markdown, citation_prompt, + clean_record, + clean_underscores, compose_subjects, count_tokens, + cue_windows, find_citations, fuse_hits, identify, + is_shouting, + isbn_from_filename, + isbn_from_text, looks_like_footnote_citations, + looks_like_person, merge_citation_reply, needs_subject, pack_cues, parse_identifier, parse_subtitles, + record_problems, + record_severity, + same_work, scrub, segment_cues, series_of, split_collection, suggest_author, + title_case, + title_in_author, + valid_isbn, ) __all__ = [ "__version__", - "cue_windows", "canonical_subject", "chunk_markdown", "citation_prompt", + "clean_record", + "clean_underscores", "compose_subjects", "count_tokens", + "cue_windows", "find_citations", "fuse_hits", "identify", + "is_shouting", + "isbn_from_filename", + "isbn_from_text", "looks_like_footnote_citations", + "looks_like_person", "merge_citation_reply", "needs_subject", "pack_cues", "parse_identifier", "parse_subtitles", + "record_problems", + "record_severity", + "same_work", "scrub", "segment_cues", "series_of", "split_collection", "suggest_author", + "title_case", + "title_in_author", + "valid_isbn", ] diff --git a/crates/bindings/python/catena/__init__.pyi b/crates/bindings/python/catena/__init__.pyi index 9d9770a..e003c5b 100644 --- a/crates/bindings/python/catena/__init__.pyi +++ b/crates/bindings/python/catena/__init__.pyi @@ -61,3 +61,17 @@ def parse_identifier(text: str) -> dict[str, Any] | None: ... def series_of( title: str, author: str | None = ... ) -> dict[str, Any] | None: ... +def clean_record(book: Mapping[str, Any]) -> dict[str, Any]: ... +def record_problems( + title: str | None = ..., author: str | None = ..., isbn: str | None = ... +) -> list[str]: ... +def record_severity(reasons: Iterable[str]) -> int: ... +def title_in_author(title: str | None = ..., author: str | None = ...) -> bool: ... +def looks_like_person(author: str) -> bool: ... +def is_shouting(title: str) -> bool: ... +def title_case(title: str) -> str: ... +def clean_underscores(title: str) -> str: ... +def valid_isbn(text: str) -> bool: ... +def isbn_from_text(text: str) -> str | None: ... +def isbn_from_filename(name: str) -> str | None: ... +def same_work(ours: str, theirs: str) -> bool: ... diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 5d4fcde..66ca8e5 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -215,6 +215,71 @@ pub fn parse_identifier(text: &str) -> Option { idnorm::parse(text) } +/// A catalogue record audited: what is wrong with it, and the repairs that +/// need no lookup. +pub fn clean_record(book: &bibclean::Book) -> bibclean::Cleaned { + bibclean::clean(book) +} + +/// What is wrong with a record, named. +pub fn record_problems( + title: Option<&str>, + author: Option<&str>, + isbn: Option<&str>, +) -> Vec { + bibclean::reasons::problems(title, author, isbn) +} + +/// How much attention a set of reasons deserves, worst first. +pub fn record_severity(reasons: &[String]) -> u32 { + bibclean::reasons::severity(reasons) +} + +/// Whether the title has been copied into the author field. +pub fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool { + bibclean::reasons::title_in_author(title, author) +} + +/// Whether an author field names a person rather than a publisher or a shelf. +pub fn looks_like_person(author: &str) -> bool { + bibclean::reasons::looks_like_person(author) +} + +/// Whether a title is set in capitals throughout. +pub fn is_shouting(title: &str) -> bool { + bibclean::fix::is_shouting(title) +} + +/// A title in title case, leaving the small words small. +pub fn title_case(title: &str) -> String { + bibclean::fix::title_case(title) +} + +/// A title with the underscores a filename left in it taken out. +pub fn clean_underscores(title: &str) -> String { + bibclean::fix::clean_underscores(title) +} + +/// Whether a string is a well-formed ISBN, check digit and all. +pub fn valid_isbn(text: &str) -> bool { + bibclean::isbn::valid(text) +} + +/// An ISBN found in a run of text. +pub fn isbn_from_text(text: &str) -> Option { + bibclean::isbn::from_text(text) +} + +/// The same, for a filename, where the digits are often all there is. +pub fn isbn_from_filename(name: &str) -> Option { + bibclean::isbn::from_filename(name) +} + +/// Whether two titles name the same work. +pub fn same_work(ours: &str, theirs: &str) -> bool { + bibclean::matching::same_work(ours, theirs) +} + /// What a closed subject scheme says about a set of headings. /// /// The scheme is supplied rather than built in — which subjects a collection @@ -304,6 +369,29 @@ mod tests { assert_eq!(count_tokens("a passage"), textkit::count("a passage")); } + #[test] + fn the_record_surface_agrees_with_the_library_underneath() { + assert_eq!( + record_problems(Some("THE REPUBLIC"), Some("Plato"), None), + bibclean::reasons::problems(Some("THE REPUBLIC"), Some("Plato"), None) + ); + assert_eq!(title_case("THE REPUBLIC"), "The Republic"); + assert_eq!(clean_underscores("the_republic_pdf"), "the republic"); + assert!(valid_isbn("9780306406157")); + assert_eq!( + isbn_from_filename("book-9780306406157.epub").as_deref(), + Some("9780306406157") + ); + assert!(same_work( + "Homo Deus", + "Homo Deus: A Brief History of Tomorrow" + )); + // Name *shape*, which is deliberately permissive — it is one signal + // among several in `record_problems`, not a verdict on its own. + assert!(looks_like_person("Copleston, Frederick")); + assert!(!looks_like_person("")); + } + #[test] fn the_author_surface_agrees_with_the_library_underneath() { const TITLE: &str = "Aquinas (Companion)"; diff --git a/crates/bindings/src/python.rs b/crates/bindings/src/python.rs index 0ea49e7..75bf2f3 100644 --- a/crates/bindings/src/python.rs +++ b/crates/bindings/src/python.rs @@ -220,6 +220,81 @@ fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult(py: Python<'py>, book: &Bound<'py, PyAny>) -> PyResult> { + to_py(py, &crate::clean_record(&from_py(book)?)) +} + +/// What is wrong with a record, named. +#[pyfunction] +#[pyo3(signature = (title = None, author = None, isbn = None))] +fn record_problems(title: Option<&str>, author: Option<&str>, isbn: Option<&str>) -> Vec { + crate::record_problems(title, author, isbn) +} + +/// How much attention a set of reasons deserves, worst first. +#[pyfunction] +fn record_severity(reasons: Vec) -> u32 { + crate::record_severity(&reasons) +} + +/// Whether the title has been copied into the author field. +#[pyfunction] +#[pyo3(signature = (title = None, author = None))] +fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool { + crate::title_in_author(title, author) +} + +/// Whether an author field names a person rather than a publisher. +#[pyfunction] +fn looks_like_person(author: &str) -> bool { + crate::looks_like_person(author) +} + +/// Whether a title is set in capitals throughout. +#[pyfunction] +fn is_shouting(title: &str) -> bool { + crate::is_shouting(title) +} + +/// A title in title case, leaving the small words small. +#[pyfunction] +fn title_case(title: &str) -> String { + crate::title_case(title) +} + +/// A title with the underscores a filename left in it taken out. +#[pyfunction] +fn clean_underscores(title: &str) -> String { + crate::clean_underscores(title) +} + +/// Whether a string is a well-formed ISBN, check digit and all. +#[pyfunction] +fn valid_isbn(text: &str) -> bool { + crate::valid_isbn(text) +} + +/// An ISBN found in a run of text. +#[pyfunction] +fn isbn_from_text(text: &str) -> Option { + crate::isbn_from_text(text) +} + +/// An ISBN found in a filename. +#[pyfunction] +fn isbn_from_filename(name: &str) -> Option { + crate::isbn_from_filename(name) +} + +/// Whether two titles name the same work. +#[pyfunction] +fn same_work(ours: &str, theirs: &str) -> bool { + crate::same_work(ours, theirs) +} + /// The subject a heading means under a scheme, or `None`. #[pyfunction] fn canonical_subject(scheme: &Bound<'_, PyAny>, name: &str) -> PyResult> { @@ -338,6 +413,18 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(looks_like_footnote_citations, module)?)?; module.add_function(wrap_pyfunction!(suggest_author, module)?)?; module.add_function(wrap_pyfunction!(needs_subject, module)?)?; + module.add_function(wrap_pyfunction!(clean_record, module)?)?; + module.add_function(wrap_pyfunction!(record_problems, module)?)?; + module.add_function(wrap_pyfunction!(record_severity, module)?)?; + module.add_function(wrap_pyfunction!(title_in_author, module)?)?; + module.add_function(wrap_pyfunction!(looks_like_person, module)?)?; + module.add_function(wrap_pyfunction!(is_shouting, module)?)?; + module.add_function(wrap_pyfunction!(title_case, module)?)?; + module.add_function(wrap_pyfunction!(clean_underscores, module)?)?; + module.add_function(wrap_pyfunction!(valid_isbn, module)?)?; + module.add_function(wrap_pyfunction!(isbn_from_text, module)?)?; + module.add_function(wrap_pyfunction!(isbn_from_filename, module)?)?; + module.add_function(wrap_pyfunction!(same_work, module)?)?; module.add_function(wrap_pyfunction!(compose_subjects, module)?)?; module.add_function(wrap_pyfunction!(canonical_subject, module)?)?; module.add_function(wrap_pyfunction!(citation_prompt, module)?)?;