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
+1
View File
@@ -31,6 +31,7 @@ crate-type = ["cdylib", "rlib"]
booksniff.workspace = true
chunkmd.workspace = true
corpus-core.workspace = true
idnorm.workspace = true
cuekit.workspace = true
cuepack.workspace = true
mdscrub.workspace = true
+1
View File
@@ -68,6 +68,7 @@ except ValueError as why:
| `fuse_hits` | [`rrf`](../rrf/README.md) |
| `identify` | [`booksniff`](../booksniff/README.md) |
| `series_of` | [`seriesof`](../seriesof/README.md) |
| `parse_identifier` | [`idnorm`](../idnorm/README.md) |
The tools that are not here are the ones a binding would gain nothing over a
subprocess for: they read files, shell out to another program, or talk to a
+1
View File
@@ -49,6 +49,7 @@ try {
| `fuseHits` | Several ranked lists into one |
| `identify` | What a file is, from its first bytes |
| `seriesOf` | The series a book belongs to, and which volume this is |
| `parseIdentifier` | A DOI, ISSN, ISBN or arXiv id, read and checked |
The tools that read files or call another program are not here: a browser has
neither, and a binding that pretends otherwise fails at run time rather than
+6 -3
View File
@@ -3,8 +3,8 @@
// check, and nothing else would notice.
import {
chunkMarkdown, scrub, countTokens, parseSubtitles, packCues,
splitCollection, fuseHits, identify, seriesOf,
type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal,
splitCollection, fuseHits, identify, seriesOf, parseIdentifier,
type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal, type Identifier,
} from "./dist/web/catena.js";
const chunks: Chunk[] = chunkMarkdown("# One\n\nbody\n", 512, 64, 50, 768);
@@ -39,4 +39,7 @@ const proposal: SeriesProposal | undefined = seriesOf("The Philokalia Vol 3");
const volume: number | undefined = proposal?.volume;
seriesOf("Aquinas (Companion)", "Cambridge");
export const checked = [volume, page, chapter, flags, clean, tokens, start, packed, slug, fused, kind, notes];
const ident: Identifier | undefined = parseIdentifier("10.1000/182");
const checksum: boolean | null | undefined = ident?.checksum;
export const checked = [volume, checksum, page, chapter, flags, clean, tokens, start, packed, slug, fused, kind, notes];
@@ -17,6 +17,7 @@ from ._catena import (
fuse_hits,
identify,
pack_cues,
parse_identifier,
parse_subtitles,
scrub,
series_of,
@@ -30,6 +31,7 @@ __all__ = [
"fuse_hits",
"identify",
"pack_cues",
"parse_identifier",
"parse_subtitles",
"scrub",
"series_of",
@@ -30,6 +30,7 @@ def fuse_hits(
limit: int | None = ...,
) -> list[dict[str, Any]]: ...
def identify(name: str, data: bytes) -> dict[str, Any]: ...
def parse_identifier(text: str) -> dict[str, Any] | None: ...
def series_of(
title: str, author: str | None = ...
) -> dict[str, Any] | None: ...
+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;
"#;