seriesof: the series a book belongs to, and which volume this is

This commit is contained in:
funkpower
2026-09-19 19:03:47 -04:00
parent e9d22ee1fa
commit 05b363ea8a
25 changed files with 1406 additions and 4 deletions
+41
View File
@@ -356,6 +356,35 @@ pub unsafe extern "C" fn catena_identify(
give_json(&crate::identify(name, bytes))
}
/// The series a title belongs to. Returns a JSON proposal, or NULL when the
/// title says nothing about a series — which is most titles, and not an
/// error: `catena_last_error` is NULL in that case.
///
/// # Safety
///
/// `title` must be NUL-terminated UTF-8; `author` likewise, or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn catena_series_of(
title: *const c_char,
author: *const c_char,
) -> *mut c_char {
clear_error();
let Some(title) = (unsafe { borrow(title, "title") }) else {
return ptr::null_mut();
};
let author = match author.is_null() {
true => None,
false => match unsafe { borrow(author, "author") } {
Some(author) => Some(author),
None => return ptr::null_mut(),
},
};
match crate::series_of(title, author) {
Some(found) => give_json(&found),
None => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -455,6 +484,18 @@ mod tests {
assert!(chunks.contains("the mill stood at the northern end"));
}
#[test]
fn a_title_with_no_series_is_null_and_not_an_error() {
let title = c"Being and Time";
assert!(through(|| unsafe { catena_series_of(title.as_ptr(), ptr::null()) }).is_none());
assert_eq!(error(), None, "no series is an answer, not a failure");
let series = c"The Philokalia Vol 3";
let json = through(|| unsafe { catena_series_of(series.as_ptr(), ptr::null()) })
.expect("a proposal");
assert!(json.contains("\"volume\":3"));
}
#[test]
fn the_abi_version_is_the_one_the_header_declares() {
assert_eq!(catena_abi_version(), 1);
+13
View File
@@ -176,6 +176,11 @@ pub fn identify(name: &str, bytes: &[u8]) -> Doc {
booksniff::sniff(&bytes, name)
}
/// The series a title belongs to, and which volume of it this is.
pub fn series_of(title: &str, author: Option<&str>) -> Option<seriesof::Proposal> {
seriesof::series_of(title, author)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -232,6 +237,14 @@ mod tests {
assert_eq!(channel_named("lexical"), None);
}
#[test]
fn a_series_comes_back_the_same_as_the_library_reads_it() {
let found = series_of("The Philokalia Vol 3", None).expect("a series");
assert_eq!(found.series, "The Philokalia");
assert_eq!(found.volume, Some(3));
assert_eq!(series_of("Being and Time", None), None);
}
#[test]
fn bytes_are_identified_the_same_way_a_file_is() {
let pdf = b"%PDF-1.4\n";
+17
View File
@@ -192,6 +192,22 @@ fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult<Bound<'py
to_py(py, &crate::identify(name, data))
}
/// The series a title belongs to, and which volume of it this is.
///
/// `None` when the title says nothing about a series, which is most titles.
#[pyfunction]
#[pyo3(signature = (title, author = None))]
fn series_of<'py>(
py: Python<'py>,
title: &str,
author: Option<&str>,
) -> PyResult<Option<Bound<'py, PyAny>>> {
match crate::series_of(title, author) {
Some(found) => Ok(Some(to_py(py, &found)?)),
None => Ok(None),
}
}
/// The module Python imports.
#[pymodule]
fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -204,5 +220,6 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(split_collection, module)?)?;
module.add_function(wrap_pyfunction!(fuse_hits, module)?)?;
module.add_function(wrap_pyfunction!(identify, module)?)?;
module.add_function(wrap_pyfunction!(series_of, module)?)?;
Ok(())
}
+26
View File
@@ -70,6 +70,20 @@ export interface Hit {
payload: unknown;
}
/** What a title says about its series. */
export interface SeriesProposal {
v: number;
title: string;
author?: string;
series: string;
/** Which volume, where the title said. Absent for membership without order. */
volume?: number;
/** A title reconstructed from publisher shorthand, where one reliably can be. */
proposed_title?: string;
/** "volume" or "publisher". */
method: string;
}
/** What a file is. */
export interface Doc {
v: number;
@@ -208,6 +222,17 @@ pub fn identify(name: &str, data: &[u8]) -> Result<JsValue, JsValue> {
to_js(&crate::identify(name, data))
}
/// The series a title belongs to, and which volume of it this is.
///
/// `undefined` when the title says nothing about a series.
#[wasm_bindgen(js_name = seriesOf, skip_typescript)]
pub fn series_of(title: &str, author: Option<String>) -> Result<JsValue, JsValue> {
match crate::series_of(title, author.as_deref()) {
Some(found) => to_js(&found),
None => Ok(JsValue::UNDEFINED),
}
}
/// Declarations for the functions whose return type the generator cannot
/// infer, so a caller gets `Chunk[]` rather than `any`.
#[wasm_bindgen(typescript_custom_section)]
@@ -218,4 +243,5 @@ export function packCues(cues: Cue[], seconds?: number, max_tokens?: number): Ch
export function splitCollection(markdown: string): Section[];
export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[];
export function identify(name: string, data: Uint8Array): Doc;
export function seriesOf(title: string, author?: string): SeriesProposal | undefined;
"#;