mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
A book that arrives as a file often has no identifier to ask about, only a title and perhaps a name. `bibfetch search --title T [--author A] [--limit N]` asks Open Library's index which books it has under those, and Google Books only when Open Library has nothing, and prints one record per candidate in the order the register gave them. From Rust, `bibfetch::search(&agent, title, author, limit, attempts)`. Each candidate is one edition. The index answers with works and, asked for `editions.*`, names the edition of each that best matches; the ISBN, publisher, language, year and cover come from that edition, the title, authors, subjects and length from the work. The work's own ISBN list mixes editions: the first War and Peace work lists four, while the edition matched is a 1917 printing with none. `id` is the first ISBN whose check digit agrees, in thirteen digits, thirteen-digit entries taken first. A candidate with none is named by the register's key: `olid` (OL…M for the edition, OL…W for the work when no edition was named) or `googlebooks` (a volume id). Google's `OTHER` shelfmarks are never read as ISBNs. `cover_id` keeps the meaning it has in a lookup record: the edition's own first cover, taken from `editions.cover_i` and only when the record names that edition. The work's `cover_i` and `cover_edition_key` are not asked for. A work with no edition named, and every Google Books candidate, has none. Open Library having nothing stands when Google Books is down; both down is exit 69. Exit 0 a candidate, 1 none, 65 an empty title. The retrying GET that lookups use is now `ask::get`, shared by both, and no longer sleeps after its last attempt.
616 lines
22 KiB
Rust
616 lines
22 KiB
Rust
//! The registers, and how to read what each one says.
|
|
//!
|
|
//! Each is the body that assigns the identifiers it answers about, so each is
|
|
//! the one place the answer is not a guess. The shapes they reply in have
|
|
//! nothing in common, which is most of what this module is.
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::{Error, Record};
|
|
|
|
/// A register that can be asked about an identifier.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Source {
|
|
/// Crossref, which assigns most scholarly DOIs.
|
|
Crossref,
|
|
/// The arXiv's own register of preprints.
|
|
Arxiv,
|
|
/// OpenLibrary, for books by ISBN.
|
|
OpenLibrary,
|
|
/// OpenLibrary's index, rather than its edition record. Asked when the
|
|
/// edition record has the book but not who wrote it, which is often: an
|
|
/// edition names its authors by key, and the index names them.
|
|
OpenLibrarySearch,
|
|
/// Google Books, for books by ISBN. Asked last, because it answers
|
|
/// anonymous callers only until a daily quota runs out.
|
|
GoogleBooks,
|
|
}
|
|
|
|
impl Source {
|
|
/// What it is called.
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Source::Crossref => "crossref",
|
|
Source::Arxiv => "arxiv",
|
|
Source::OpenLibrary => "openlibrary",
|
|
Source::OpenLibrarySearch => "openlibrary",
|
|
Source::GoogleBooks => "googlebooks",
|
|
}
|
|
}
|
|
|
|
/// Which register answers about a kind of identifier.
|
|
pub fn for_kind(kind: &str) -> Option<Self> {
|
|
Self::chain_for_kind(kind).first().copied()
|
|
}
|
|
|
|
/// Every register that answers about a kind, in the order to ask them.
|
|
///
|
|
/// One register is enough for a DOI or a preprint: the body that assigns
|
|
/// the identifier is the only one that has it. A book is different. An
|
|
/// ISBN is assigned by a publisher and recorded by whoever gets round to
|
|
/// it, so coverage is patchy in different places for different books,
|
|
/// and an answer missing the authors is common enough to be worth asking
|
|
/// a second register about.
|
|
pub fn chain_for_kind(kind: &str) -> &'static [Self] {
|
|
match kind {
|
|
"doi" => &[Source::Crossref],
|
|
"arxiv" => &[Source::Arxiv],
|
|
// The index first. Asked for the fields by name it answers with
|
|
// the title, the authors, the publisher, the language, a page
|
|
// count and the subjects in one request, which is the whole of
|
|
// what a catalogue wants; the edition record is then needed only
|
|
// for a subtitle or a publisher the index did not give. Asking it
|
|
// second cost three requests per book where one usually does, and
|
|
// at three books in flight that is the difference between two
|
|
// days and two hours.
|
|
"isbn" => &[
|
|
Source::OpenLibrarySearch,
|
|
Source::OpenLibrary,
|
|
Source::GoogleBooks,
|
|
],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// Where to ask about one identifier.
|
|
pub fn url(self, id: &str) -> String {
|
|
match self {
|
|
Source::Crossref => format!("https://api.crossref.org/works/{}", encode(id)),
|
|
Source::Arxiv => format!(
|
|
"https://export.arxiv.org/api/query?id_list={}&max_results=1",
|
|
encode(id)
|
|
),
|
|
Source::OpenLibrary => {
|
|
format!("https://openlibrary.org/isbn/{}.json", encode(id))
|
|
}
|
|
// `fields` or the index answers with a handful of defaults and
|
|
// keeps the rest, which is how a language it holds went unread.
|
|
// Not `cover_i`: that is the cover chosen for the whole work, and
|
|
// on a work with hundreds of editions it is usually another
|
|
// edition's, in another translation or language.
|
|
Source::OpenLibrarySearch => format!(
|
|
"https://openlibrary.org/search.json?q=isbn:{}\
|
|
&fields=title,subtitle,author_name,first_publish_year,publisher,language,subject,\
|
|
number_of_pages_median&limit=1",
|
|
encode(id)
|
|
),
|
|
Source::GoogleBooks => format!(
|
|
"https://www.googleapis.com/books/v1/volumes?q=isbn:{}",
|
|
encode(id)
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Read what it said into a record.
|
|
pub fn read(self, id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
match self {
|
|
Source::Crossref => crossref(id, kind, body),
|
|
Source::Arxiv => arxiv(id, kind, body),
|
|
Source::OpenLibrary => open_library(id, kind, body),
|
|
Source::OpenLibrarySearch => open_library_search(id, kind, body),
|
|
Source::GoogleBooks => google_books(id, kind, body),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Percent-encode what a path or a query would otherwise read as structure.
|
|
///
|
|
/// A DOI suffix may hold a slash, a question mark or a hash, and every one of
|
|
/// those means something else in a URL.
|
|
pub(crate) fn encode(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
for byte in text.bytes() {
|
|
match byte {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
|
out.push(byte as char)
|
|
}
|
|
byte => out.push_str(&format!("%{byte:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A record with the fields nothing found fills in.
|
|
pub(crate) fn blank(id: &str, kind: &str, source: Source) -> Record {
|
|
let mut record = crate::missing(id, kind, source);
|
|
record.found = true;
|
|
record
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CrossrefReply {
|
|
message: CrossrefWork,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CrossrefWork {
|
|
#[serde(default)]
|
|
title: Vec<String>,
|
|
#[serde(default)]
|
|
author: Vec<CrossrefAuthor>,
|
|
#[serde(default, rename = "container-title")]
|
|
container_title: Vec<String>,
|
|
#[serde(default)]
|
|
issued: Option<CrossrefDate>,
|
|
#[serde(default, rename = "abstract")]
|
|
summary: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CrossrefAuthor {
|
|
#[serde(default)]
|
|
given: Option<String>,
|
|
#[serde(default)]
|
|
family: Option<String>,
|
|
#[serde(default)]
|
|
name: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CrossrefDate {
|
|
#[serde(default, rename = "date-parts")]
|
|
date_parts: Vec<Vec<i64>>,
|
|
}
|
|
|
|
fn crossref(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
let reply: CrossrefReply =
|
|
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
|
let work = reply.message;
|
|
|
|
let mut record = blank(id, kind, Source::Crossref);
|
|
record.title = work.title.into_iter().next();
|
|
record.container = work.container_title.into_iter().next();
|
|
record.year = work
|
|
.issued
|
|
.and_then(|issued| issued.date_parts.into_iter().next())
|
|
.and_then(|parts| parts.into_iter().next());
|
|
record.authors = work
|
|
.author
|
|
.into_iter()
|
|
.filter_map(|author| match (author.given, author.family, author.name) {
|
|
(Some(given), Some(family), _) => Some(format!("{given} {family}")),
|
|
(None, Some(family), _) => Some(family),
|
|
(_, _, Some(name)) => Some(name),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
// Crossref's abstracts arrive as JATS markup.
|
|
record.summary = work.summary.map(|summary| strip_tags(&summary));
|
|
Ok(record)
|
|
}
|
|
|
|
/// The arXiv answers in Atom, which is XML. Reading it properly would mean a
|
|
/// parser; what is wanted is four fields, and they are each one tag.
|
|
fn arxiv(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
// An empty feed means no such preprint, which the API signals by omitting
|
|
// the entry rather than by a status code.
|
|
let Some(entry) = between(body, "<entry>", "</entry>") else {
|
|
return Ok(crate::missing(id, kind, Source::Arxiv));
|
|
};
|
|
|
|
let mut record = blank(id, kind, Source::Arxiv);
|
|
record.title = between(entry, "<title>", "</title>").map(collapse);
|
|
record.summary = between(entry, "<summary>", "</summary>").map(collapse);
|
|
record.container = Some("arXiv".to_string());
|
|
record.year = between(entry, "<published>", "</published>")
|
|
.and_then(|published| published.get(..4))
|
|
.and_then(|year| year.parse().ok());
|
|
|
|
let mut authors = Vec::new();
|
|
let mut rest = entry;
|
|
while let Some(author) = between(rest, "<author>", "</author>") {
|
|
if let Some(name) = between(author, "<name>", "</name>") {
|
|
authors.push(collapse(name));
|
|
}
|
|
let Some(at) = rest.find("</author>") else {
|
|
break;
|
|
};
|
|
rest = &rest[at + "</author>".len()..];
|
|
}
|
|
record.authors = authors;
|
|
Ok(record)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct OpenLibraryBook {
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
subtitle: Option<String>,
|
|
#[serde(default)]
|
|
publish_date: Option<String>,
|
|
#[serde(default)]
|
|
publishers: Vec<String>,
|
|
#[serde(default)]
|
|
subjects: Vec<String>,
|
|
/// `[{"key": "/languages/eng"}]`, which is how OpenLibrary names one.
|
|
#[serde(default)]
|
|
languages: Vec<OpenLibraryKey>,
|
|
#[serde(default)]
|
|
number_of_pages: Option<u32>,
|
|
/// Cover ids, best first. A deleted cover is left in the list as `-1`.
|
|
#[serde(default)]
|
|
covers: Vec<i64>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct OpenLibraryKey {
|
|
#[serde(default)]
|
|
key: String,
|
|
}
|
|
|
|
fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
let book: OpenLibraryBook =
|
|
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
|
|
|
let mut record = blank(id, kind, Source::OpenLibrary);
|
|
// An edition record keeps the subtitle in its own field, and a catalogue
|
|
// wants the whole title. Google Books is already joined the same way.
|
|
record.title = match (book.title, book.subtitle) {
|
|
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
|
|
(title, _) => title,
|
|
};
|
|
record.container = book.publishers.into_iter().next();
|
|
// The date is free text — "1999", "March 1999", "1999-03-01".
|
|
record.year = book.publish_date.as_deref().and_then(year_in);
|
|
record.subjects = book.subjects;
|
|
// "/languages/eng" names the language by the last segment of a key.
|
|
record.language = book
|
|
.languages
|
|
.into_iter()
|
|
.find_map(|l| l.key.rsplit('/').next().map(str::to_string))
|
|
.filter(|l| !l.is_empty());
|
|
record.pages = book.number_of_pages;
|
|
// The edition's own cover, and the only place a cover id is taken from.
|
|
record.cover_id = book
|
|
.covers
|
|
.into_iter()
|
|
.find_map(|id| u64::try_from(id).ok().filter(|id| *id > 0));
|
|
Ok(record)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SearchReply {
|
|
#[serde(default)]
|
|
docs: Vec<SearchDoc>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SearchDoc {
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
subtitle: Option<String>,
|
|
#[serde(default)]
|
|
author_name: Vec<String>,
|
|
#[serde(default)]
|
|
first_publish_year: Option<i64>,
|
|
#[serde(default)]
|
|
publisher: Vec<String>,
|
|
#[serde(default)]
|
|
language: Vec<String>,
|
|
#[serde(default)]
|
|
subject: Vec<String>,
|
|
#[serde(rename = "number_of_pages_median", default)]
|
|
pages: Option<u32>,
|
|
}
|
|
|
|
/// How many of a work's subject headings are worth carrying.
|
|
///
|
|
/// The index lists every heading every edition has ever been given, which on a
|
|
/// well-known book is hundreds and on Hamlet is thousands. A shelf is filed
|
|
/// from a handful, and the first are the broadest.
|
|
pub(crate) const SUBJECT_LIMIT: usize = 12;
|
|
|
|
/// A search that matched nothing answers with no documents rather than a
|
|
/// status, so an empty reply is the absence and not a failure.
|
|
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
|
let Some(doc) = reply.docs.into_iter().next() else {
|
|
return Ok(crate::missing(id, kind, Source::OpenLibrarySearch));
|
|
};
|
|
|
|
let mut record = blank(id, kind, Source::OpenLibrarySearch);
|
|
record.title = match (doc.title, doc.subtitle) {
|
|
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
|
|
(title, _) => title,
|
|
};
|
|
record.authors = doc.author_name;
|
|
record.year = doc.first_publish_year;
|
|
record.container = doc.publisher.into_iter().next();
|
|
record.language = doc.language.into_iter().next();
|
|
// The index lists every subject any edition of the work carries, which
|
|
// runs to hundreds on a well-known book. A shelf needs a handful.
|
|
record.subjects = doc.subject;
|
|
record.subjects.truncate(SUBJECT_LIMIT);
|
|
record.pages = doc.pages;
|
|
Ok(record)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct GoogleReply {
|
|
#[serde(default)]
|
|
pub(crate) items: Vec<GoogleItem>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct GoogleItem {
|
|
/// Google's own key for the volume.
|
|
#[serde(default)]
|
|
pub(crate) id: Option<String>,
|
|
#[serde(rename = "volumeInfo", default)]
|
|
pub(crate) volume_info: GoogleVolume,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub(crate) struct GoogleVolume {
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
subtitle: Option<String>,
|
|
#[serde(default)]
|
|
authors: Vec<String>,
|
|
#[serde(rename = "publishedDate", default)]
|
|
published_date: Option<String>,
|
|
#[serde(default)]
|
|
publisher: Option<String>,
|
|
#[serde(default)]
|
|
description: Option<String>,
|
|
#[serde(default)]
|
|
categories: Vec<String>,
|
|
#[serde(rename = "language", default)]
|
|
language: Option<String>,
|
|
#[serde(rename = "pageCount", default)]
|
|
page_count: Option<u32>,
|
|
/// `[{"type": "ISBN_13", "identifier": "…"}]`, alongside library
|
|
/// shelfmarks typed `OTHER`.
|
|
#[serde(rename = "industryIdentifiers", default)]
|
|
pub(crate) identifiers: Vec<GoogleIdentifier>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct GoogleIdentifier {
|
|
#[serde(rename = "type", default)]
|
|
pub(crate) kind: String,
|
|
#[serde(default)]
|
|
pub(crate) identifier: String,
|
|
}
|
|
|
|
/// A search that matched nothing answers with no items rather than a status,
|
|
/// so an empty reply is the absence and not a failure.
|
|
fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|
let reply: GoogleReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
|
let Some(item) = reply.items.into_iter().next() else {
|
|
return Ok(crate::missing(id, kind, Source::GoogleBooks));
|
|
};
|
|
Ok(google_volume(id, kind, item.volume_info))
|
|
}
|
|
|
|
/// One Google Books volume as a record.
|
|
pub(crate) fn google_volume(id: &str, kind: &str, volume: GoogleVolume) -> Record {
|
|
let mut record = blank(id, kind, Source::GoogleBooks);
|
|
record.title = match (volume.title, volume.subtitle) {
|
|
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
|
|
(title, _) => title,
|
|
};
|
|
record.authors = volume.authors;
|
|
record.year = volume.published_date.as_deref().and_then(year_in);
|
|
record.container = volume.publisher;
|
|
record.summary = volume.description.map(|text| collapse(&text));
|
|
record.subjects = volume.categories;
|
|
record.language = volume.language;
|
|
record.pages = volume.page_count;
|
|
record
|
|
}
|
|
|
|
/// The first four-digit year in a string.
|
|
pub(crate) fn year_in(text: &str) -> Option<i64> {
|
|
let bytes: Vec<char> = text.chars().collect();
|
|
for window in bytes.windows(4) {
|
|
if window.iter().all(char::is_ascii_digit) {
|
|
let year: i64 = window.iter().collect::<String>().parse().ok()?;
|
|
if (1000..=2999).contains(&year) {
|
|
return Some(year);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// What sits between two markers, the first time they appear in order.
|
|
fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
|
|
let start = text.find(open)? + open.len();
|
|
let end = text[start..].find(close)? + start;
|
|
Some(&text[start..end])
|
|
}
|
|
|
|
/// Runs of whitespace down to one space, trimmed.
|
|
fn collapse(text: &str) -> String {
|
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
/// Text with its tags removed.
|
|
///
|
|
/// Crossref hands back JATS — `<jats:p>` and friends — which is markup for a
|
|
/// typesetter rather than anything a reader wants.
|
|
fn strip_tags(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut inside = false;
|
|
for c in text.chars() {
|
|
match c {
|
|
'<' => inside = true,
|
|
'>' => inside = false,
|
|
c if !inside => out.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
collapse(&out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn each_kind_goes_to_the_body_that_assigns_it() {
|
|
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
|
|
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
|
|
// The index, not the edition record: it answers with the whole of
|
|
// what a catalogue wants in one request, where the edition record
|
|
// takes up to twenty-eight seconds and names its authors by key.
|
|
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch));
|
|
// No register answers about an ISSN without a key.
|
|
assert_eq!(Source::for_kind("issn"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_doi_suffix_cannot_change_the_shape_of_the_url() {
|
|
let url = Source::Crossref.url("10.1000/a?b#c/d");
|
|
assert!(url.ends_with("10.1000%2Fa%3Fb%23c%2Fd"), "{url}");
|
|
assert_eq!(url.matches('?').count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn crossref_is_read_into_the_fields_that_matter() {
|
|
let body = r#"{"message":{"title":["On Computable Numbers"],
|
|
"author":[{"given":"Alan","family":"Turing"},{"family":"Church"},{"name":"A Committee"}],
|
|
"container-title":["Proceedings"],
|
|
"issued":{"date-parts":[[1936,11,12]]},
|
|
"abstract":"<jats:p>A paragraph.</jats:p>"}}"#;
|
|
let record = crossref("10.1", "doi", body).expect("a record");
|
|
assert!(record.found);
|
|
assert_eq!(record.title.as_deref(), Some("On Computable Numbers"));
|
|
assert_eq!(record.authors, ["Alan Turing", "Church", "A Committee"]);
|
|
assert_eq!(record.year, Some(1936));
|
|
assert_eq!(record.container.as_deref(), Some("Proceedings"));
|
|
assert_eq!(record.summary.as_deref(), Some("A paragraph."));
|
|
}
|
|
|
|
#[test]
|
|
fn a_crossref_reply_missing_everything_is_still_a_record() {
|
|
let record = crossref("10.1", "doi", r#"{"message":{}}"#).expect("a record");
|
|
assert!(record.found);
|
|
assert_eq!(record.title, None);
|
|
assert!(record.authors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn an_arxiv_feed_with_no_entry_means_no_such_preprint() {
|
|
let record = arxiv("2401.01234", "arxiv", "<feed></feed>").expect("a record");
|
|
assert!(!record.found);
|
|
}
|
|
|
|
#[test]
|
|
fn an_arxiv_entry_is_read_out_of_its_atom() {
|
|
let body = "<feed><entry><title>A\n Title</title>\
|
|
<summary> Some words. </summary>\
|
|
<published>2024-01-02T00:00:00Z</published>\
|
|
<author><name>Ada Lovelace</name></author>\
|
|
<author><name>Charles Babbage</name></author></entry></feed>";
|
|
let record = arxiv("2401.01234", "arxiv", body).expect("a record");
|
|
assert!(record.found);
|
|
assert_eq!(record.title.as_deref(), Some("A Title"));
|
|
assert_eq!(record.summary.as_deref(), Some("Some words."));
|
|
assert_eq!(record.year, Some(2024));
|
|
assert_eq!(record.authors, ["Ada Lovelace", "Charles Babbage"]);
|
|
}
|
|
|
|
#[test]
|
|
fn openlibrary_gives_up_a_year_from_whatever_it_wrote() {
|
|
for (date, year) in [
|
|
("1999", Some(1999)),
|
|
("March 1999", Some(1999)),
|
|
("1999-03-01", Some(1999)),
|
|
("n.d.", None),
|
|
] {
|
|
let body = format!(r#"{{"title":"A Book","publish_date":"{date}"}}"#);
|
|
let record = open_library("1", "isbn", &body).expect("a record");
|
|
assert_eq!(record.year, year, "{date}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_cover_id_a_record_names_is_kept_for_the_cover_lookup() {
|
|
// A deleted cover stays in an edition's list as -1, ahead of the
|
|
// ones still there.
|
|
let record = open_library("1", "isbn", r#"{"title":"A","covers":[-1,1162784,99]}"#)
|
|
.expect("a record");
|
|
assert_eq!(record.cover_id, Some(1162784));
|
|
let record = open_library("1", "isbn", r#"{"title":"A"}"#).expect("a record");
|
|
assert_eq!(record.cover_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_cover_id_is_the_editions_and_not_the_works() {
|
|
// ISBN 9780140447934 today: the index (asked first) names the work's
|
|
// cover; the edition record has none. Merged as `ask::lookup` merges.
|
|
let mut kept = open_library_search(
|
|
"9780140447934",
|
|
"isbn",
|
|
r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#,
|
|
)
|
|
.expect("a record");
|
|
let edition = open_library("9780140447934", "isbn", r#"{"title":"War and Peace"}"#)
|
|
.expect("a record");
|
|
kept.fill_from(edition);
|
|
// Cover 12621906 is OL34941467M, Standard Ebooks' Maude translation.
|
|
assert_eq!(kept.cover_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn an_edition_with_no_covers_gives_no_cover_id() {
|
|
let index = r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#;
|
|
// `lookup` often stops at the index, before the edition is asked.
|
|
let alone = open_library_search("9780140447934", "isbn", index).expect("a record");
|
|
assert_eq!(alone.cover_id, None);
|
|
// An edition whose only cover was deleted, and one with an empty list.
|
|
for edition in [
|
|
r#"{"title":"War and Peace","covers":[-1]}"#,
|
|
r#"{"title":"War and Peace","covers":[]}"#,
|
|
] {
|
|
let mut kept = open_library_search("9780140447934", "isbn", index).expect("a record");
|
|
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
|
|
assert_eq!(kept.cover_id, None, "{edition}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_index_is_not_asked_for_the_works_cover() {
|
|
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_answer_that_is_not_what_it_should_be_says_so() {
|
|
assert!(matches!(
|
|
crossref("10.1", "doi", "not json"),
|
|
Err(Error::Shape(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn markup_is_not_part_of_an_abstract() {
|
|
assert_eq!(strip_tags("<jats:p>One <i>two</i></jats:p>"), "One two");
|
|
assert_eq!(strip_tags("plain"), "plain");
|
|
}
|
|
}
|