diff --git a/Cargo.lock b/Cargo.lock index b9fbcda..b101f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,6 +252,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" name = "catena" version = "0.1.0" dependencies = [ + "bibclean", "booksniff", "chunkmd", "citefind", diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index 4ceafac..0a22591 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -29,6 +29,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] booksniff.workspace = true +bibclean.workspace = true chunkmd.workspace = true citefind.workspace = true corpus-core.workspace = true diff --git a/crates/bindings/README.md b/crates/bindings/README.md index 75f2bf1..cfb17c1 100644 --- a/crates/bindings/README.md +++ b/crates/bindings/README.md @@ -80,6 +80,7 @@ except ValueError as why: | `parse_identifier` | [`idnorm`](../idnorm/README.md) | | `find_citations` | [`citefind`](../citefind/README.md) | | `looks_like_footnote_citations` | whether a body chunk carries notes worth reading | +| `suggest_author` | [`bibclean`](../bibclean/README.md)'s author recovery | | `citation_prompt` | the question `citefind --prompt` writes | | `merge_citation_reply` | what `citefind --merge` does with the answer | diff --git a/crates/bindings/include/catena.h b/crates/bindings/include/catena.h index 8a65bff..f7bcf5c 100644 --- a/crates/bindings/include/catena.h +++ b/crates/bindings/include/catena.h @@ -98,6 +98,11 @@ char *catena_series_of(const char *title, const char *author); * one — an answer, not an error. */ char *catena_parse_identifier(const char *text); +/* The best author a record's own title and front matter can offer. Returns a + * JSON suggestion, or NULL when neither names a person — an answer, not an + * error. `front_matter` may be NULL. */ +char *catena_suggest_author(const char *title, const char *front_matter); + /* Whether a chunk of body text carries footnote or endnote citations. * Returns 1 for yes, 0 for no, -1 on error. */ int catena_looks_like_footnote_citations(const char *text); diff --git a/crates/bindings/npm/typecheck.ts b/crates/bindings/npm/typecheck.ts index cff81a5..2a84f23 100644 --- a/crates/bindings/npm/typecheck.ts +++ b/crates/bindings/npm/typecheck.ts @@ -4,9 +4,9 @@ import { chunkMarkdown, scrub, countTokens, parseSubtitles, packCues, splitCollection, fuseHits, identify, seriesOf, parseIdentifier, segmentCues, cueWindows, - findCitations, citationPrompt, mergeCitationReply, + findCitations, citationPrompt, mergeCitationReply, suggestAuthor, type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal, type Identifier, type Segment, - type Reference, + type Reference, type AuthorSuggestion, } from "./dist/web/catena.js"; const chunks: Chunk[] = chunkMarkdown("# One\n\nbody\n", 512, 64, 50, 768); @@ -47,6 +47,9 @@ const proposal: SeriesProposal | undefined = seriesOf("The Philokalia Vol 3"); const volume: number | undefined = proposal?.volume; seriesOf("Aquinas (Companion)", "Cambridge"); +const suggested: AuthorSuggestion | undefined = suggestAuthor("Aquinas", "by Thomas Aquinas"); +void suggested?.confidence; + const references: Reference[] = findCitations("Smith, John. *A Title*. Oxford: Clarendon Press, 1998."); const unsettled: string[] = references[0].unsettled; const ask: string | undefined = citationPrompt(references); diff --git a/crates/bindings/python/catena/__init__.py b/crates/bindings/python/catena/__init__.py index 00d6593..a8bbccd 100644 --- a/crates/bindings/python/catena/__init__.py +++ b/crates/bindings/python/catena/__init__.py @@ -28,6 +28,7 @@ from ._catena import ( segment_cues, series_of, split_collection, + suggest_author, ) __all__ = [ @@ -48,4 +49,5 @@ __all__ = [ "segment_cues", "series_of", "split_collection", + "suggest_author", ] diff --git a/crates/bindings/python/catena/__init__.pyi b/crates/bindings/python/catena/__init__.pyi index 6c09d32..b6c15aa 100644 --- a/crates/bindings/python/catena/__init__.pyi +++ b/crates/bindings/python/catena/__init__.pyi @@ -39,6 +39,9 @@ def fuse_hits( def identify(name: str, data: bytes) -> dict[str, Any]: ... def find_citations(text: str) -> list[dict[str, Any]]: ... def looks_like_footnote_citations(text: str) -> bool: ... +def suggest_author( + title: str, front_matter: str | None = ... +) -> dict[str, Any] | None: ... def citation_prompt(references: Iterable[Mapping[str, Any]]) -> str | None: ... def merge_citation_reply( references: Iterable[Mapping[str, Any]], reply: str diff --git a/crates/bindings/src/capi.rs b/crates/bindings/src/capi.rs index c304185..b220bc1 100644 --- a/crates/bindings/src/capi.rs +++ b/crates/bindings/src/capi.rs @@ -417,6 +417,35 @@ pub unsafe extern "C" fn catena_identify( give_json(&crate::identify(name, bytes)) } +/// The best author a record's own title and front matter can offer. Returns +/// a JSON suggestion, or NULL when neither says anything that reads as a +/// name — an answer, not an error. +/// +/// # Safety +/// +/// `title` must be NUL-terminated UTF-8; `front_matter` likewise, or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn catena_suggest_author( + title: *const c_char, + front_matter: *const c_char, +) -> *mut c_char { + clear_error(); + let Some(title) = (unsafe { borrow(title, "title") }) else { + return ptr::null_mut(); + }; + let front = match front_matter.is_null() { + true => None, + false => match unsafe { borrow(front_matter, "front_matter") } { + Some(front) => Some(front), + None => return ptr::null_mut(), + }, + }; + match crate::suggest_author(title, front) { + Some(found) => give_json(&found), + None => ptr::null_mut(), + } +} + /// Whether a chunk of body text carries footnote or endnote citations. /// Returns 1 for yes, 0 for no, -1 on error. /// diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index e68889e..ee58c30 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -215,6 +215,17 @@ pub fn parse_identifier(text: &str) -> Option { idnorm::parse(text) } +/// The best author a record's own title and front matter can offer. +/// +/// `None` when neither says anything that reads as a person's name, which is +/// the honest answer for a scanned book with no title page. +pub fn suggest_author( + title: &str, + front_matter: Option<&str>, +) -> Option { + bibclean::author::best(title, front_matter) +} + /// Whether a chunk of body text carries footnote or endnote citations. /// /// The structural signal a converter leaves on EPUB3 note definitions first; @@ -269,6 +280,17 @@ mod tests { assert_eq!(count_tokens("a passage"), textkit::count("a passage")); } + #[test] + fn the_author_surface_agrees_with_the_library_underneath() { + const TITLE: &str = "Aquinas (Companion)"; + const FRONT: &str = "THE CAMBRIDGE COMPANION TO AQUINAS\n\nedited by Norman Kretzmann"; + assert_eq!( + suggest_author(TITLE, Some(FRONT)), + bibclean::author::best(TITLE, Some(FRONT)) + ); + assert!(suggest_author("", None).is_none()); + } + #[test] fn the_footnote_signal_agrees_with_the_library_underneath() { const NOTES: &str = "1. John Smith, *A Title* (Oxford: Clarendon Press, 1998), 183.\n\ diff --git a/crates/bindings/src/python.rs b/crates/bindings/src/python.rs index 0421cdf..dc97a1b 100644 --- a/crates/bindings/src/python.rs +++ b/crates/bindings/src/python.rs @@ -220,6 +220,22 @@ fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult( + py: Python<'py>, + title: &str, + front_matter: Option<&str>, +) -> PyResult>> { + match crate::suggest_author(title, front_matter) { + Some(found) => Ok(Some(to_py(py, &found)?)), + None => Ok(None), + } +} + /// Whether a chunk of body text carries footnote or endnote citations. #[pyfunction] fn looks_like_footnote_citations(text: &str) -> bool { @@ -297,6 +313,7 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(identify, module)?)?; module.add_function(wrap_pyfunction!(find_citations, module)?)?; module.add_function(wrap_pyfunction!(looks_like_footnote_citations, module)?)?; + module.add_function(wrap_pyfunction!(suggest_author, module)?)?; module.add_function(wrap_pyfunction!(citation_prompt, module)?)?; module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?; module.add_function(wrap_pyfunction!(series_of, module)?)?; diff --git a/crates/bindings/src/wasm.rs b/crates/bindings/src/wasm.rs index dcbb45d..992f604 100644 --- a/crates/bindings/src/wasm.rs +++ b/crates/bindings/src/wasm.rs @@ -97,6 +97,17 @@ export interface Identifier { url?: string; } +/** A name a record's own text can offer, and how far to trust it. */ +export interface AuthorSuggestion { + author: string; + /** Which signal produced it. */ + method: string; + confidence: number; + /** The text it was read from, so a person can check it. */ + evidence: string; + isbn: string | null; +} + /** One reference out of a bibliography or a page of notes. */ export interface Reference { v: number; @@ -305,6 +316,17 @@ pub fn identify(name: &str, data: &[u8]) -> Result { to_js(&crate::identify(name, data)) } +/// The best author a record's own title and front matter can offer. +/// +/// `undefined` when neither says anything that reads as a person's name. +#[wasm_bindgen(js_name = suggestAuthor, skip_typescript)] +pub fn suggest_author(title: &str, front_matter: Option) -> Result { + match crate::suggest_author(title, front_matter.as_deref()) { + Some(found) => to_js(&found), + None => Ok(JsValue::UNDEFINED), + } +} + /// Whether a chunk of body text carries footnote or endnote citations. #[wasm_bindgen(js_name = looksLikeFootnoteCitations)] pub fn looks_like_footnote_citations(text: &str) -> bool { @@ -371,6 +393,7 @@ export function cueWindows(cues: Cue[]): string[]; export function splitCollection(markdown: string): Section[]; export function fuseHits(hits: Hit[], k?: number, weights?: Record, limit?: number): Hit[]; export function identify(name: string, data: Uint8Array): Doc; +export function suggestAuthor(title: string, front_matter?: string): AuthorSuggestion | undefined; export function findCitations(text: string): Reference[]; export function citationPrompt(references: Reference[]): string | undefined; export function mergeCitationReply(references: Reference[], reply: string): Reference[];