idnorm and bibfetch: an identifier read, and a register asked

This commit is contained in:
funkpower
2026-09-19 19:19:54 -04:00
parent 4d14544409
commit d47881ef3b
29 changed files with 1920 additions and 9 deletions
+29
View File
@@ -385,6 +385,24 @@ pub unsafe extern "C" fn catena_series_of(
}
}
/// Read an identifier out of whatever it arrived wrapped in. Returns a JSON
/// record, or NULL when the string is not one — an answer, not an error.
///
/// # Safety
///
/// `text` must be NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn catena_parse_identifier(text: *const c_char) -> *mut c_char {
clear_error();
let Some(text) = (unsafe { borrow(text, "text") }) else {
return ptr::null_mut();
};
match crate::parse_identifier(text) {
Some(found) => give_json(&found),
None => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -496,6 +514,17 @@ mod tests {
assert!(json.contains("\"volume\":3"));
}
#[test]
fn an_identifier_is_normalised_across_the_boundary() {
let text = c"https://doi.org/10.1000/182";
let json = through(|| unsafe { catena_parse_identifier(text.as_ptr()) }).expect("a record");
assert!(json.contains("\"id\":\"10.1000/182\""), "{json}");
let nope = c"hello";
assert!(through(|| unsafe { catena_parse_identifier(nope.as_ptr()) }).is_none());
assert_eq!(error(), None, "not an identifier is an answer");
}
#[test]
fn the_abi_version_is_the_one_the_header_declares() {
assert_eq!(catena_abi_version(), 1);
+13
View File
@@ -176,6 +176,12 @@ pub fn identify(name: &str, bytes: &[u8]) -> Doc {
booksniff::sniff(&bytes, name)
}
/// Read an identifier — a DOI, ISSN, ISBN or arXiv id — out of whatever it
/// arrived wrapped in.
pub fn parse_identifier(text: &str) -> Option<idnorm::Identifier> {
idnorm::parse(text)
}
/// 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)
@@ -237,6 +243,13 @@ mod tests {
assert_eq!(channel_named("lexical"), None);
}
#[test]
fn an_identifier_comes_back_normalised() {
let found = parse_identifier("https://doi.org/10.1000/182").expect("an identifier");
assert_eq!(found.id, "10.1000/182");
assert_eq!(parse_identifier("hello"), 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");
+12
View File
@@ -208,6 +208,17 @@ fn series_of<'py>(
}
}
/// Read an identifier out of whatever it arrived wrapped in.
///
/// `None` when the string is not one.
#[pyfunction]
fn parse_identifier<'py>(py: Python<'py>, text: &str) -> PyResult<Option<Bound<'py, PyAny>>> {
match crate::parse_identifier(text) {
Some(found) => Ok(Some(to_py(py, &found)?)),
None => Ok(None),
}
}
/// The module Python imports.
#[pymodule]
fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -221,5 +232,6 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(fuse_hits, module)?)?;
module.add_function(wrap_pyfunction!(identify, module)?)?;
module.add_function(wrap_pyfunction!(series_of, module)?)?;
module.add_function(wrap_pyfunction!(parse_identifier, module)?)?;
Ok(())
}
+27
View File
@@ -70,6 +70,21 @@ export interface Hit {
payload: unknown;
}
/** A DOI, ISSN, ISBN or arXiv identifier, read and checked. */
export interface Identifier {
v: number;
/** The text it was read from. */
input: string;
/** "doi", "issn", "isbn" or "arxiv". */
kind: string;
/** The identifier, in the form it should be stored and compared in. */
id: string;
/** Whether the check digit agrees, or null where the kind carries none. */
checksum: boolean | null;
/** A URL that resolves it. */
url?: string;
}
/** What a title says about its series. */
export interface SeriesProposal {
v: number;
@@ -233,6 +248,17 @@ pub fn series_of(title: &str, author: Option<String>) -> Result<JsValue, JsValue
}
}
/// Read an identifier out of whatever it arrived wrapped in.
///
/// `undefined` when the string is not one.
#[wasm_bindgen(js_name = parseIdentifier, skip_typescript)]
pub fn parse_identifier(text: &str) -> Result<JsValue, JsValue> {
match crate::parse_identifier(text) {
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)]
@@ -244,4 +270,5 @@ 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;
export function parseIdentifier(text: string): Identifier | undefined;
"#;