mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd41a93c9b | ||
|
|
e9f9e75c1d | ||
|
|
65fd4adaad | ||
|
|
58573399e7 | ||
|
|
d06e13e2f3 | ||
|
|
8ecc4766ee | ||
|
|
3bd3ea11ae | ||
|
|
b7feefb704 | ||
|
|
f43b9d4900 | ||
|
|
6844f10e0e | ||
|
|
4aae5b6ac0 | ||
|
|
3fd48d4999 | ||
|
|
0e83c6ffc1 | ||
|
|
5f2a471f58 | ||
|
|
bd104834ed |
No files matched your search
Generated
+26
-26
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "avplan"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bibclean"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -143,7 +143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bibfetch"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -196,7 +196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bookmeta"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -210,7 +210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "booksniff"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -250,7 +250,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "catena"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"bibfetch",
|
||||
@@ -277,7 +277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "catena-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_mangen",
|
||||
@@ -312,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chunkmd"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -334,7 +334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "citefind"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"chunkmd",
|
||||
@@ -399,7 +399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "colophon"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"catena-cli",
|
||||
@@ -448,7 +448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "corpus-core"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -492,7 +492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuekit"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"corpus-core",
|
||||
"regex",
|
||||
@@ -504,7 +504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuenorm"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -514,7 +514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuepack"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -525,7 +525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cueseg"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -612,7 +612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "emb"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idnorm"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"catena-cli",
|
||||
@@ -942,7 +942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "langid"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1022,7 +1022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdscrub"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1034,7 +1034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdsplit"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1113,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pdfgutter"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1126,7 +1126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdftriage"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1370,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rrf"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1517,7 +1517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "seriesof"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1634,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "textkit"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"tiktoken-rs",
|
||||
@@ -1724,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tocfind"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
|
||||
+27
-27
@@ -3,39 +3,39 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://git.pumpin.online/funkpower/catena"
|
||||
|
||||
[workspace.dependencies]
|
||||
catena-cli = { path = "crates/catena-cli", version = "0.3.1" }
|
||||
corpus-core = { path = "crates/corpus-core", version = "0.3.1" }
|
||||
cuekit = { path = "crates/cuekit", version = "0.3.1" }
|
||||
textkit = { path = "crates/textkit", version = "0.3.1" }
|
||||
chunkmd = { path = "crates/chunkmd", version = "0.3.1" }
|
||||
mdscrub = { path = "crates/mdscrub", version = "0.3.1" }
|
||||
mdsplit = { path = "crates/mdsplit", version = "0.3.1" }
|
||||
pdftriage = { path = "crates/pdftriage", version = "0.3.1" }
|
||||
cuenorm = { path = "crates/cuenorm", version = "0.3.1" }
|
||||
cueseg = { path = "crates/cueseg", version = "0.3.1" }
|
||||
rrf = { path = "crates/rrf", version = "0.3.1" }
|
||||
cuepack = { path = "crates/cuepack", version = "0.3.1" }
|
||||
bibclean = { path = "crates/bibclean", version = "0.3.1" }
|
||||
avplan = { path = "crates/avplan", version = "0.3.1" }
|
||||
pdfgutter = { path = "crates/pdfgutter", version = "0.3.1" }
|
||||
catena = { path = "crates/bindings", version = "0.3.1" }
|
||||
seriesof = { path = "crates/seriesof", version = "0.3.1" }
|
||||
idnorm = { path = "crates/idnorm", version = "0.3.1" }
|
||||
bibfetch = { path = "crates/bibfetch", version = "0.3.1" }
|
||||
bookmeta = { path = "crates/bookmeta", version = "0.3.1" }
|
||||
langid = { path = "crates/langid", version = "0.3.1" }
|
||||
tocfind = { path = "crates/tocfind", version = "0.3.1" }
|
||||
booksniff = { path = "crates/booksniff", version = "0.3.1" }
|
||||
citefind = { path = "crates/citefind", version = "0.3.1" }
|
||||
colophon = { path = "crates/colophon", version = "0.3.1" }
|
||||
emb = { path = "crates/emb", version = "0.3.1" }
|
||||
catena-cli = { path = "crates/catena-cli", version = "0.5.0" }
|
||||
corpus-core = { path = "crates/corpus-core", version = "0.5.0" }
|
||||
cuekit = { path = "crates/cuekit", version = "0.5.0" }
|
||||
textkit = { path = "crates/textkit", version = "0.5.0" }
|
||||
chunkmd = { path = "crates/chunkmd", version = "0.5.0" }
|
||||
mdscrub = { path = "crates/mdscrub", version = "0.5.0" }
|
||||
mdsplit = { path = "crates/mdsplit", version = "0.5.0" }
|
||||
pdftriage = { path = "crates/pdftriage", version = "0.5.0" }
|
||||
cuenorm = { path = "crates/cuenorm", version = "0.5.0" }
|
||||
cueseg = { path = "crates/cueseg", version = "0.5.0" }
|
||||
rrf = { path = "crates/rrf", version = "0.5.0" }
|
||||
cuepack = { path = "crates/cuepack", version = "0.5.0" }
|
||||
bibclean = { path = "crates/bibclean", version = "0.5.0" }
|
||||
avplan = { path = "crates/avplan", version = "0.5.0" }
|
||||
pdfgutter = { path = "crates/pdfgutter", version = "0.5.0" }
|
||||
catena = { path = "crates/bindings", version = "0.5.0" }
|
||||
seriesof = { path = "crates/seriesof", version = "0.5.0" }
|
||||
idnorm = { path = "crates/idnorm", version = "0.5.0" }
|
||||
bibfetch = { path = "crates/bibfetch", version = "0.5.0" }
|
||||
bookmeta = { path = "crates/bookmeta", version = "0.5.0" }
|
||||
langid = { path = "crates/langid", version = "0.5.0" }
|
||||
tocfind = { path = "crates/tocfind", version = "0.5.0" }
|
||||
booksniff = { path = "crates/booksniff", version = "0.5.0" }
|
||||
citefind = { path = "crates/citefind", version = "0.5.0" }
|
||||
colophon = { path = "crates/colophon", version = "0.5.0" }
|
||||
emb = { path = "crates/emb", version = "0.5.0" }
|
||||
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_mangen = "0.3"
|
||||
|
||||
+44
-17
@@ -86,7 +86,7 @@ is a failure — nobody replied, and the identifier may be perfectly good.
|
||||
|---|---|---|
|
||||
| `doi` | Crossref | It assigns most scholarly DOIs |
|
||||
| `arxiv` | the arXiv | Its own register of preprints |
|
||||
| `isbn` | OpenLibrary's edition record, then its index, then Google Books | Books, without a key |
|
||||
| `isbn` | OpenLibrary's index, then its edition record, then Google Books | Books, without a key |
|
||||
|
||||
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
|
||||
@@ -97,6 +97,14 @@ names them. So the registers are asked in turn and the first complete answer
|
||||
wins. An incomplete one is kept in case nothing better comes, and a register
|
||||
being down is a failure only when every register is.
|
||||
|
||||
OpenLibrary's index answers about the work, not the edition: its publishers,
|
||||
languages, years and page counts are every edition's at once, and the first
|
||||
of each is usually another edition's. So for a book it gives only the title,
|
||||
the authors and the subjects, and the `container`, `language`, `year` and
|
||||
`pages` of a record come from the edition record for that ISBN, or from
|
||||
Google Books when the edition record does not say. A field neither gives is
|
||||
left out rather than filled from the work.
|
||||
|
||||
Google Books is asked last because it answers anonymous callers only until a
|
||||
daily quota runs out, after which it replies `429` to everyone sharing the
|
||||
address.
|
||||
@@ -163,7 +171,7 @@ bytes, the media type and the address asked when there is.
|
||||
|
||||
```
|
||||
$ bibfetch search --title 'The First World War' --author Howard --limit 2
|
||||
{"v":1,"id":"9780192853622","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War","authors":["Michael Eliot Howard"],"year":2002,"container":"Oxford University Press","subjects":["World War, 1914-1918"],"language":"eng","pages":154,"cover_id":1123124}
|
||||
{"v":1,"id":"9780192853622","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War","authors":["Michael Eliot Howard"],"year":2002,"container":"Oxford University Press","subjects":["World War, 1914-1918"],"language":"eng","cover_id":1123124}
|
||||
{"v":1,"id":"9780199205592","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War: A Very Short Introduction","authors":["Michael Howard"],"year":2007,"container":"Oxford University Press","language":"eng","cover_id":14606475}
|
||||
```
|
||||
|
||||
@@ -172,18 +180,33 @@ title and perhaps a name. This asks which books a register has under those,
|
||||
and prints one record per candidate: Open Library's index first, and Google
|
||||
Books only when Open Library has nothing.
|
||||
|
||||
Open Library's title search is strict. Given an author as well, it finds
|
||||
nothing for *The Ladder of Divine Ascent* and four editions of *Ladder of
|
||||
Divine Ascent*. So when the title as given finds nothing, the title without a
|
||||
leading "The", "A" or "An" is asked next, and then the title and author as one
|
||||
free-text query. Each is asked only when the one before found nothing, so a
|
||||
title that matches costs one request. The looser questions can bring back
|
||||
books that only share words with the title, which is one more reason the
|
||||
candidates are for the caller to judge.
|
||||
|
||||
Each candidate is one edition. Open Library's index answers with works, and
|
||||
names the edition of each that best matches the search; what belongs to an
|
||||
edition — its ISBN, publisher, language, year and cover — is that edition's,
|
||||
and what belongs to the work — the title, the authors, the subjects and the
|
||||
length — is the work's. Google Books answers with volumes, which are editions
|
||||
already.
|
||||
names the edition of each that best matches the search. The ISBN, publisher,
|
||||
language, year and cover are that edition's and no other's: a field the
|
||||
edition does not give is left out rather than filled from the work, whose
|
||||
publishers, years and ISBNs belong to all its editions at once. Only the
|
||||
title, the authors and the subjects, which describe the work, are the work's.
|
||||
Open Library's index gives no length for an edition, so its candidates carry
|
||||
no `pages`. Google Books answers with volumes, which are editions already.
|
||||
|
||||
| Field | When the candidate has an ISBN | When it has none |
|
||||
|---|---|---|
|
||||
| `id` | The first ISBN whose check digit agrees, in thirteen digits; one written in thirteen is taken over one written in ten | The register's own key: an Open Library id such as `OL37863121M` for the edition, or `OL267171W` for the work when no edition was named; a Google Books volume id |
|
||||
| `id` | The first ISBN of the edition whose check digit agrees, in thirteen digits; one written in thirteen is taken over one written in ten | The register's own key: an Open Library id such as `OL37863121M` for the edition, or `OL267171W` for the work when no edition was named; a Google Books volume id |
|
||||
| `kind` | `isbn` | `olid` for Open Library, `googlebooks` for Google Books |
|
||||
|
||||
A work the index names no edition of, or names one it gives neither an ISBN
|
||||
nor a key for, is a candidate named by the work's key, with its title,
|
||||
authors and subjects and nothing an edition would say.
|
||||
|
||||
An ISBN whose check digit is wrong is passed over rather than corrected, and
|
||||
Google's shelfmarks, typed `OTHER`, are not read as ISBNs even when their
|
||||
digits would pass for one. A record of kind `isbn` can be handed straight back
|
||||
@@ -194,9 +217,8 @@ to `bibfetch` for everything the registers say about that edition; `olid` and
|
||||
Open Library's record of the edition the record names, by that edition's ISBN
|
||||
or its key, so `bibfetch cover --cover-id` fetches that edition's cover and no
|
||||
other. The cover the index picked for the whole work is never asked for,
|
||||
because it is often another edition's. A work the index names no edition of
|
||||
gives the first ISBN it lists that reads, which may be any edition's, and no
|
||||
cover id; Google Books knows nothing of Open Library's covers and gives none.
|
||||
because it is often another edition's. Google Books knows nothing of Open
|
||||
Library's covers and gives none.
|
||||
|
||||
The candidates are what the register said, in the order it said them. This
|
||||
makes no judgement about which one is the book in hand, and keeps one that is
|
||||
@@ -210,10 +232,14 @@ bibfetch search --title 'Anathem' --author Stephenson \
|
||||
| jq -r 'select(.kind == "isbn") | .id' | head -1
|
||||
```
|
||||
|
||||
Open Library answering that it has nothing is an answer, and it stands when
|
||||
Google Books cannot be reached; a register being down is a failure only when
|
||||
both are. Each is asked the same way a lookup asks, retried with the same
|
||||
waits. Open Library is sent the title and the author as written; Google Books
|
||||
Open Library having nothing is an answer only once Google Books has been
|
||||
asked and has nothing either. When Google Books cannot be reached, the search
|
||||
fails rather than say no register has the title, and when it refuses for its
|
||||
quota (`403`, or `429` on every attempt) the failure says so and the command
|
||||
exits `75`. Open Library being down is a failure only when Google Books has
|
||||
nothing to offer in its place. Each is asked the same way a lookup asks,
|
||||
retried with the same waits, except that a `403` from Google Books is not
|
||||
asked again. Open Library is sent the title and the author as written; Google Books
|
||||
is sent each word as its own `intitle:` or `inauthor:` term, so nothing in a
|
||||
title is read as search syntax.
|
||||
|
||||
@@ -224,8 +250,9 @@ title is read as search syntax.
|
||||
| `--limit <COUNT>` | The most candidates to print, `1` to `40`. Default `10` |
|
||||
|
||||
Exit `0` at least one candidate was printed, `1` no register has the title,
|
||||
`65` the title was empty, `69` no register could be reached, `74` the output
|
||||
could not be written.
|
||||
`65` the title was empty, `69` a register that had to answer could not be
|
||||
reached, `74` the output could not be written, `75` Google Books refused for
|
||||
its quota.
|
||||
|
||||
From Rust, `bibfetch::search(&agent, title, author, limit, attempts)` returns
|
||||
the candidates, empty when no register has the title.
|
||||
|
||||
@@ -265,11 +265,10 @@ mod merge_tests {
|
||||
let mut index = rec("openlibrary");
|
||||
index.title = Some("The First World War".into());
|
||||
index.authors = vec!["Michael Howard".into()];
|
||||
index.year = Some(2007);
|
||||
|
||||
edition.fill_from(index);
|
||||
assert_eq!(edition.authors, ["Michael Howard"]);
|
||||
assert_eq!(edition.year, Some(2007));
|
||||
assert_eq!(edition.language.as_deref(), Some("eng"));
|
||||
// The fuller title arrived first and is not displaced by the shorter.
|
||||
assert_eq!(
|
||||
edition.title.as_deref(),
|
||||
|
||||
@@ -256,6 +256,7 @@ fn search(
|
||||
eprintln!("bibfetch: {why}");
|
||||
ExitCode::from(match why {
|
||||
bibfetch::Error::Invalid(_) => exit::DATA,
|
||||
bibfetch::Error::Limited(_) => exit::TEMPFAIL,
|
||||
_ => exit::UNAVAILABLE,
|
||||
})
|
||||
}
|
||||
|
||||
+328
-104
@@ -23,22 +23,34 @@ pub const MAX_LIMIT: usize = 40;
|
||||
/// What Open Library's index is asked to say about each work, and about the
|
||||
/// one edition of it that best matches the search.
|
||||
///
|
||||
/// Not the work's own `cover_i` or `cover_edition_key`: the cover the index
|
||||
/// chose for a work is often another edition's. The edition's `cover_i` is
|
||||
/// the first cover in that edition's record, which is what a lookup by ISBN
|
||||
/// gives as `cover_id`.
|
||||
const OPEN_LIBRARY_FIELDS: &str = "key,title,subtitle,author_name,first_publish_year,publisher,\
|
||||
isbn,language,subject,number_of_pages_median,editions,editions.key,editions.isbn,\
|
||||
editions.publisher,editions.language,editions.publish_date,editions.cover_i";
|
||||
/// Of the work, only what describes the work: its publishers, ISBNs,
|
||||
/// languages, years and page counts are every edition's at once, and a
|
||||
/// record made from them would describe no edition. Not the work's own
|
||||
/// `cover_i` or `cover_edition_key` either: the cover the index chose for a
|
||||
/// work is often another edition's. The edition's `cover_i` is the first
|
||||
/// cover in that edition's record, which is what a lookup by ISBN gives as
|
||||
/// `cover_id`.
|
||||
const OPEN_LIBRARY_FIELDS: &str = "key,title,subtitle,author_name,subject,editions,editions.key,\
|
||||
editions.isbn,editions.publisher,editions.language,editions.publish_date,editions.cover_i";
|
||||
|
||||
/// Where Google Books is asked.
|
||||
const GOOGLE_BOOKS: &str = "https://www.googleapis.com/books/v1/volumes";
|
||||
|
||||
/// Ask which books a register has under a title, and by an author if one is
|
||||
/// named.
|
||||
///
|
||||
/// Open Library's index first, and Google Books only when that answered with
|
||||
/// nothing. A register being down is a failure only when every register
|
||||
/// asked is: Open Library answering that it has nothing is an answer, and it
|
||||
/// stands when Google Books cannot be reached. Asks nothing when `limit` is
|
||||
/// zero; a `limit` over [`MAX_LIMIT`] is taken as that.
|
||||
/// nothing. Open Library's title search is strict: given an author too, it
|
||||
/// finds nothing for "The Ladder of Divine Ascent" that it finds for "Ladder
|
||||
/// of Divine Ascent". So when the title as given finds nothing, the title
|
||||
/// without a leading article is asked, and then the title and author as one
|
||||
/// free-text query, before Google Books is. Each later question is asked only
|
||||
/// when the one before it found nothing, and a looser one can return books
|
||||
/// that only share words with the title, which the caller judges. Once Google Books has been asked, its failure is the answer:
|
||||
/// [`Error::Limited`] when it refused for its quota, [`Error::Unreachable`]
|
||||
/// when it could not be reached, since saying no register has the title
|
||||
/// would be a claim no register made. Asks nothing when `limit` is zero; a
|
||||
/// `limit` over [`MAX_LIMIT`] is taken as that.
|
||||
pub fn search(
|
||||
agent: &ureq::Agent,
|
||||
title: &str,
|
||||
@@ -46,9 +58,7 @@ pub fn search(
|
||||
limit: usize,
|
||||
attempts: u32,
|
||||
) -> Result<Vec<Record>, Error> {
|
||||
search_with(title, author, limit, |url| {
|
||||
crate::ask::get(agent, url, attempts)
|
||||
})
|
||||
search_with(title, author, limit, |url| get(agent, url, attempts))
|
||||
}
|
||||
|
||||
/// [`search`], with how to fetch an address passed in.
|
||||
@@ -75,25 +85,104 @@ fn search_with(
|
||||
None => Err(Error::Unreachable(format!("{url}: answered 404"))),
|
||||
};
|
||||
|
||||
let open_library =
|
||||
fetch(open_library_url(title, author, limit)).and_then(|body| open_library(&body, limit));
|
||||
let mut from_open_library = Ok(Vec::new());
|
||||
for url in open_library_urls(title, author, limit) {
|
||||
from_open_library = fetch(url).and_then(|body| open_library(&body, limit));
|
||||
if !from_open_library.as_ref().is_ok_and(Vec::is_empty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let open_library = from_open_library;
|
||||
if open_library.as_ref().is_ok_and(|found| !found.is_empty()) {
|
||||
return open_library;
|
||||
}
|
||||
|
||||
let google = match google_url(title, author, limit) {
|
||||
Some(url) => fetch(url).and_then(|body| google_books(&body, limit)),
|
||||
let Some(url) = google_url(title, author, limit) else {
|
||||
// Nothing in the title Google could be asked by.
|
||||
None => Ok(Vec::new()),
|
||||
return open_library;
|
||||
};
|
||||
match (open_library, google) {
|
||||
(_, Ok(found)) if !found.is_empty() => Ok(found),
|
||||
(Ok(none), _) => Ok(none),
|
||||
(Err(why), _) => Err(why),
|
||||
let google = fetch(url).and_then(|body| google_books(&body, limit))?;
|
||||
if google.is_empty() {
|
||||
// Open Library's nothing, or its failure, which Google Books having
|
||||
// nothing either does not excuse.
|
||||
return open_library;
|
||||
}
|
||||
Ok(google)
|
||||
}
|
||||
|
||||
/// Where to ask Open Library.
|
||||
/// The body at an address, asked as a lookup asks, or `None` when the
|
||||
/// register says there is no such thing.
|
||||
///
|
||||
/// Except that Google Books refusing for its quota is [`Error::Limited`]: a
|
||||
/// `403` at once, since it is the day's quota and asking again within
|
||||
/// seconds will not move it, and a `429` once every attempt has been refused.
|
||||
fn get(agent: &ureq::Agent, url: &str, attempts: u32) -> Result<Option<String>, Error> {
|
||||
let attempts = attempts.max(1);
|
||||
let mut last = Error::Unreachable("no attempt was made".into());
|
||||
for attempt in 0..attempts {
|
||||
match agent.get(url).call() {
|
||||
Ok(mut response) => {
|
||||
return response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map(Some)
|
||||
.map_err(|e| Error::Unreachable(e.to_string()));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => return Ok(None),
|
||||
Err(ureq::Error::StatusCode(status)) => match refused(url, status) {
|
||||
Some(why) if status == 403 => return Err(why),
|
||||
Some(why) => last = why,
|
||||
None => last = Error::Unreachable(format!("{url}: answered {status}")),
|
||||
},
|
||||
Err(e) => last = Error::Unreachable(e.to_string()),
|
||||
}
|
||||
if attempt + 1 < attempts {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1 << attempt.min(5)));
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// The refusal a status from a register is, when it is one for asking too
|
||||
/// often.
|
||||
fn refused(url: &str, status: u16) -> Option<Error> {
|
||||
(url.starts_with(GOOGLE_BOOKS) && matches!(status, 403 | 429)).then(|| {
|
||||
Error::Limited(format!(
|
||||
"Google Books refused ({status}): its quota for this address is spent for now"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Every question to put to Open Library, strictest first.
|
||||
fn open_library_urls(title: &str, author: Option<&str>, limit: usize) -> Vec<String> {
|
||||
let mut urls = vec![open_library_url(title, author, limit)];
|
||||
if let Some(rest) = without_article(title) {
|
||||
urls.push(open_library_url(rest, author, limit));
|
||||
}
|
||||
let words = match author {
|
||||
Some(author) => format!("{title} {author}"),
|
||||
None => title.to_string(),
|
||||
};
|
||||
urls.push(format!(
|
||||
"https://openlibrary.org/search.json?q={}&fields={OPEN_LIBRARY_FIELDS}&limit={limit}",
|
||||
source::encode(&words),
|
||||
));
|
||||
urls
|
||||
}
|
||||
|
||||
/// A title without its leading English article, when it has one and
|
||||
/// something follows it.
|
||||
fn without_article(title: &str) -> Option<&str> {
|
||||
let (first, rest) = title.split_once(char::is_whitespace)?;
|
||||
let rest = rest.trim_start();
|
||||
(["the", "a", "an"]
|
||||
.iter()
|
||||
.any(|article| first.eq_ignore_ascii_case(article))
|
||||
&& !rest.is_empty())
|
||||
.then_some(rest)
|
||||
}
|
||||
|
||||
/// Where to ask Open Library by title and author.
|
||||
fn open_library_url(title: &str, author: Option<&str>, limit: usize) -> String {
|
||||
let author = author
|
||||
.map(|author| format!("&author={}", source::encode(author)))
|
||||
@@ -124,7 +213,7 @@ fn google_url(title: &str, author: Option<&str>, limit: usize) -> Option<String>
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults={limit}",
|
||||
"{GOOGLE_BOOKS}?q={}&maxResults={limit}",
|
||||
source::encode(&terms.join(" "))
|
||||
))
|
||||
}
|
||||
@@ -162,18 +251,8 @@ struct Work {
|
||||
#[serde(default)]
|
||||
author_name: Vec<String>,
|
||||
#[serde(default)]
|
||||
first_publish_year: Option<i64>,
|
||||
#[serde(default)]
|
||||
publisher: Vec<String>,
|
||||
#[serde(default)]
|
||||
isbn: Vec<String>,
|
||||
#[serde(default)]
|
||||
language: Vec<String>,
|
||||
#[serde(default)]
|
||||
subject: Vec<String>,
|
||||
#[serde(default)]
|
||||
number_of_pages_median: Option<u32>,
|
||||
#[serde(default)]
|
||||
editions: Option<Editions>,
|
||||
}
|
||||
|
||||
@@ -212,10 +291,11 @@ fn olid(key: &str) -> Option<String> {
|
||||
/// Read Open Library's answer into one candidate per work, in its order.
|
||||
///
|
||||
/// What belongs to an edition — its ISBN, publisher, language, year and
|
||||
/// cover — is taken from the edition the index chose as matching best; what
|
||||
/// belongs to the work — its title, authors, subjects and length — from the
|
||||
/// work. A work the index names no edition of gives its first ISBN that reads,
|
||||
/// which may be any edition's, and no cover id.
|
||||
/// cover — is taken from the edition the index chose as matching best, and
|
||||
/// left out when that edition does not give it; only the title, authors and
|
||||
/// subjects, which describe the work, are the work's. A work the index names
|
||||
/// no edition of is named by the work's key and says nothing an edition
|
||||
/// would.
|
||||
fn open_library(body: &str, limit: usize) -> Result<Vec<Record>, Error> {
|
||||
let reply: OpenLibraryReply =
|
||||
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
||||
@@ -228,26 +308,47 @@ fn open_library(body: &str, limit: usize) -> Result<Vec<Record>, Error> {
|
||||
}
|
||||
|
||||
fn candidate(work: Work) -> Option<Record> {
|
||||
let edition = work.editions.and_then(|e| e.docs.into_iter().next());
|
||||
// The edition, and what it is named by: its ISBN, or its key when it has
|
||||
// none. One with neither cannot be named, and is as good as none.
|
||||
let edition = work
|
||||
.editions
|
||||
.and_then(|e| e.docs.into_iter().next())
|
||||
.and_then(|edition| {
|
||||
match first_isbn(edition.isbn.iter().map(String::as_str)) {
|
||||
Some(isbn) => Some((isbn, "isbn")),
|
||||
None => edition
|
||||
.key
|
||||
.as_deref()
|
||||
.and_then(olid)
|
||||
.map(|key| (key, "olid")),
|
||||
}
|
||||
.map(|named| (named, edition))
|
||||
});
|
||||
|
||||
// The cover is kept only when the record names the edition it is the
|
||||
// cover of, by that edition's ISBN or its key.
|
||||
let (id, kind, edition_named) = match &edition {
|
||||
Some(edition) => match first_isbn(edition.isbn.iter().map(String::as_str)) {
|
||||
Some(isbn) => (isbn, "isbn", true),
|
||||
None => match edition.key.as_deref().and_then(olid) {
|
||||
Some(key) => (key, "olid", true),
|
||||
None => (work.key.as_deref().and_then(olid)?, "olid", false),
|
||||
},
|
||||
},
|
||||
None => match first_isbn(work.isbn.iter().map(String::as_str)) {
|
||||
Some(isbn) => (isbn, "isbn", false),
|
||||
let mut record = match edition {
|
||||
Some(((id, kind), edition)) => {
|
||||
let mut record = source::blank(&id, kind, Source::OpenLibrarySearch);
|
||||
record.container = edition.publisher.into_iter().next();
|
||||
record.language = edition.language.into_iter().next();
|
||||
record.year = edition
|
||||
.publish_date
|
||||
.iter()
|
||||
.find_map(|date| source::year_in(date));
|
||||
record.cover_id = edition
|
||||
.cover_i
|
||||
.and_then(|id| u64::try_from(id).ok())
|
||||
.filter(|id| *id > 0);
|
||||
record
|
||||
}
|
||||
// The work's own ISBNs, publishers and years are its editions', and
|
||||
// which belongs with which the index does not say.
|
||||
None => source::blank(
|
||||
// A work with no key cannot be named, so it is not a candidate.
|
||||
None => (work.key.as_deref().and_then(olid)?, "olid", false),
|
||||
},
|
||||
&work.key.as_deref().and_then(olid)?,
|
||||
"olid",
|
||||
Source::OpenLibrarySearch,
|
||||
),
|
||||
};
|
||||
|
||||
let mut record = source::blank(&id, kind, Source::OpenLibrarySearch);
|
||||
record.title = match (work.title, work.subtitle) {
|
||||
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
|
||||
(title, _) => title,
|
||||
@@ -255,30 +356,6 @@ fn candidate(work: Work) -> Option<Record> {
|
||||
record.authors = work.author_name;
|
||||
record.subjects = work.subject;
|
||||
record.subjects.truncate(SUBJECT_LIMIT);
|
||||
record.pages = work.number_of_pages_median;
|
||||
|
||||
let (publisher, language, year) = match edition {
|
||||
Some(edition) => {
|
||||
if edition_named {
|
||||
record.cover_id = edition
|
||||
.cover_i
|
||||
.and_then(|id| u64::try_from(id).ok())
|
||||
.filter(|id| *id > 0);
|
||||
}
|
||||
(
|
||||
edition.publisher.into_iter().next(),
|
||||
edition.language.into_iter().next(),
|
||||
edition
|
||||
.publish_date
|
||||
.iter()
|
||||
.find_map(|date| source::year_in(date)),
|
||||
)
|
||||
}
|
||||
None => (None, None, None),
|
||||
};
|
||||
record.container = publisher.or_else(|| work.publisher.into_iter().next());
|
||||
record.language = language.or_else(|| work.language.into_iter().next());
|
||||
record.year = year.or(work.first_publish_year);
|
||||
Some(record)
|
||||
}
|
||||
|
||||
@@ -312,8 +389,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Open Library's answer to `title=the first world war&author=howard`,
|
||||
/// recorded 2026-11-13 with the fields above and `limit=2`, the ISBN and
|
||||
/// subject lists shortened.
|
||||
/// recorded 2026-11-13 with `limit=2`, the ISBN and subject lists
|
||||
/// shortened, and with the work's own publishers, ISBNs, years and length,
|
||||
/// which are not asked for and are read past when they come.
|
||||
const HOWARD: &str = r#"{"numFound": 9, "start": 0, "numFoundExact": true, "num_found": 9,
|
||||
"documentation_url": "https://openlibrary.org/dev/docs/api/search", "q": "", "offset": null,
|
||||
"docs": [
|
||||
@@ -420,7 +498,8 @@ mod tests {
|
||||
assert_eq!(first.container.as_deref(), Some("Oxford University Press"));
|
||||
assert_eq!(first.language.as_deref(), Some("eng"));
|
||||
assert_eq!(first.subjects, ["World War, 1914-1918"]);
|
||||
assert_eq!(first.pages, Some(154));
|
||||
// The work's median length is no edition's.
|
||||
assert_eq!(first.pages, None);
|
||||
assert_eq!(first.cover_id, Some(1123124));
|
||||
|
||||
let second = &found[1];
|
||||
@@ -474,17 +553,64 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_work_with_no_edition_named_gives_its_isbn_but_no_cover() {
|
||||
let body = r#"{"docs": [{"key": "/works/OL1W", "title": "A Book",
|
||||
"isbn": ["0-306-40615-2"], "publisher": ["Plenum"], "first_publish_year": 1981}]}"#;
|
||||
fn what_the_edition_does_not_give_is_left_out_not_taken_from_the_work() {
|
||||
let body = r#"{"docs": [{"key": "/works/OL267171W", "title": "War and Peace",
|
||||
"author_name": ["Leo Tolstoy"], "subject": ["Napoleonic Wars"],
|
||||
"first_publish_year": 1867, "publisher": ["Crowell"], "language": ["eng"],
|
||||
"number_of_pages_median": 1296,
|
||||
"editions": {"docs": [{"key": "/books/OL1M", "isbn": ["9780199232765"],
|
||||
"cover_i": 5}]}}]}"#;
|
||||
let found = open_library(body, 5).expect("candidates");
|
||||
let only = &found[0];
|
||||
assert_eq!(
|
||||
(found[0].id.as_str(), found[0].kind.as_str()),
|
||||
("9780306406157", "isbn")
|
||||
(only.id.as_str(), only.kind.as_str()),
|
||||
("9780199232765", "isbn")
|
||||
);
|
||||
assert_eq!(found[0].container.as_deref(), Some("Plenum"));
|
||||
assert_eq!(found[0].year, Some(1981));
|
||||
assert_eq!(found[0].cover_id, None);
|
||||
assert_eq!(only.cover_id, Some(5));
|
||||
// 1867 and Crowell are other editions'.
|
||||
assert_eq!(only.year, None);
|
||||
assert_eq!(only.container, None);
|
||||
assert_eq!(only.language, None);
|
||||
assert_eq!(only.pages, None);
|
||||
// What describes the work is the work's.
|
||||
assert_eq!(only.title.as_deref(), Some("War and Peace"));
|
||||
assert_eq!(only.authors, ["Leo Tolstoy"]);
|
||||
assert_eq!(only.subjects, ["Napoleonic Wars"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_work_with_no_edition_named_is_named_by_its_key_and_says_nothing_an_edition_would() {
|
||||
let work = r#""key": "/works/OL1W", "title": "A Book", "author_name": ["A. Writer"],
|
||||
"isbn": ["0-306-40615-2"], "publisher": ["Plenum"], "first_publish_year": 1981,
|
||||
"language": ["eng"], "number_of_pages_median": 300"#;
|
||||
// No edition, and an edition with neither an ISBN nor a key.
|
||||
for editions in [
|
||||
"",
|
||||
r#", "editions": {"docs": [{"publisher": ["Plenum"], "cover_i": 7}]}"#,
|
||||
] {
|
||||
let body = format!(r#"{{"docs": [{{{work}{editions}}}]}}"#);
|
||||
let found = open_library(&body, 5).expect("candidates");
|
||||
let only = &found[0];
|
||||
// Not 9780306406157: which edition that is, the index does not say.
|
||||
assert_eq!(
|
||||
(only.id.as_str(), only.kind.as_str()),
|
||||
("OL1W", "olid"),
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(only.title.as_deref(), Some("A Book"));
|
||||
assert_eq!(only.authors, ["A. Writer"]);
|
||||
assert_eq!(
|
||||
(
|
||||
only.year,
|
||||
only.container.as_deref(),
|
||||
only.language.as_deref(),
|
||||
only.pages,
|
||||
only.cover_id
|
||||
),
|
||||
(None, None, None, None, None),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -545,8 +671,19 @@ mod tests {
|
||||
recorded(&mut asked, &bodies),
|
||||
)
|
||||
.expect("candidates");
|
||||
assert_eq!(asked.len(), 2);
|
||||
assert!(asked[1].starts_with("https://www.googleapis.com/books/v1/volumes?q="));
|
||||
// The title as given, without its article, as free text, then Google.
|
||||
assert_eq!(asked.len(), 4, "{asked:?}");
|
||||
assert!(
|
||||
asked[1].contains("?title=Google%20Story&author=Vise&"),
|
||||
"{}",
|
||||
asked[1]
|
||||
);
|
||||
assert!(
|
||||
asked[2].contains("?q=The%20Google%20Story%20Vise&"),
|
||||
"{}",
|
||||
asked[2]
|
||||
);
|
||||
assert!(asked[3].starts_with("https://www.googleapis.com/books/v1/volumes?q="));
|
||||
|
||||
assert_eq!(found.len(), 2);
|
||||
let first = &found[0];
|
||||
@@ -593,15 +730,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_library_having_nothing_stands_when_google_books_is_down() {
|
||||
fn google_books_down_after_open_library_had_nothing_is_a_failure() {
|
||||
let mut asked = Vec::new();
|
||||
let bodies = [
|
||||
("openlibrary.org", Ok(NOTHING)),
|
||||
("googleapis.com", Err(down())),
|
||||
];
|
||||
let found =
|
||||
search_with("Nothing", None, 5, recorded(&mut asked, &bodies)).expect("an answer");
|
||||
assert!(found.is_empty());
|
||||
let failed = search_with("Nothing", None, 5, recorded(&mut asked, &bodies));
|
||||
assert!(matches!(failed, Err(Error::Unreachable(_))), "{failed:?}");
|
||||
// By title, as free text, then Google.
|
||||
assert_eq!(asked.len(), 3, "{asked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_books_refusing_for_its_quota_is_said_to_be_that() {
|
||||
let mut asked = Vec::new();
|
||||
let limited = refused(&google_url("Nothing", None, 5).expect("a url"), 429);
|
||||
let bodies = [
|
||||
("openlibrary.org", Ok(NOTHING)),
|
||||
("googleapis.com", Err(limited.expect("a refusal"))),
|
||||
];
|
||||
let failed = search_with("Nothing", None, 5, recorded(&mut asked, &bodies));
|
||||
assert!(matches!(failed, Err(Error::Limited(_))), "{failed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_google_books_quota_statuses_are_refusals() {
|
||||
let google = google_url("Anathem", None, 5).expect("a url");
|
||||
for status in [403, 429] {
|
||||
assert!(
|
||||
matches!(refused(&google, status), Some(Error::Limited(_))),
|
||||
"{status}"
|
||||
);
|
||||
}
|
||||
for status in [400, 500, 503] {
|
||||
assert!(refused(&google, status).is_none(), "{status}");
|
||||
}
|
||||
let open_library = open_library_url("Anathem", None, 5);
|
||||
for status in [403, 429] {
|
||||
assert!(refused(&open_library, status).is_none(), "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -650,8 +818,50 @@ mod tests {
|
||||
];
|
||||
search_with("Anathem", Some(" "), 5, recorded(&mut asked, &bodies)).expect("ok");
|
||||
assert!(!asked[0].contains("author="), "{}", asked[0]);
|
||||
assert!(!asked[1].contains("inauthor"), "{}", asked[1]);
|
||||
assert!(asked[1].contains("q=intitle%3AAnathem&"), "{}", asked[1]);
|
||||
assert!(asked[1].contains("?q=Anathem&"), "{}", asked[1]);
|
||||
assert!(!asked[2].contains("inauthor"), "{}", asked[2]);
|
||||
assert!(asked[2].contains("q=intitle%3AAnathem&"), "{}", asked[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_article_is_dropped_when_the_title_as_given_finds_nothing() {
|
||||
let mut asked = Vec::new();
|
||||
let mut answered = 0;
|
||||
let found = search_with(
|
||||
"The Ladder of Divine Ascent",
|
||||
Some("John Climacus"),
|
||||
5,
|
||||
|url: &str| {
|
||||
asked.push(url.to_string());
|
||||
answered += 1;
|
||||
Ok(Some(
|
||||
if answered == 2 { TOLSTOY } else { NOTHING }.to_string(),
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect("candidates");
|
||||
assert!(!found.is_empty());
|
||||
assert_eq!(
|
||||
asked.len(),
|
||||
2,
|
||||
"nothing is asked after a question finds something"
|
||||
);
|
||||
assert!(
|
||||
asked[1].contains("?title=Ladder%20of%20Divine%20Ascent&author=John%20Climacus&"),
|
||||
"{}",
|
||||
asked[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_leading_article_with_words_after_it_is_dropped() {
|
||||
assert_eq!(without_article("The Republic"), Some("Republic"));
|
||||
assert_eq!(without_article("an Essay"), Some("Essay"));
|
||||
assert_eq!(without_article("A"), None);
|
||||
assert_eq!(without_article("The "), None);
|
||||
assert_eq!(without_article("Theology"), None);
|
||||
assert_eq!(without_article("Anathem"), None);
|
||||
assert_eq!(open_library_urls("Anathem", None, 5).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -723,7 +933,9 @@ intitle:y intitle:z inauthor:O'Brien inauthor:Sons";
|
||||
let found = search_with("?!", Some("Someone"), 5, recorded(&mut asked, &bodies))
|
||||
.expect("an answer");
|
||||
assert!(found.is_empty());
|
||||
assert_eq!(asked.len(), 1);
|
||||
// By title and as free text; never Google.
|
||||
assert_eq!(asked.len(), 2, "{asked:?}");
|
||||
assert!(asked.iter().all(|url| url.contains("openlibrary.org")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -744,13 +956,25 @@ intitle:y intitle:z inauthor:O'Brien inauthor:Sons";
|
||||
let found =
|
||||
search_with("The Google Story", None, 1, recorded(&mut asked, &bodies)).expect("ok");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(asked[0].ends_with("&limit=1"), "{}", asked[0]);
|
||||
assert!(asked[1].ends_with("&maxResults=1"), "{}", asked[1]);
|
||||
let google = asked.last().expect("Google was asked");
|
||||
assert!(
|
||||
asked[..asked.len() - 1]
|
||||
.iter()
|
||||
.all(|url| url.ends_with("&limit=1")),
|
||||
"{asked:?}"
|
||||
);
|
||||
assert!(google.ends_with("&maxResults=1"), "{google}");
|
||||
|
||||
let mut asked = Vec::new();
|
||||
search_with("The Google Story", None, 500, recorded(&mut asked, &bodies)).expect("ok");
|
||||
assert!(asked[0].ends_with("&limit=40"), "{}", asked[0]);
|
||||
assert!(asked[1].ends_with("&maxResults=40"), "{}", asked[1]);
|
||||
let google = asked.last().expect("Google was asked");
|
||||
assert!(
|
||||
asked[..asked.len() - 1]
|
||||
.iter()
|
||||
.all(|url| url.ends_with("&limit=40")),
|
||||
"{asked:?}"
|
||||
);
|
||||
assert!(google.ends_with("&maxResults=40"), "{google}");
|
||||
|
||||
let mut asked = Vec::new();
|
||||
let found =
|
||||
|
||||
+119
-33
@@ -17,9 +17,10 @@ pub enum Source {
|
||||
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.
|
||||
/// OpenLibrary's index, rather than its edition record. Asked for what is
|
||||
/// true of the work — the title, the authors by name, the subjects —
|
||||
/// because an edition names its authors by key and the index names them.
|
||||
/// Never for what differs between editions.
|
||||
OpenLibrarySearch,
|
||||
/// Google Books, for books by ISBN. Asked last, because it answers
|
||||
/// anonymous callers only until a daily quota runs out.
|
||||
@@ -55,14 +56,14 @@ impl Source {
|
||||
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.
|
||||
// The index first, for what is true of the work: the title, the
|
||||
// authors by name, the subjects. Then the edition record, for
|
||||
// what is true of this edition only: the publisher, the language,
|
||||
// the year and the length. The index answers about the work, and
|
||||
// its publishers, languages and years are every edition's at
|
||||
// once, so they are not asked of it; the lookup goes on to the
|
||||
// edition record for them. Google Books last, for whatever the
|
||||
// edition record does not say.
|
||||
"isbn" => &[
|
||||
Source::OpenLibrarySearch,
|
||||
Source::OpenLibrary,
|
||||
@@ -83,15 +84,16 @@ impl Source {
|
||||
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.
|
||||
// Only what is true of the work. Not `publisher`, `language`,
|
||||
// `first_publish_year` or `number_of_pages_median`: those are every
|
||||
// edition's at once, and the first of each is usually another
|
||||
// edition's (the Penguin *War and Peace* came back Spanish, from
|
||||
// W.W. Norton, in 1845). Not `cover_i`: that is the cover chosen
|
||||
// for the whole work, often 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",
|
||||
&fields=title,subtitle,author_name,subject&limit=1",
|
||||
encode(id)
|
||||
),
|
||||
Source::GoogleBooks => format!(
|
||||
@@ -295,6 +297,8 @@ struct SearchReply {
|
||||
docs: Vec<SearchDoc>,
|
||||
}
|
||||
|
||||
/// What the index says about a work. Only the fields true of every edition
|
||||
/// are read, so an answer that carries the others anyway cannot leak them.
|
||||
#[derive(Deserialize)]
|
||||
struct SearchDoc {
|
||||
#[serde(default)]
|
||||
@@ -304,15 +308,7 @@ struct SearchDoc {
|
||||
#[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.
|
||||
@@ -324,6 +320,11 @@ 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.
|
||||
///
|
||||
/// The record says what is true of the work and nothing that belongs to one
|
||||
/// edition: no publisher, language, year or page count. Those come from the
|
||||
/// edition record, which [`crate::ask::lookup`] asks next because a record
|
||||
/// without them is never complete.
|
||||
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 {
|
||||
@@ -336,14 +337,10 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
|
||||
(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)
|
||||
}
|
||||
|
||||
@@ -475,9 +472,8 @@ mod tests {
|
||||
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.
|
||||
// The index first, for the authors by name; the edition record names
|
||||
// them by key, and is asked next for what only the edition knows.
|
||||
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);
|
||||
@@ -599,6 +595,96 @@ mod tests {
|
||||
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
|
||||
}
|
||||
|
||||
/// What the index said for ISBN 9780140447934, the Penguin Classics *War
|
||||
/// and Peace*, on 2026-11-13, asked for the fields the lookup asked for
|
||||
/// before #6: the publishers, languages and first year of every edition
|
||||
/// of the work.
|
||||
const WAR_AND_PEACE_INDEX: &str = r#"{"docs":[{"title":"War and Peace",
|
||||
"author_name":["Leo Tolstoy"],"first_publish_year":1845,
|
||||
"publisher":["W.W. Norton","IndyPublish.com","Canterbury Classics"],
|
||||
"language":["spa","gre","dut","eng"],"subject":["Fiction"],
|
||||
"number_of_pages_median":1296}]}"#;
|
||||
|
||||
/// The edition record for the same ISBN, `/isbn/9780140447934.json`, with
|
||||
/// the English language its `languages` field should name.
|
||||
const WAR_AND_PEACE_EDITION: &str = r#"{"title":"War and Peace (Penguin Classics)",
|
||||
"publishers":["Penguin Books"],"publish_date":"September 27, 2007",
|
||||
"languages":[{"key":"/languages/eng"}],"number_of_pages":1440}"#;
|
||||
|
||||
/// Index first, then the edition record, merged as `ask::lookup` merges.
|
||||
fn looked_up(edition: &str) -> Record {
|
||||
let mut kept =
|
||||
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
|
||||
kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_says_nothing_only_an_edition_can() {
|
||||
let record =
|
||||
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||
// What is true of the work is kept.
|
||||
assert_eq!(record.title.as_deref(), Some("War and Peace"));
|
||||
assert_eq!(record.authors, ["Leo Tolstoy"]);
|
||||
// What belongs to one edition is not taken from all of them.
|
||||
assert_eq!(record.language, None);
|
||||
assert_eq!(record.container, None);
|
||||
assert_eq!(record.year, None);
|
||||
assert_eq!(record.pages, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lookup_by_isbn_gives_the_editions_language_publisher_and_year() {
|
||||
let record = looked_up(WAR_AND_PEACE_EDITION);
|
||||
assert_eq!(record.language.as_deref(), Some("eng"));
|
||||
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
|
||||
assert_eq!(record.year, Some(2007));
|
||||
assert_eq!(record.pages, Some(1440));
|
||||
assert_eq!(record.authors, ["Leo Tolstoy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_edition_does_not_say_is_missing_rather_than_the_works() {
|
||||
// The real edition record today names no language at all.
|
||||
let record = looked_up(
|
||||
r#"{"title":"War and Peace (Penguin Classics)","publishers":["Penguin Books"],
|
||||
"publish_date":"September 27, 2007","number_of_pages":1440}"#,
|
||||
);
|
||||
assert_eq!(record.language, None);
|
||||
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
|
||||
assert_eq!(record.year, Some(2007));
|
||||
// Nor a date: the work's first year is not this edition's.
|
||||
let record = looked_up(r#"{"title":"War and Peace (Penguin Classics)"}"#);
|
||||
assert_eq!(record.year, None);
|
||||
assert_eq!(record.container, None);
|
||||
assert_eq!(record.pages, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_alone_never_ends_a_lookup() {
|
||||
// `lookup` stops at the first complete record; the index must not be
|
||||
// one, or the edition record is never asked.
|
||||
let record =
|
||||
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||
assert!(!record.complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_is_not_asked_for_what_its_editions_differ_in() {
|
||||
let url = Source::OpenLibrarySearch.url("9780140447934");
|
||||
for field in [
|
||||
"publisher",
|
||||
"language",
|
||||
"first_publish_year",
|
||||
"number_of_pages_median",
|
||||
] {
|
||||
assert!(
|
||||
!url.split(['=', ',', '&']).any(|f| f == field),
|
||||
"{field} in {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_is_not_what_it_should_be_says_so() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -130,10 +130,18 @@ An EPUB's is the first of these that reaches an image:
|
||||
2. The item `<meta name="cover" content="…">` names (EPUB 2). Some
|
||||
converters write the image's address there rather than its id, and that is
|
||||
followed too.
|
||||
3. The guide's `<reference type="cover">`, a page, and the first `<img src>`
|
||||
or SVG `<image href>` on it. A guide that points straight at an image is
|
||||
taken at its word.
|
||||
4. Any image in the manifest whose id or address says `cover`.
|
||||
3. The guide's `<reference type="cover">`, a page, and the `<img src>` or
|
||||
SVG `<image href>` on it. Where the page has several, the one whose file
|
||||
name, `alt`, `id` or `class` says `cover` is taken; where none does, the
|
||||
first is, with a note that there were others. A guide that points straight
|
||||
at an image is taken at its word.
|
||||
4. Any image in the manifest whose id or file name says `cover`, one named
|
||||
`cover` outright first and the rest in manifest order.
|
||||
|
||||
A name says `cover` when it has it as a word of its own, in any case. Words
|
||||
break at anything not a letter or digit and where a lower case letter meets a
|
||||
capital, so `CoverImage`, `Book_COVER` and `cover-thumb` say it, and
|
||||
`discover-ch1` and `recovery-map` do not.
|
||||
|
||||
A route that names something unusable — not in the archive, not an image —
|
||||
leaves a note, and the next route is asked. Addresses are resolved against the
|
||||
@@ -145,7 +153,9 @@ What the bytes say beats what the manifest says: a JPEG listed as
|
||||
`image/png` comes back as `image/jpeg`, with a note. A cover larger than
|
||||
`bookmeta::MAX_COVER` (16 MiB) is left out with a note, in either format, and
|
||||
an EPUB's is never decompressed further than that, whatever size the archive
|
||||
claims for it.
|
||||
claims for it. Nor are more than eight members read looking for one,
|
||||
however many candidates the manifest lists: the search stops there, with a
|
||||
note.
|
||||
|
||||
`Cover::guessed` says the file left the choice open: a Kindle book's first
|
||||
image record, or an EPUB image picked because its name says `cover`. Either
|
||||
|
||||
+275
-14
@@ -22,6 +22,13 @@ use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
|
||||
/// document or is trying to be a decompression bomb.
|
||||
const MAX_PACKAGE: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// How many members one search for a cover will decompress.
|
||||
///
|
||||
/// Each is bounded by [`MAX_COVER`], but a manifest can list as many
|
||||
/// cover-named images as it likes, and reading every one of them is time a
|
||||
/// single book is not owed. A real cover is found in one or two reads.
|
||||
const MAX_READS: usize = 8;
|
||||
|
||||
/// Read what an EPUB says about itself.
|
||||
pub fn read(path: &str, bytes: &[u8]) -> Metadata {
|
||||
let mut meta = Metadata::empty(path, Source::Opf);
|
||||
@@ -295,6 +302,8 @@ fn cover<R: std::io::Read + std::io::Seek>(
|
||||
archive,
|
||||
items: &items,
|
||||
tried: Vec::new(),
|
||||
reads: 0,
|
||||
spent: false,
|
||||
meta,
|
||||
};
|
||||
|
||||
@@ -344,12 +353,21 @@ fn cover<R: std::io::Read + std::io::Seek>(
|
||||
}
|
||||
}
|
||||
|
||||
// Last: an image whose id or address says cover.
|
||||
for item in items.iter().filter(|item| {
|
||||
item.media_type.starts_with("image/")
|
||||
&& (item.id.to_lowercase().contains("cover")
|
||||
|| item.href.to_lowercase().contains("cover"))
|
||||
}) {
|
||||
// Last: an image whose id or file name has the word cover in it, one
|
||||
// named `cover` outright first, then in manifest order.
|
||||
let file_stem = |item: &Item| stem(item.path.as_deref().unwrap_or(&item.href)).to_string();
|
||||
let mut named: Vec<&Item> = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.media_type.starts_with("image/")
|
||||
&& (says_cover(&item.id) || says_cover(&file_stem(item)))
|
||||
})
|
||||
.collect();
|
||||
named.sort_by_key(|item| !file_stem(item).eq_ignore_ascii_case("cover"));
|
||||
for item in named {
|
||||
if look.spent {
|
||||
break;
|
||||
}
|
||||
if let Some(mut found) = look.image(base, &item.href) {
|
||||
found.guessed = true;
|
||||
return Some(found);
|
||||
@@ -364,12 +382,19 @@ struct Look<'a, R> {
|
||||
archive: &'a mut zip::ZipArchive<R>,
|
||||
items: &'a [Item],
|
||||
tried: Vec<String>,
|
||||
/// How many members have been decompressed, against [`MAX_READS`].
|
||||
reads: usize,
|
||||
/// Whether the reads have run out, and said so.
|
||||
spent: bool,
|
||||
meta: &'a mut Metadata,
|
||||
}
|
||||
|
||||
impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
|
||||
/// Where an address leads, or a note saying it leads nowhere it may.
|
||||
fn locate(&mut self, base: &str, href: &str) -> Option<String> {
|
||||
if self.spent {
|
||||
return None;
|
||||
}
|
||||
let Some(path) = resolve(base, href) else {
|
||||
self.meta.note(format!(
|
||||
"the cover's address, {href}, is not a path inside the archive"
|
||||
@@ -392,6 +417,11 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
|
||||
|
||||
/// The image a cover page shows, or the page itself where the guide
|
||||
/// points straight at an image.
|
||||
///
|
||||
/// A page with several images may lead with a logo or an ornament, so
|
||||
/// the one whose file name, `alt`, `id` or `class` says cover is taken
|
||||
/// over the first; with none of them saying so, the first is taken, and
|
||||
/// a note says the page had others.
|
||||
fn page(&mut self, base: &str, href: &str) -> Option<Cover> {
|
||||
let path = self.locate(base, href)?;
|
||||
let bytes = self.fetch(&path, MAX_PACKAGE as usize, "cover page")?;
|
||||
@@ -399,23 +429,55 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
|
||||
return self.check(&path, bytes);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let Some(src) = tags(&text).find_map(|(name, tag)| match name {
|
||||
"img" => attr(tag, "src"),
|
||||
"image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")),
|
||||
_ => None,
|
||||
}) else {
|
||||
let images: Vec<(String, bool)> = tags(&text)
|
||||
.filter_map(|(name, tag)| {
|
||||
let src = match name {
|
||||
"img" => attr(tag, "src"),
|
||||
"image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")),
|
||||
_ => None,
|
||||
}?;
|
||||
let named = says_cover(stem(src.split('#').next().unwrap_or("")))
|
||||
|| ["alt", "id", "class"]
|
||||
.iter()
|
||||
.any(|name| attr(tag, name).is_some_and(|value| says_cover(&value)));
|
||||
Some((src, named))
|
||||
})
|
||||
.collect();
|
||||
let src = if let Some((src, _)) = images.iter().find(|(_, named)| *named) {
|
||||
src
|
||||
} else if let Some((first, _)) = images.first() {
|
||||
if images.len() > 1 {
|
||||
self.meta.note(format!(
|
||||
"the cover page, {path}, shows {} images, and the first was taken",
|
||||
images.len()
|
||||
));
|
||||
}
|
||||
first
|
||||
} else {
|
||||
self.meta
|
||||
.note(format!("the cover page, {path}, shows no image"));
|
||||
return None;
|
||||
};
|
||||
self.image(directory_of(&path), &src)
|
||||
self.image(directory_of(&path), src)
|
||||
}
|
||||
|
||||
/// One member's bytes, read no further than `cap`.
|
||||
///
|
||||
/// The size the archive declares is checked first and the read is
|
||||
/// bounded anyway, because the declaration may be a lie.
|
||||
/// bounded anyway, because the declaration may be a lie. No more than
|
||||
/// [`MAX_READS`] members are read in all; the first asked for after
|
||||
/// that ends the search, with a note.
|
||||
fn fetch(&mut self, path: &str, cap: usize, what: &str) -> Option<Vec<u8>> {
|
||||
if self.spent {
|
||||
return None;
|
||||
}
|
||||
if self.reads == MAX_READS {
|
||||
self.spent = true;
|
||||
self.meta.note(format!(
|
||||
"the search for a cover stopped after reading {MAX_READS} members"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let file = match self.archive.by_name(path) {
|
||||
Ok(file) => file,
|
||||
Err(zip::result::ZipError::FileNotFound) => {
|
||||
@@ -434,6 +496,7 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
|
||||
self.meta.note(too_large);
|
||||
return None;
|
||||
}
|
||||
self.reads += 1;
|
||||
let mut bytes = Vec::new();
|
||||
if file.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() {
|
||||
self.meta
|
||||
@@ -480,6 +543,50 @@ fn directory_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(dir, _)| dir)
|
||||
}
|
||||
|
||||
/// A path's file name without its extension.
|
||||
fn stem(path: &str) -> &str {
|
||||
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a name has `cover` in it as a word of its own, in any case.
|
||||
///
|
||||
/// Words are split at anything not a letter or a digit and where a lower
|
||||
/// case letter meets a capital, so `CoverImage`, `Book_COVER` and
|
||||
/// `cover-thumb` say cover, and `discover` and `recovery` do not.
|
||||
fn says_cover(name: &str) -> bool {
|
||||
words(name)
|
||||
.iter()
|
||||
.any(|word| word.eq_ignore_ascii_case("cover"))
|
||||
}
|
||||
|
||||
/// The words of a name, as [`says_cover`] splits them.
|
||||
fn words(name: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
let mut prev = ' ';
|
||||
for (at, c) in name.char_indices() {
|
||||
let begins =
|
||||
c.is_alphanumeric() && (start.is_none() || (prev.is_lowercase() && c.is_uppercase()));
|
||||
if !c.is_alphanumeric() || begins {
|
||||
if let Some(from) = start.take() {
|
||||
out.push(&name[from..at]);
|
||||
}
|
||||
}
|
||||
if begins {
|
||||
start = Some(at);
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
if let Some(from) = start {
|
||||
out.push(&name[from..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where an address written in a member in `base` leads inside the archive.
|
||||
///
|
||||
/// The fragment comes off and percent-escapes are undone. An address with a
|
||||
@@ -523,7 +630,13 @@ fn percent_decode(text: &str) -> Option<String> {
|
||||
let mut at = 0;
|
||||
while at < raw.len() {
|
||||
if raw[at] == b'%' {
|
||||
let hex = std::str::from_utf8(raw.get(at + 1..at + 3)?).ok()?;
|
||||
// Two hex digits and nothing else: `from_str_radix` would also
|
||||
// take a sign, and read `%+f` as 15.
|
||||
let hex = raw.get(at + 1..at + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
let hex = std::str::from_utf8(hex).ok()?;
|
||||
out.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
at += 3;
|
||||
} else {
|
||||
@@ -878,6 +991,147 @@ mod tests {
|
||||
assert_eq!(meta.cover.expect("a cover").bytes, JPEG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_says_cover_only_as_a_word_of_its_own() {
|
||||
for yes in [
|
||||
"cover",
|
||||
"CoverImage",
|
||||
"Book_COVER",
|
||||
"cover-thumb",
|
||||
"frontCover",
|
||||
"x.cover",
|
||||
] {
|
||||
assert!(says_cover(yes), "{yes}");
|
||||
}
|
||||
for no in [
|
||||
"discover-ch1",
|
||||
"recovery-map",
|
||||
"covers",
|
||||
"Coverage",
|
||||
"uncover",
|
||||
"",
|
||||
] {
|
||||
assert!(!says_cover(no), "{no}");
|
||||
}
|
||||
assert_eq!(words("CoverImage_v2"), ["Cover", "Image", "v2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_only_contains_cover_is_not_a_cover() {
|
||||
let package = opf(
|
||||
r#"<item id="ch1" href="images/discover-ch1.png" media-type="image/png"/>
|
||||
<item id="i2" href="images/cover.jpg" media-type="image/jpeg"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/images/discover-ch1.png", PNG),
|
||||
("OEBPS/images/cover.jpg", JPEG),
|
||||
],
|
||||
);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.bytes, JPEG);
|
||||
assert!(cover.guessed);
|
||||
|
||||
let alone = opf(
|
||||
r#"<item id="map" href="images/recovery-map.png" media-type="image/png"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&alone, &[("OEBPS/images/recovery-map.png", PNG)]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_named_cover_outright_beats_one_listed_before_it() {
|
||||
let package = opf(
|
||||
r#"<item id="cover-thumb" href="images/thumb.png" media-type="image/png"/>
|
||||
<item id="i2" href="images/Cover.jpg" media-type="image/jpeg"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/images/thumb.png", PNG),
|
||||
("OEBPS/images/Cover.jpg", JPEG),
|
||||
],
|
||||
);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.bytes, JPEG);
|
||||
assert!(cover.guessed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guide_cover_page_prefers_the_image_that_says_cover() {
|
||||
let package = opf(
|
||||
"",
|
||||
r#"<guide><reference type="cover" href="text/cover.xhtml"/></guide>"#,
|
||||
);
|
||||
for art in [
|
||||
r#"<img src="../images/front-cover.jpg"/>"#,
|
||||
r#"<img alt="Cover" src="../images/front-cover.jpg"/>"#,
|
||||
r#"<img id="coverImage" src="../images/front-cover.jpg"/>"#,
|
||||
r#"<img class="full cover" src="../images/front-cover.jpg"/>"#,
|
||||
] {
|
||||
let page = format!(r#"<html><body><img src="../images/logo.png"/>{art}</body></html>"#);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/text/cover.xhtml", page.as_bytes()),
|
||||
("OEBPS/images/logo.png", PNG),
|
||||
("OEBPS/images/front-cover.jpg", JPEG),
|
||||
],
|
||||
);
|
||||
assert_eq!(meta.cover.expect(art).bytes, JPEG, "{art}");
|
||||
assert!(meta.notes.is_empty(), "{art}: {:?}", meta.notes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guide_cover_page_with_several_unnamed_images_takes_the_first_and_says_so() {
|
||||
let package = opf(
|
||||
"",
|
||||
r#"<guide><reference type="cover" href="text/cover.xhtml"/></guide>"#,
|
||||
);
|
||||
let page = br#"<html><body><img src="../images/logo.png"/><img src="../images/art.jpg"/></body></html>"#;
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/text/cover.xhtml", page),
|
||||
("OEBPS/images/logo.png", PNG),
|
||||
("OEBPS/images/art.jpg", JPEG),
|
||||
],
|
||||
);
|
||||
assert_eq!(meta.cover.expect("a cover").bytes, PNG);
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the cover page, OEBPS/text/cover.xhtml, shows 2 images, and the first was taken"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_for_a_cover_reads_no_more_than_its_budget() {
|
||||
let count = MAX_READS + 4;
|
||||
let manifest: String = (0..count)
|
||||
.map(|n| format!(r#"<item id="cover-{n}" href="c{n}.jpg" media-type="image/jpeg"/>"#))
|
||||
.collect();
|
||||
let names: Vec<String> = (0..count).map(|n| format!("OEBPS/c{n}.jpg")).collect();
|
||||
let members: Vec<(&str, &[u8])> = names
|
||||
.iter()
|
||||
.map(|name| (name.as_str(), &b"<html>not an image</html>"[..]))
|
||||
.collect();
|
||||
let meta = book(&opf(&manifest, ""), &members);
|
||||
assert_eq!(meta.cover, None);
|
||||
let mut expected: Vec<String> = (0..MAX_READS)
|
||||
.map(|n| format!("the cover, OEBPS/c{n}.jpg, is not an image"))
|
||||
.collect();
|
||||
expected.push(format!(
|
||||
"the search for a cover stopped after reading {MAX_READS} members"
|
||||
));
|
||||
assert_eq!(meta.notes, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_that_names_no_cover_has_none_and_says_nothing() {
|
||||
let package = opf(
|
||||
@@ -938,6 +1192,8 @@ mod tests {
|
||||
archive: &mut archive,
|
||||
items: &[],
|
||||
tried: Vec::new(),
|
||||
reads: 0,
|
||||
spent: false,
|
||||
meta: &mut meta,
|
||||
};
|
||||
let got = look.fetch("big.jpg", cap, "cover").map(|bytes| bytes.len());
|
||||
@@ -1030,6 +1286,8 @@ mod tests {
|
||||
Some("OEBPS/text/b.jpg")
|
||||
);
|
||||
assert_eq!(resolve("", "a%2Fb.jpg").as_deref(), Some("a/b.jpg"));
|
||||
assert_eq!(percent_decode("a%2fb%2F").as_deref(), Some("a/b/"));
|
||||
assert_eq!(percent_decode("a%+fb"), None);
|
||||
for hostile in [
|
||||
"../../c.jpg",
|
||||
"../../../etc/passwd",
|
||||
@@ -1038,6 +1296,9 @@ mod tests {
|
||||
"file:///etc/passwd",
|
||||
"%ZZ.jpg",
|
||||
"%ff.jpg",
|
||||
"a%+fb",
|
||||
"a%-1b",
|
||||
"a% fb",
|
||||
"c%",
|
||||
"#only-a-fragment",
|
||||
"",
|
||||
|
||||
Reference in new issue
Block a user